diff --git a/.gitignore b/.gitignore index 502ad6ed..6ebd40ff 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,18 @@ release-builds/ scripts/i18n_report* *wallet-storage.json +# Local dev/preview console captures +devserver*.err +devserver*.out +preview*.err +preview*.out +previewserver*.err +previewserver*.out + +# Local external reference workspaces +/external/qapp-core-inspect/ +/external/quittersource/ + squashfs-root/ appimagetool-x86_64.AppImage *.bak diff --git a/electron/package.json b/electron/package.json index b535920a..4ff505a7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -45,11 +45,13 @@ "electron-unhandled": "~4.0.1", "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", - "extract-zip": "^2.0.1" + "extract-zip": "^2.0.1", + "write-file-atomic": "^7.0.1" }, "devDependencies": { "@electron/notarize": "^2.5.0", "@electron/rebuild": "^3.7.2", + "@types/write-file-atomic": "^4.0.3", "electron": "^32.3.1", "electron-builder": "^25.1.8", "shelljs": "^0.8.5", diff --git a/electron/src/core.ts b/electron/src/core.ts index cafe0b10..f061ff9a 100644 --- a/electron/src/core.ts +++ b/electron/src/core.ts @@ -1825,13 +1825,12 @@ export async function downloadCoreWindows() { export async function installCore(executeProgress) { executeProgress(); - return new Promise(async (res, rej) => { - if (process.platform === 'win32') { - await downloadCoreWindows(); - } else { - await javaversion(); - } - }); + if (process.platform === 'win32') { + await downloadCoreWindows(); + } else { + await javaversion(); + } + return true; } export async function startCore() { @@ -2184,6 +2183,28 @@ export async function bootstrap(): Promise { } } +/** When Core can reach `/admin/bootstrap`, runs existing {@link bootstrap}. Otherwise deletes chain `db` (if needed) and {@link startCore}. */ +export async function bootstrapOrClearChainAndStart(): Promise { + try { + const isInstalled = await isCoreInstalled(); + if (!isInstalled) return false; + + const running = await isCoreRunning(); + if (running) { + const bootOk = await bootstrap(); + if (bootOk) return true; + } + + const delOk = await deleteDB(); + if (!delOk && (await dbExists())) return false; + + await startCore(); + return true; + } catch (error) { + return false; + } +} + const rmAsync = promisify(fs.rm ?? fs.rmdir); // Node 14+ supports fs.rm async function readJsonIfExists(file: string): Promise { diff --git a/electron/src/index.ts b/electron/src/index.ts index f5ba3515..441bee6d 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -17,6 +17,9 @@ import { import { log as loggerLog, error as loggerError } from './logger'; import { ElectronCapacitorApp, + flushMiscPersistentStore, + flushPersistentStore, + loadPersistedAllowedDomainsAtStartup, setupContentSecurityPolicy, setupReloadWatcher, } from './setup'; @@ -141,9 +144,7 @@ async function setupMultiInstanceUserData(basePort = 55000, maxInstances = 10) { if (!(await isPortTaken(port))) { // First instance — use default Electron behavior if (i === 0) { - loggerLog( - `🟢 Using default userData path: ${app.getPath('userData')}` - ); + loggerLog(`🟢 Using default userData path: ${app.getPath('userData')}`); } else { const instanceName = `qortal-instance-${i + 1}`; const userDataPath = path.join(app.getPath('appData'), instanceName); @@ -163,10 +164,12 @@ async function setupMultiInstanceUserData(basePort = 55000, maxInstances = 10) { // Run Application (async () => { - setupMultiInstanceUserData(); + await setupMultiInstanceUserData(); await app.whenReady(); + loadPersistedAllowedDomainsAtStartup(); + // Set Content Security Policy setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme()); @@ -193,6 +196,8 @@ async function setupMultiInstanceUserData(basePort = 55000, maxInstances = 10) { // Set isQuitting flag before the app quits app.on('before-quit', () => { setIsQuitting(true); + flushPersistentStore(); + flushMiscPersistentStore(); }); // Handle when all of our windows are close (platforms have their own expectations). diff --git a/electron/src/preload.ts b/electron/src/preload.ts index ed204df6..a15b0b91 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -8,7 +8,23 @@ import { contextBridge, shell, ipcRenderer } from 'electron'; try { // Expose Electron API contextBridge.exposeInMainWorld('electronAPI', { - openExternal: (url) => shell.openExternal(url), + openExternal: (url: string) => { + if (typeof url !== 'string') { + return; + } + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return; + } + if (!parsed.hostname) { + return; + } + void shell.openExternal(parsed.toString()); + } catch { + // Invalid URL + } + }, setAllowedDomains: (domains) => { ipcRenderer.send('set-allowed-domains', domains); }, @@ -18,8 +34,18 @@ try { windowMinimize: () => ipcRenderer.invoke('window:minimize'), windowMaximize: () => ipcRenderer.invoke('window:maximize'), windowClose: () => ipcRenderer.invoke('window:close'), + focusWindow: () => ipcRenderer.invoke('window:focus'), getWindowState: () => ipcRenderer.invoke('window:isMaximized').then((isMaximized: boolean) => ({ isMaximized })), + onWindowStateChange: (callback: (state: { isMaximized: boolean }) => void) => { + const handler = (_event, isMaximized: boolean) => { + callback({ isMaximized }); + }; + ipcRenderer.on('window:state-changed', handler); + return () => { + ipcRenderer.removeListener('window:state-changed', handler); + }; + }, getPlatform: () => ipcRenderer.invoke('window:getPlatform'), showAppMenu: (x?: number, y?: number) => ipcRenderer.invoke('window:showAppMenu', { x, y }), @@ -48,6 +74,31 @@ try { ipcRenderer.invoke('file:deleteFile', filePath), }); + // Generic persistent store (persistent-store.json, in-memory cache + debounced writes in main) + contextBridge.exposeInMainWorld('appStorage', { + get: async (key) => { + return ipcRenderer.invoke('persistentStore:get', key); + }, + set: async (key, value) => { + return ipcRenderer.invoke('persistentStore:set', key, value); + }, + delete: async (key) => { + return ipcRenderer.invoke('persistentStore:delete', key); + }, + }); + + contextBridge.exposeInMainWorld('miscStorage', { + get: async (key) => { + return ipcRenderer.invoke('miscPersistentStore:get', key); + }, + set: async (key, value) => { + return ipcRenderer.invoke('miscPersistentStore:set', key, value); + }, + delete: async (key) => { + return ipcRenderer.invoke('miscPersistentStore:delete', key); + }, + }); + // Expose it contextBridge.exposeInMainWorld('walletStorage', { get: async (key) => { @@ -148,6 +199,12 @@ try { const raw = await ipcRenderer.invoke('coreSetup:bootstrap'); return raw; }, + bootstrapOrClearChainAndStart: async () => { + const raw = await ipcRenderer.invoke( + 'coreSetup:bootstrapOrClearChainAndStart' + ); + return raw; + }, onProgress: (cb: (p: any) => void) => { const h = (_e: unknown, p: any) => cb(p); ipcRenderer.on('coreSetup:progress', h); diff --git a/electron/src/setup.ts b/electron/src/setup.ts index dc136801..78eddd97 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -25,6 +25,7 @@ import { log as loggerLog, error as loggerError } from './logger'; import { myCapacitorApp, isQuitting, setIsQuitting } from '.'; import { bootstrap, + bootstrapOrClearChainAndStart, customQortalInstalledDir, dbExists, deleteDB, @@ -55,6 +56,8 @@ import { const AdmZip = require('adm-zip'); const fs = require('fs'); const path = require('path'); +const writeFileAtomic = require('write-file-atomic'); + const defaultDomains = [ 'capacitor-electron://-', @@ -80,6 +83,80 @@ const defaultDomains = [ const domainHolder = { allowedDomains: [...defaultDomains], }; + +/** Same path layout as `getSharedSettingsFilePath('wallet-storage.json')` (preload `walletStorage`). */ +function getWalletStorageJsonPathSync(): string { + return path.join(app.getPath('appData'), 'qortal-hub', 'wallet-storage.json'); +} + +function readCustomNodeUrlsFromWalletStorageFile(): string[] { + try { + const filePath = getWalletStorageJsonPathSync(); + if (!fs.existsSync(filePath)) return []; + const raw = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as { customNodes?: unknown }; + const nodes = data?.customNodes; + if (!Array.isArray(nodes)) return []; + return nodes + .map((n: { url?: unknown }) => + typeof n?.url === 'string' ? n.url.trim() : '' + ) + .filter(Boolean); + } catch { + return []; + } +} + +function mergeUserDomainsIntoAllowlist(domains: string[]): string[] { + const validatedUserDomains = domains + .flatMap((domain) => { + try { + const url = new URL(domain); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const socketUrl = `${protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`; + return [url.origin, socketUrl]; + } catch { + return []; + } + }) + .filter(Boolean) as string[]; + + return [...new Set([...defaultDomains, ...validatedUserDomains])]; +} + +function applyAllowedDomainsFromUserUrls( + domains: string[], + options: { reloadWindow: boolean } +): void { + if (!Array.isArray(domains)) { + return; + } + const newAllowedDomains = mergeUserDomainsIntoAllowlist(domains); + const sortedCurrentDomains = [...domainHolder.allowedDomains].sort(); + const sortedNewDomains = [...newAllowedDomains].sort(); + const hasChanged = + sortedCurrentDomains.length !== sortedNewDomains.length || + sortedCurrentDomains.some( + (domain, index) => domain !== sortedNewDomains[index] + ); + + if (hasChanged) { + domainHolder.allowedDomains = newAllowedDomains; + + if (options.reloadWindow) { + const mainWindow = myCapacitorApp.getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.reload(); + } + } + } +} + +/** Apply custom node URLs from wallet storage before the web app loads (no window reload). */ +export function loadPersistedAllowedDomainsAtStartup(): void { + const urls = readCustomNodeUrlsFromWalletStorageFile(); + applyAllowedDomainsFromUserUrls(urls, { reloadWindow: false }); +} // Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode. const reloadWatcher = { debouncer: null, @@ -200,6 +277,12 @@ export class ElectronCapacitorApp { }, }); this.mainWindowState.manage(this.MainWindow); + this.MainWindow.on('maximize', () => { + this.MainWindow?.webContents.send('window:state-changed', true); + }); + this.MainWindow.on('unmaximize', () => { + this.MainWindow?.webContents.send('window:state-changed', false); + }); if (this.CapacitorFileConfig.backgroundColor) { this.MainWindow.setBackgroundColor( @@ -395,6 +478,7 @@ export function setupContentSecurityPolicy(customScheme: string): void { customScheme, ...new Set(expandedDomains), ]; + const frameSources = [ "'self'", 'http://localhost:*', @@ -480,45 +564,7 @@ ipcMain.on('set-allowed-domains', (event, domains: string[]) => { if (!Array.isArray(domains)) { return; } - // Validate and transform user-provided domains - const validatedUserDomains = domains - .flatMap((domain) => { - try { - const url = new URL(domain); - const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - const socketUrl = `${protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`; - return [url.origin, socketUrl]; - } catch { - return []; - } - }) - .filter(Boolean) as string[]; - - // Combine default and validated user domains - const newAllowedDomains = [ - ...new Set([...defaultDomains, ...validatedUserDomains]), - ]; - - // Sort both current allowed domains and new domains for comparison - const sortedCurrentDomains = [...domainHolder.allowedDomains].sort(); - const sortedNewDomains = [...newAllowedDomains].sort(); - - // Check if the lists are different - const hasChanged = - sortedCurrentDomains.length !== sortedNewDomains.length || - sortedCurrentDomains.some( - (domain, index) => domain !== sortedNewDomains[index] - ); - - // If there's a change, update allowedDomains and reload the window - if (hasChanged) { - domainHolder.allowedDomains = newAllowedDomains; - - const mainWindow = myCapacitorApp.getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.reload(); - } - } + applyAllowedDomainsFromUserUrls(domains, { reloadWindow: true }); }); // Custom title bar: window controls (minimize, maximize, close) @@ -540,6 +586,14 @@ ipcMain.handle('window:close', () => { if (win && !win.isDestroyed()) win.close(); }); +ipcMain.handle('window:focus', () => { + const win = myCapacitorApp.getMainWindow(); + if (win && !win.isDestroyed()) { + win.show(); + win.focus(); + } +}); + ipcMain.handle('window:isMaximized', () => { const win = myCapacitorApp.getMainWindow(); return win != null && !win.isDestroyed() && win.isMaximized(); @@ -631,6 +685,168 @@ export async function getSharedSettingsFilePath( return path.join(dir, fileName); } +// Persistent store: shared across instances via atomic writes to appData/qortal-hub/ +// Uses write-file-atomic to prevent partial writes corrupting the file. +// On set/delete: read-from-disk → merge → atomic write, so concurrent instances +// never overwrite each other's keys (only a simultaneous write of the *same* key +// by two instances at the exact same moment could still race, which is acceptable). +const PERSISTENT_STORE_FILENAME = 'qortal-persistent-store.json'; +const MISC_PERSISTENT_STORE_FILENAME = 'misc-persist.json'; + +function parsePersistentStoreRaw(raw: string): Record { + const trimmed = raw?.trim() ?? ''; + if (trimmed === '') return {}; + try { + return (JSON.parse(trimmed) as Record) || {}; + } catch (_) { + return {}; + } +} + +function createPersistentJsonStore(fileName: string, label: string) { + let cache: Record | null = null; + let loadedFromDisk = false; + + const getFilePath = (): string => { + const dir = path.join(app.getPath('appData'), 'qortal-hub'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, fileName); + }; + + const readFromDisk = async (): Promise> => { + try { + const filePath = getFilePath(); + const stats = await fs.promises.stat(filePath).catch(() => null); + if (!stats?.isFile()) return {}; + const raw = await fs.promises.readFile(filePath, 'utf-8'); + return parsePersistentStoreRaw(raw); + } catch (err) { + loggerError(`Error reading ${label} from disk`, err); + return {}; + } + }; + + const load = async (): Promise> => { + if (cache !== null) return cache; + const data = await readFromDisk(); + const hadData = Object.keys(data).length > 0; + cache = data; + if (hadData) loadedFromDisk = true; + return cache; + }; + + const flush = (): void => { + if (cache === null) return; + if (!loadedFromDisk && Object.keys(cache).length === 0) { + return; + } + try { + const filePath = getFilePath(); + let onDisk: Record = {}; + if (fs.existsSync(filePath)) { + try { + onDisk = parsePersistentStoreRaw(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + onDisk = {}; + } + } + const merged = { ...onDisk, ...cache }; + writeFileAtomic.sync(filePath, JSON.stringify(merged, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError(`Error flushing ${label}`, err); + } + }; + + const get = async (key: string): Promise => { + const store = await load(); + return store[key]; + }; + + const set = async (key: string, value: unknown): Promise => { + // Read-merge-write: fetch fresh disk state, merge the new key, write atomically. + // This ensures concurrent instances don't clobber each other's unrelated keys. + const onDisk = await readFromDisk(); + onDisk[key] = value; + try { + const filePath = getFilePath(); + await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError(`Error writing ${label} (set)`, err); + } + if (cache === null) cache = {}; + cache[key] = value; + loadedFromDisk = true; + }; + + const deleteKey = async (key: string): Promise => { + // Read-merge-write: fetch fresh disk state, remove the key, write atomically. + const onDisk = await readFromDisk(); + delete onDisk[key]; + try { + const filePath = getFilePath(); + await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError(`Error writing ${label} (delete)`, err); + } + if (cache !== null) delete cache[key]; + }; + + return { deleteKey, flush, get, set }; +} + +const persistentStore = createPersistentJsonStore( + PERSISTENT_STORE_FILENAME, + 'persistent store' +); +const miscPersistentStore = createPersistentJsonStore( + MISC_PERSISTENT_STORE_FILENAME, + 'misc persistent store' +); + +export function flushPersistentStore(): void { + persistentStore.flush(); +} + +export function flushMiscPersistentStore(): void { + miscPersistentStore.flush(); +} + +ipcMain.handle('persistentStore:get', async (_event, key: string) => + persistentStore.get(key) +); + +ipcMain.handle( + 'persistentStore:set', + async (_event, key: string, value: unknown) => { + await persistentStore.set(key, value); + } +); + +ipcMain.handle('persistentStore:delete', async (_event, key: string) => { + await persistentStore.deleteKey(key); +}); + +ipcMain.handle('miscPersistentStore:get', async (_event, key: string) => + miscPersistentStore.get(key) +); + +ipcMain.handle( + 'miscPersistentStore:set', + async (_event, key: string, value: unknown) => { + await miscPersistentStore.set(key, value); + } +); + +ipcMain.handle('miscPersistentStore:delete', async (_event, key: string) => { + await miscPersistentStore.deleteKey(key); +}); + // App settings (stored in SharedSettingsFilePath) - e.g. close/minimize to tray preference const APP_SETTINGS_FILENAME = 'app-settings.json'; @@ -652,7 +868,8 @@ export async function readAppSettings(): Promise { ...DEFAULT_APP_SETTINGS, ...parsed, closeAction: - parsed.closeAction && ['ask', 'minimizeToTray', 'quit'].includes(parsed.closeAction) + parsed.closeAction && + ['ask', 'minimizeToTray', 'quit'].includes(parsed.closeAction) ? (parsed.closeAction as CloseAction) : DEFAULT_APP_SETTINGS.closeAction, }; @@ -663,7 +880,11 @@ export async function readAppSettings(): Promise { async function writeAppSettings(settings: AppSettings): Promise { const filePath = await getSharedSettingsFilePath(APP_SETTINGS_FILENAME); - await fs.promises.writeFile(filePath, JSON.stringify(settings, null, 2), 'utf-8'); + await fs.promises.writeFile( + filePath, + JSON.stringify(settings, null, 2), + 'utf-8' + ); } // READ handler @@ -999,7 +1220,7 @@ ipcMain.handle('coreSetup:installCore', async (event) => { }); } - if (isInstalled) return; + if (isInstalled) return true; const wc = event.sender; const sendProgress = (p) => { @@ -1007,7 +1228,16 @@ ipcMain.handle('coreSetup:installCore', async (event) => { }; const running = await installCore(sendProgress); return running; - } catch (error) {} + } catch (error) { + console.error('Failed to install Qortal Core:', error); + broadcastProgress({ + step: 'downloadedCore', + status: 'error', + progress: 0, + message: '010', + }); + return false; + } }); ipcMain.handle('coreSetup:startCore', async () => { @@ -1084,6 +1314,14 @@ ipcMain.handle('coreSetup:bootstrap', async () => { } }); +ipcMain.handle('coreSetup:bootstrapOrClearChainAndStart', async () => { + try { + return await bootstrapOrClearChainAndStart(); + } catch (error) { + loggerError('error', error); + } +}); + ipcMain.handle('coreSetup:pickQortalDirectory', async () => { try { const { canceled, filePaths } = await dialog.showOpenDialog({ @@ -1095,11 +1333,9 @@ ipcMain.handle('coreSetup:pickQortalDirectory', async () => { if (isInstalled) { const filePath = await getSharedSettingsFilePath('wallet-storage.json'); - const stats = await fs.promises.stat(filePath).catch(() => null); - if (!stats || !stats.isFile()) return null; - - const raw = await fs.promises.readFile(filePath, 'utf-8'); - + const raw = await fs.promises + .readFile(filePath, 'utf-8') + .catch(() => null); const data = raw ? JSON.parse(raw) : {}; data['qortalDirectory'] = dir; await fs.promises.writeFile( @@ -1110,7 +1346,7 @@ ipcMain.handle('coreSetup:pickQortalDirectory', async () => { broadcastProgress({ type: 'hasCustomPath', hasCustomPath: true, - customPath: filePath, + customPath: dir, }); } else return false; } catch (error) { diff --git a/electron/src/video-server.ts b/electron/src/video-server.ts index 3bf80f7f..464a2b4b 100644 --- a/electron/src/video-server.ts +++ b/electron/src/video-server.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import { URL } from 'url'; import { log as loggerLog, error as loggerError } from './logger'; +import { isLocalPrivateHost } from './local-https-cert'; interface EncryptionConfig { key: Buffer; @@ -75,7 +76,7 @@ function decryptChunk( function fetchRange(url: string, start: number, end: number): Promise { return new Promise((resolve, reject) => { const urlObj = new URL(url); - const client = http; + const client = urlObj.protocol === 'https:' && !isLocalPrivateHost(urlObj.hostname) ? https : http; const options: http.RequestOptions = { hostname: urlObj.hostname, diff --git a/package-lock.json b/package-lock.json index cf3296de..d4a7b569 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,10 +46,12 @@ "axios": "^1.13.5", "bcryptjs": "2.4.3", "buffer": "6.0.3", + "canvas-confetti": "^1.9.4", "chokidar": "^3.6.0", "compressorjs": "^1.2.1", "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.0", + "entities": "^8.0.0", "file-saver": "^2.0.5", "framer-motion": "^12.23.24", "html-to-text": "^9.0.5", @@ -1820,14 +1822,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@capacitor-community/electron/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@capacitor/android": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.1.2.tgz", @@ -2024,9 +2018,10 @@ } }, "node_modules/@electron/asar": { - "version": "3.2.15", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.15.tgz", - "integrity": "sha512-AerUbRZpkDVRs58WP32t4U2bx85sfwRkQI8RMIEi6s2NBE++sgjsgAAMtXvnfTISKUkXo386pxFW7sa7WtMCrw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "license": "MIT", "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", @@ -2133,18 +2128,11 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/osx-sign": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", - "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "license": "BSD-2-Clause", "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", @@ -2185,14 +2173,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/packager": { "version": "18.3.6", "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-18.3.6.tgz", @@ -2253,14 +2233,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/packager/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/packager/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -2270,11 +2242,12 @@ } }, "node_modules/@electron/universal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", - "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "license": "MIT", "dependencies": { - "@electron/asar": "^3.2.7", + "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", @@ -2310,14 +2283,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/windows-sign": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.1.3.tgz", @@ -2360,14 +2325,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -3225,14 +3182,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@ionic/utils-fs/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@ionic/utils-object": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", @@ -3377,14 +3326,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/@ionic/utils-subprocess/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@ionic/utils-subprocess/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -6119,20 +6060,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.16.0" } }, - "node_modules/@types/node/node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "license": "MIT" - }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -7329,21 +7264,6 @@ "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", "license": "MIT" }, - "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -8221,6 +8141,16 @@ } ] }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -8313,9 +8243,9 @@ "license": "(BSD-3-Clause AND Apache-2.0)" }, "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "devOptional": true, "funding": [ { @@ -8730,30 +8660,6 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "license": "MIT" }, - "node_modules/cssstyle": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", - "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "rrweb-cssom": "^0.6.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -8769,22 +8675,6 @@ "uniq": "^1.0.0" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8859,15 +8749,6 @@ "node": ">=0.10.0" } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -9144,6 +9025,18 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -9306,12 +9199,12 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -10197,14 +10090,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/flora-colossus/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -10457,14 +10342,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/galactus/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -10876,21 +10753,6 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10940,27 +10802,23 @@ "entities": "^4.4.0" } }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -10973,22 +10831,6 @@ "node": ">=10.19.0" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -11589,15 +11431,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -12748,49 +12581,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz", - "integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.4", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.10", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.17.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -13255,6 +13045,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -16519,15 +16321,6 @@ "node": ">= 6" } }, - "node_modules/nwsapi": { - "version": "2.2.10", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", - "integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16843,21 +16636,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -17506,15 +17284,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -17563,15 +17332,6 @@ "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -18210,16 +17970,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/rename-cli/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -18243,15 +17993,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/resedit": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.3.tgz", @@ -18444,15 +18185,6 @@ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==" }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -18593,21 +18325,6 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==" }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -19623,15 +19340,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -19981,39 +19689,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/traverse": { "version": "0.6.11", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.11.tgz", @@ -20257,6 +19932,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -20347,15 +20028,12 @@ } }, "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", - "optional": true, - "peer": true, "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unrs-resolver": { @@ -20448,19 +20126,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -20963,21 +20628,6 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -20996,61 +20646,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -21441,14 +21036,6 @@ "punycode": "^2.1.0" } }, - "node_modules/workbox-build/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/workbox-build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -21640,42 +21227,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -21704,15 +21255,6 @@ "node": ">=8.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -21724,20 +21266,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/package.json b/package.json index 8239c814..49595920 100644 --- a/package.json +++ b/package.json @@ -51,10 +51,12 @@ "axios": "^1.13.5", "bcryptjs": "2.4.3", "buffer": "6.0.3", + "canvas-confetti": "^1.9.4", "chokidar": "^3.6.0", "compressorjs": "^1.2.1", "dompurify": "^3.2.6", "emoji-picker-react": "^4.12.0", + "entities": "^8.0.0", "file-saver": "^2.0.5", "framer-motion": "^12.23.24", "html-to-text": "^9.0.5", diff --git a/public/q-tube-preview.webm b/public/q-tube-preview.webm new file mode 100644 index 00000000..5c075aa4 Binary files /dev/null and b/public/q-tube-preview.webm differ diff --git a/public/qort-coin-blue.png b/public/qort-coin-blue.png new file mode 100644 index 00000000..8eaacd0e Binary files /dev/null and b/public/qort-coin-blue.png differ diff --git a/public/subwire-preview.webm b/public/subwire-preview.webm new file mode 100644 index 00000000..76b138f5 Binary files /dev/null and b/public/subwire-preview.webm differ diff --git a/src/App.tsx b/src/App.tsx index a85ec3e5..8b3919f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,17 @@ import { Suspense, } from 'react'; import { useDropzone } from 'react-dropzone'; -import { Box, ButtonBase, useTheme } from '@mui/material'; +import { Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import { AnimatePresence } from 'framer-motion'; import { decryptStoredWallet } from './utils/decryptWallet'; +import { + getWalletErrorMessage, + getWalletFieldLabel, +} from './utils/walletErrorMessages'; import './utils/seedPhrase/randomSentenceGenerator.ts'; import { createAccount, + generateRandomSentence, saveFileToDisk, saveSeedPhraseToDisk, } from './utils/generateWallet/generateWallet'; @@ -19,19 +25,22 @@ import { crypto, walletVersion } from './constants/decryptWallet'; import PhraseWallet from './utils/generateWallet/phrase-wallet'; import { AppContainer } from './styles/App-styles.ts'; import { Loader } from './components/Loader'; +import ErrorBoundary from './common/ErrorBoundary'; import { AuthenticationForm } from './components/AuthenticationForm'; -import { ProfileLeft } from './components/Profile'; import { BuyOrderRequestScreen, ConnectionRequestScreen, CountdownOverlay, CreateWalletView, + ElectronPersistentStorageHydration, InfoDialog, NotAuthenticatedFooter, + NotificationPermissionSlideDown, PaymentPublishDialog, PaymentRequestScreen, QortalRequestExtensionDialog, QortalRequestScreen, + ReceiveQortOverlay, SendQortOverlay, SuccessOverlay, SuccessScreen, @@ -44,15 +53,14 @@ import { LazyAuthenticatedShell } from './components/App/LazyAuthenticatedShell' import { useAppModals } from './hooks/useAppModals'; import { useAppReset } from './hooks/useAppReset'; import { useAppMessageHandler } from './hooks/useAppMessageHandler'; -import { CustomizedSnackbars } from './components/Snackbar/Snackbar'; -import HelpIcon from '@mui/icons-material/Help'; +import { QortinoNotificationHost } from './components/Snackbar/QortinoNotificationHost'; import { getWallets, storeWallets } from './background/background.ts'; import { executeEvent, subscribeToEvent, unsubscribeFromEvent, } from './utils/events'; -import { DrawerComponent } from './components/Drawer/Drawer'; +import { stopSharedEarbumpPlayback } from './components/Group/earbumpSharedAudio'; import { Settings } from './components/Group/Settings'; import { useRetrieveDataLocalStorage } from './hooks/useRetrieveDataLocalStorage.tsx'; import { useQortalGetSaveSettings } from './hooks/useQortalGetSaveSettings.tsx'; @@ -65,6 +73,7 @@ import { infoSnackGlobalAtom, isLoadingAuthenticateAtom, isOpenCoreSetup, + isPublicNodeUnavailableAtom, isRunningPublicNodeAtom, openSnackGlobalAtom, qortBalanceLoadingAtom, @@ -87,9 +96,11 @@ import { BuyQortInformation } from './components/BuyQortInformation'; import { PdfViewer } from './common/PdfViewer'; import { useTranslation } from 'react-i18next'; import { DownloadWallet } from './components/Auth/DownloadWallet.tsx'; +import { BackupWalletModal } from './components/Auth/BackupWalletModal.tsx'; import { useAtom, useSetAtom } from 'jotai'; import { - getDefaultLocalNodeUrl, + HTTP_LOCALHOST_12391, + HTTPS_EXT_NODE_QORTAL_LINK, isLocalNodeUrl, TIME_SECONDS_10_IN_MILLISECONDS, } from './constants/constants.ts'; @@ -106,6 +117,10 @@ import { } from './components/Desktop/CustomTitleBar'; import { roundUpToDecimals } from './utils/numberFunctions.ts'; import { GlobalQortalNavBar } from './components/Desktop/GlobalQortalNavBar.tsx'; +import type { AuthUnlockTransitionSnapshot } from './types/authTransition'; + +const MINTING_LOCAL_DEBUG_STORAGE_KEY = 'hub.mintingLocalDebug'; +const LOCAL_CORE_READY_SYNC_PERCENT = 99.95; // Re-export for consumers that still import from App export type { extStates } from './types/app'; @@ -127,7 +142,107 @@ export { } from './utils/globalApi'; export { isMainWindow } from './constants/app'; +const formatRuntimeFaultMessage = ( + value: unknown, + fallbackMessage: string +): string => { + if (value instanceof Error) { + return value.stack || value.message || fallbackMessage; + } + + if (typeof value === 'string' && value.trim()) { + return value; + } + + if ( + value && + typeof value === 'object' && + 'message' in value && + typeof (value as { message?: unknown }).message === 'string' && + (value as { message: string }).message.trim() + ) { + return (value as { message: string }).message; + } + + if ( + value && + typeof value === 'object' && + 'reason' in value && + typeof (value as { reason?: unknown }).reason === 'string' && + (value as { reason: string }).reason.trim() + ) { + return (value as { reason: string }).reason; + } + + if (value != null) { + try { + const serialized = JSON.stringify(value, null, 2); + if (serialized && serialized !== '{}') { + return `${fallbackMessage}\n${serialized}`; + } + } catch { + // Fall through to String(value) below. + } + + const stringified = String(value); + if ( + stringified && + stringified !== '[object Object]' && + stringified !== 'undefined' + ) { + return `${fallbackMessage}\n${stringified}`; + } + } + + return fallbackMessage; +}; + +/** Chromium reports this when resize work spans the same frame; not an app fault. */ +const RESIZE_OBSERVER_LOOP_MESSAGE = + /ResizeObserver loop completed with undelivered notifications/i; + +const isIgnorableRuntimeFault = (value: unknown): boolean => { + const extractMessage = (): string => { + if (typeof value === 'string') return value; + if (value instanceof Error) return value.message || ''; + if ( + value && + typeof value === 'object' && + 'message' in value && + typeof (value as { message?: unknown }).message === 'string' + ) { + return (value as { message: string }).message; + } + return ''; + }; + + const message = extractMessage().trim(); + if (RESIZE_OBSERVER_LOOP_MESSAGE.test(message)) { + return true; + } + + const errorCode = + value && + typeof value === 'object' && + 'error' in value && + typeof (value as { error?: unknown }).error === 'string' + ? (value as { error: string }).error + : ''; + + return ( + errorCode === 'timeout' && + /^Request timed out after \d+ ms\b/i.test(message) + ); +}; + function App() { + type SendQortOriginRect = { + left: number; + top: number; + width: number; + height: number; + } | null; + const [extState, setExtstate] = useAtom(extStateAtom); const [desktopViewMode, setDesktopViewMode] = useState('home'); const [rawWallet, setRawWallet] = useAtom(rawWalletAtom); @@ -141,10 +256,18 @@ function App() { const [paymentTo, setPaymentTo] = useState(''); const [sendPaymentError, setSendPaymentError] = useState(''); const [countdown, setCountdown] = useState(null); + const [globalRuntimeFault, setGlobalRuntimeFault] = useState<{ + message: string; + source: 'boundary' | 'error' | 'promise'; + } | null>(null); + const [authUnlockTransition, setAuthUnlockTransition] = + useState(null); const [walletToBeDownloaded, setWalletToBeDownloaded] = useState(null); + const [isBackupWalletModalOpen, setIsBackupWalletModalOpen] = useState(false); const [walletToBeDownloadedPassword, setWalletToBeDownloadedPassword] = useState(''); const setOpenCoreSetup = useSetAtom(isOpenCoreSetup); + const setPublicNodeUnavailable = useSetAtom(isPublicNodeUnavailableAtom); const setAuthenticatePassword = useSetAtom(authenticatePasswordAtom); const [sendqortState, setSendqortState] = useState(null); const [isLoading, setIsLoading] = useAtom(isLoadingAuthenticateAtom); @@ -174,8 +297,17 @@ function App() { hasSettingsChangedAtom ); + useEffect(() => { + const w = window as Window & { __qortalCurrentAddress?: string | null }; + w.__qortalCurrentAddress = userInfo?.address ?? null; + return () => { + delete w.__qortalCurrentAddress; + }; + }, [userInfo?.address]); + const downloadResource = useFetchResources(); const holdRefExtState = useRef('not-authenticated'); + const suppressWalletInfoRestoreRef = useRef(false); const isFocusedRef = useRef(true); const permissionHandlerRef = useRef< ((message: any, event: MessageEvent) => void) | null @@ -227,15 +359,25 @@ function App() { const [infoSnack, setInfoSnack] = useAtom(infoSnackGlobalAtom); const [openSnack, setOpenSnack] = useAtom(openSnackGlobalAtom); - const [isOpenDrawerProfile, setIsOpenDrawerProfile] = useState(false); const [isOpenDrawerLookup, setIsOpenDrawerLookup] = useState(false); const [isOpenSendQort, setIsOpenSendQort] = useState(false); + const [isOpenReceiveQort, setIsOpenReceiveQort] = useState(false); const [isOpenSendQortSuccess, setIsOpenSendQortSuccess] = useState(false); + const [sendQortOriginRect, setSendQortOriginRect] = + useState(null); + const [sendQortTargetRect, setSendQortTargetRect] = + useState(null); + const [receiveQortOriginRect, setReceiveQortOriginRect] = + useState(null); + const [receiveQortTargetRect, setReceiveQortTargetRect] = + useState(null); + const [receiveQortAddress, setReceiveQortAddress] = useState(''); const [selectedNode, setSelectedNode] = useAtom(selectedNodeInfoAtom); const { isNodeValid, authenticate, getBalanceFunc, + handleSaveNodeInfo, validateApiKeyFromRegistration, } = useAuth(); useBlockedAddressesLoader(extState === 'authenticated'); @@ -252,8 +394,27 @@ function App() { const [isOpenMinting, setIsOpenMinting] = useState(false); const generatorRef = useRef(null); + const ensureGeneratedSeedphrase = useCallback(() => { + const currentPhrase = generatorRef.current?.parsedString; + if (currentPhrase) return currentPhrase; + + const generatedPhrase = generateRandomSentence(); + generatorRef.current = { + parsedString: generatedPhrase, + }; + return generatedPhrase; + }, []); + + const prepareNewSeedphrase = useCallback(() => { + const generatedPhrase = generateRandomSentence(); + generatorRef.current = { + parsedString: generatedPhrase, + }; + return generatedPhrase; + }, []); + const exportSeedphrase = () => { - const seedPhrase = generatorRef.current.parsedString; + const seedPhrase = ensureGeneratedSeedphrase(); saveSeedPhraseToDisk(seedPhrase); }; @@ -278,6 +439,64 @@ function App() { const [storeAccount, setStoredAccount] = useState(true); + useEffect(() => { + const handleWindowError = (event: ErrorEvent) => { + if (isIgnorableRuntimeFault(event.error ?? event.message)) { + console.warn( + 'Ignoring non-fatal runtime fault', + event.error || event.message, + event + ); + return; + } + console.error( + 'Global runtime error', + event.error || event.message, + event + ); + setGlobalRuntimeFault({ + message: formatRuntimeFaultMessage( + event.error ?? event.message, + 'Unknown runtime error' + ), + source: 'error', + }); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + if (isIgnorableRuntimeFault(reason)) { + console.warn('Ignoring non-fatal runtime fault', reason, event); + return; + } + console.error('Unhandled promise rejection', reason, event); + setGlobalRuntimeFault({ + message: formatRuntimeFaultMessage( + reason, + 'Unhandled promise rejection' + ), + source: 'promise', + }); + }; + + window.addEventListener('error', handleWindowError); + window.addEventListener('unhandledrejection', handleUnhandledRejection); + + return () => { + window.removeEventListener('error', handleWindowError); + window.removeEventListener( + 'unhandledrejection', + handleUnhandledRejection + ); + }; + }, []); + + useEffect(() => { + if (extState !== 'authenticated' && globalRuntimeFault) { + setGlobalRuntimeFault(null); + } + }, [extState, globalRuntimeFault]); + const contextValue = useMemo( () => ({ onCancel, @@ -301,10 +520,10 @@ function App() { setSelectedNode(response); } else { const payload = { - url: getDefaultLocalNodeUrl(), + url: HTTPS_EXT_NODE_QORTAL_LINK, apikey: '', }; - handleSetGlobalApikey(response); + handleSetGlobalApikey(payload); setSelectedNode(payload); } }) @@ -319,18 +538,21 @@ function App() { .sendMessage('getWalletInfo') .then((response) => { if (response && response?.walletInfo) { - setRawWallet(response?.walletInfo); + if (suppressWalletInfoRestoreRef.current) return; + if ( holdRefExtState.current === 'web-app-request-payment' || holdRefExtState.current === 'web-app-request-connection' || holdRefExtState.current === 'web-app-request-buy-order' ) return; + + if (holdRefExtState.current !== 'not-authenticated') return; + if (response?.hasKeyPair) { + setRawWallet(response?.walletInfo); setExtstate('authenticated'); window.sendMessage('startNotificationCheck').catch(() => {}); - } else { - setExtstate('wallet-dropped'); } } }) @@ -400,7 +622,7 @@ function App() { if (!(field in pf)) throw new Error( t('auth:message.error.field_not_found_json', { - field: field, + field: getWalletFieldLabel(field), postProcess: 'capitalizeFirstChar', }) ); @@ -575,22 +797,30 @@ function App() { getUserInfo(); }, [address]); - useEffect(() => { - return () => { - console.log('exit'); - }; - }, []); - const saveFileToDiskFunc = useCallback(async () => { try { - await saveFileToDisk( + if (!walletToBeDownloaded?.wallet || !walletToBeDownloaded?.qortAddress) { + setWalletToBeDownloadedError('No wallet backup is ready yet.'); + return false; + } + + const saved = await saveFileToDisk( walletToBeDownloaded.wallet, walletToBeDownloaded.qortAddress ); + return Boolean(saved); } catch (error: any) { - setWalletToBeDownloadedError(error?.message); + setWalletToBeDownloadedError( + getWalletErrorMessage( + error, + t('auth:wallet_errors.unable_to_save_backup', { + postProcess: 'capitalizeFirstChar', + }) + ) + ); + return false; } - }, [walletToBeDownloaded]); + }, [walletToBeDownloaded, t]); const saveWalletToLocalStorage = async (newWallet) => { try { @@ -615,6 +845,7 @@ function App() { const createAccountFunc = async () => { try { + setWalletToBeDownloadedError(''); if (!walletToBeDownloadedPassword) { setWalletToBeDownloadedError( t('core:message.generic.password_enter', { @@ -641,6 +872,13 @@ function App() { ); return; } + const generatedSeedphrase = ensureGeneratedSeedphrase(); + if (!generatedSeedphrase) { + setWalletToBeDownloadedError( + 'We could not prepare the seedphrase. Please go back and try again.' + ); + return; + } setIsLoading(true); await new Promise((res) => { @@ -649,7 +887,7 @@ function App() { }, 250); }); - const res = await createAccount(generatorRef.current.parsedString); + const res = await createAccount(generatedSeedphrase); const wallet = await res.generateSaveWalletData( walletToBeDownloadedPassword, crypto.kdfThreads, @@ -688,15 +926,19 @@ function App() { getBalanceFunc(); } else if (response?.error) { setIsLoading(false); - setWalletToBeDecryptedError(response.error); + setWalletToBeDecryptedError(getWalletErrorMessage(response.error)); } }) .catch((error) => { setIsLoading(false); + setWalletToBeDecryptedError(getWalletErrorMessage(error)); console.error('Failed to decrypt wallet:', error); }); } catch (error: any) { - setWalletToBeDownloadedError(error?.message); + console.error('Failed to create account:', error); + setWalletToBeDownloadedError( + 'We could not create this account. Please try again.' + ); setIsLoading(false); } }; @@ -714,6 +956,7 @@ function App() { .sendMessage('logout', {}) .then((response) => { if (response) { + stopSharedEarbumpPlayback(); executeEvent('logout-event', {}); resetAllStates(); } @@ -730,19 +973,30 @@ function App() { }, [hasSettingsChanged, extState]); const returnToMain = useCallback(() => { + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'authenticated'; setPaymentTo(''); setSendPaymentError(''); setCountdown(null); setWalletToBeDownloaded(null); setWalletToBeDownloadedPassword(''); + generatorRef.current = null; setShowSeed(false); setCreationStep(1); + setSendQortOriginRect(null); + setSendQortTargetRect(null); + setReceiveQortOriginRect(null); + setReceiveQortTargetRect(null); + setReceiveQortAddress(''); setExtstate('authenticated'); setIsOpenSendQort(false); + setIsOpenReceiveQort(false); setIsOpenSendQortSuccess(false); }, []); const resetAllStates = () => { + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'not-authenticated'; setExtstate('not-authenticated'); setRawWallet(null); setRequestConnection(null); @@ -754,6 +1008,7 @@ function App() { setCountdown(null); setWalletToBeDownloaded(null); setWalletToBeDownloadedPassword(''); + generatorRef.current = null; setShowSeed(false); setCreationStep(1); setWalletToBeDownloadedPasswordConfirm(''); @@ -813,66 +1068,99 @@ function App() { }; }, []); - const openGlobalSnackBarFunc = (e) => { - const message = e.detail?.message; - const type = e.detail?.type; - setOpenSnack(true); - setInfoSnack({ - type, - message, - }); - }; - - useEffect(() => { - subscribeToEvent('openGlobalSnackBar', openGlobalSnackBarFunc); - - return () => { - unsubscribeFromEvent('openGlobalSnackBar', openGlobalSnackBarFunc); - }; - }, []); - const openPaymentInternal = (e) => { const directAddress = e.detail?.address; const name = e.detail?.name; + const anchorRect = e.detail?.anchorRect; + const targetRect = e.detail?.targetRect; + setSendQortOriginRect( + anchorRect + ? { + left: anchorRect.left, + top: anchorRect.top, + width: anchorRect.width, + height: anchorRect.height, + } + : null + ); + setSendQortTargetRect( + targetRect + ? { + left: targetRect.left, + top: targetRect.top, + width: targetRect.width, + height: targetRect.height, + } + : null + ); setIsOpenSendQort(true); - setPaymentTo(name || directAddress); + setPaymentTo(name || directAddress || ''); + }; + + const openReceiveQortInternal = (e) => { + const anchorRect = e.detail?.anchorRect; + const targetRect = e.detail?.targetRect; + setReceiveQortOriginRect( + anchorRect + ? { + left: anchorRect.left, + top: anchorRect.top, + width: anchorRect.width, + height: anchorRect.height, + } + : null + ); + setReceiveQortTargetRect( + targetRect + ? { + left: targetRect.left, + top: targetRect.top, + width: targetRect.width, + height: targetRect.height, + } + : null + ); + setReceiveQortAddress(e.detail?.address || address || ''); + setIsOpenReceiveQort(true); }; useEffect(() => { subscribeToEvent('openPaymentInternal', openPaymentInternal); + subscribeToEvent('openReceiveQortInternal', openReceiveQortInternal); return () => { unsubscribeFromEvent('openPaymentInternal', openPaymentInternal); + unsubscribeFromEvent('openReceiveQortInternal', openReceiveQortInternal); }; - }, []); + }, [address]); - const onOpenSendQort = useCallback(() => setIsOpenSendQort(true), []); - const onCloseDrawerProfile = useCallback( - () => setIsOpenDrawerProfile(false), - [] - ); - const onOpenSendQortAndCloseDrawer = useCallback(() => { + const onOpenSendQort = useCallback(() => { + setSendQortOriginRect(null); + setSendQortTargetRect(null); + executeEvent('openSendQortInternal', {}); setIsOpenSendQort(true); - setIsOpenDrawerProfile(false); }, []); const onOpenRegisterName = useCallback( () => executeEvent('openRegisterName', {}), [] ); const onOpenSettings = useCallback(() => setIsSettingsOpen(true), []); - const onOpenDrawerLookup = useCallback(() => setIsOpenDrawerLookup(true), []); - const onOpenWalletsApp = useCallback( - () => executeEvent('openWalletsApp', {}), + const onOpenDrawerLookup = useCallback( + () => setIsOpenDrawerLookup((prev) => !prev), [] ); - const onOpenDrawerProfile = useCallback( - () => setIsOpenDrawerProfile(true), + const onOpenWalletsApp = useCallback( + () => executeEvent('openWalletsApp', {}), [] ); const onOpenMinting = useCallback(async () => { try { + const forceLocalMintingPreview = + typeof window !== 'undefined' && + (localStorage.getItem(MINTING_LOCAL_DEBUG_STORAGE_KEY) === 'true' || + localStorage.getItem(MINTING_LOCAL_DEBUG_STORAGE_KEY) === '1'); const res = await isRunningGateway(); - if (res) + if (res && !forceLocalMintingPreview) throw new Error( t('core:message.generic.no_minting_details', { postProcess: 'capitalizeFirstChar', @@ -888,9 +1176,27 @@ function App() { } }, [t]); const onBackupWallet = useCallback(() => { + if (extState === 'authenticated' && rawWallet) { + setIsBackupWalletModalOpen(true); + return; + } + setExtstate('download-wallet'); - setIsOpenDrawerProfile(false); - }, [setExtstate]); + }, [extState, rawWallet, setExtstate]); + + const closeBackupWalletModal = useCallback(() => { + setIsBackupWalletModalOpen(false); + }, []); + + useEffect(() => { + subscribeToEvent('openMintingPanel', onOpenMinting); + subscribeToEvent('openBackupWallet', onBackupWallet); + + return () => { + unsubscribeFromEvent('openMintingPanel', onOpenMinting); + unsubscribeFromEvent('openBackupWallet', onBackupWallet); + }; + }, [onBackupWallet, onOpenMinting]); const onOkQortalRequestAccepted = useCallback( () => onOkQortalRequest('accepted'), @@ -912,18 +1218,33 @@ function App() { () => confirmPayment(true), [confirmPayment] ); - const onGoToCreateWallet = useCallback( - () => setExtstate('create-wallet'), - [setExtstate] - ); + const onGoToCreateWallet = useCallback(() => { + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'create-wallet'; + prepareNewSeedphrase(); + setWalletToBeDownloadedError(''); + setWalletToBeDownloadedPassword(''); + setWalletToBeDownloadedPasswordConfirm(''); + setCreationStep(1); + setExtstate('create-wallet'); + }, [ + prepareNewSeedphrase, + setExtstate, + setWalletToBeDownloadedPassword, + setWalletToBeDownloadedPasswordConfirm, + ]); const onWalletsBack = useCallback(() => { + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'not-authenticated'; setRawWallet(null); setExtstate('not-authenticated'); logoutFunc(); }, [setExtstate, logoutFunc]); const onAuthenticationFormBack = useCallback(() => { + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'not-authenticated'; setRawWallet(null); - setExtstate('wallets'); + setExtstate('not-authenticated'); setAuthenticatePassword(''); logoutFunc(); }, [setExtstate, logoutFunc]); @@ -934,11 +1255,15 @@ function App() { setWalletToBeDownloadedPassword(''); return; } + suppressWalletInfoRestoreRef.current = true; + holdRefExtState.current = 'not-authenticated'; setExtstate('not-authenticated'); setShowSeed(false); setCreationStep(1); setWalletToBeDownloadedPasswordConfirm(''); setWalletToBeDownloadedPassword(''); + setWalletToBeDownloadedError(''); + generatorRef.current = null; }, [ creationStep, setExtstate, @@ -947,16 +1272,109 @@ function App() { ]); const onShowSeed = useCallback(() => setShowSeed(true), []); const onHideSeed = useCallback(() => setShowSeed(false), []); - const onCreationStepNext = useCallback(() => setCreationStep(2), []); + const onCreationStepNext = useCallback(() => { + ensureGeneratedSeedphrase(); + setWalletToBeDownloadedError(''); + setCreationStep(2); + }, [ensureGeneratedSeedphrase]); + + const isPublicNodeReachable = useCallback(async () => { + try { + const response = await fetch( + `${HTTPS_EXT_NODE_QORTAL_LINK}/admin/status` + ); + return response.ok; + } catch (error) { + return false; + } + }, []); + + const isLocalCoreReadyForHub = useCallback(async () => { + try { + const response = await fetch(`${HTTP_LOCALHOST_12391}/admin/status`); + if (!response.ok) return false; + + const status = await response.json(); + const syncPercent = Number(status?.syncPercent); + return ( + Number.isFinite(syncPercent) && + syncPercent >= LOCAL_CORE_READY_SYNC_PERCENT + ); + } catch (error) { + return false; + } + }, []); + + const prepareNodeForHubEntry = useCallback(async () => { + const selectedUrl = selectedNode?.url || HTTPS_EXT_NODE_QORTAL_LINK; + const usingDefaultPublic = selectedUrl === HTTPS_EXT_NODE_QORTAL_LINK; + const blockedEntry = { + canEnter: false, + shouldOpenCoreSetupAfterEntry: false, + }; + + if (usingDefaultPublic) { + if (!(await isPublicNodeReachable())) { + setPublicNodeUnavailable(true); + setOpenCoreSetup(true); + return blockedEntry; + } + + setPublicNodeUnavailable(false); + return { + canEnter: true, + shouldOpenCoreSetupAfterEntry: true, + }; + } + + if (isLocalNodeUrl(selectedUrl) && !(await isLocalCoreReadyForHub())) { + if (await isPublicNodeReachable()) { + setPublicNodeUnavailable(false); + await handleSaveNodeInfo({ + url: HTTPS_EXT_NODE_QORTAL_LINK, + apikey: '', + }); + return { + canEnter: true, + shouldOpenCoreSetupAfterEntry: true, + }; + } + + setPublicNodeUnavailable(true); + setOpenCoreSetup(true); + return blockedEntry; + } + + setPublicNodeUnavailable(false); + return { + canEnter: true, + shouldOpenCoreSetupAfterEntry: false, + }; + }, [ + handleSaveNodeInfo, + isLocalCoreReadyForHub, + isPublicNodeReachable, + selectedNode?.url, + setOpenCoreSetup, + setPublicNodeUnavailable, + ]); + const onBackupAccountConfirm = useCallback(async () => { - await saveFileToDiskFunc(); + return saveFileToDiskFunc(); + }, [saveFileToDiskFunc]); + + const onEnterHubAfterCreate = useCallback(async () => { + const entryPreparation = await prepareNodeForHubEntry(); + if (!entryPreparation.canEnter) return; + returnToMain(); - await showInfo({ - message: t('auth:tips.wallet_secure', { - postProcess: 'capitalizeFirstChar', - }), - }); - }, [t, showInfo, saveFileToDiskFunc, returnToMain]); + + if (window?.coreSetup && entryPreparation.shouldOpenCoreSetupAfterEntry) { + window.setTimeout(() => { + setOpenCoreSetup(true); + }, 650); + } + }, [prepareNodeForHubEntry, returnToMain, setOpenCoreSetup]); const onCountdownComplete = useCallback(() => { window.close(); }, []); @@ -979,78 +1397,193 @@ function App() { () => setOpenCoreSetup(true), [setOpenCoreSetup] ); - const onShowTutorialImportantInfo = useCallback( - () => showTutorial('important-information', true), - [showTutorial] - ); - const isElectron = typeof window !== 'undefined' && typeof ( window as Window & { electronAPI?: { windowMinimize?: () => unknown } } ).electronAPI?.windowMinimize === 'function'; + const shouldReduceAuthTransition = + typeof window !== 'undefined' && + (window.matchMedia('(prefers-reduced-motion: reduce)').matches || + window.localStorage.getItem('hub_ui_animations_enabled') === 'false'); const mainContent = ( <> + {extState === 'not-authenticated' && ( )} {extState === 'authenticated' && isMainWindow && ( }> - + ( + + + Hub runtime error + + + The authenticated shell crashed during render. + + {error?.message ? ( + + {error.message} + + ) : null} + {componentStack ? ( + + {componentStack.trim()} + + ) : null} + + )} + > + + + + )} - {isOpenSendQort && isMainWindow && ( - { - setIsOpenSendQort(false); - setIsOpenSendQortSuccess(true); - }} - show={show} + {isMainWindow && ( + )} + + {isOpenSendQort && isMainWindow && ( + { + setIsOpenSendQort(false); + setSendQortOriginRect(null); + setSendQortTargetRect(null); + setIsOpenSendQortSuccess(true); + }} + show={show} + /> + )} + {isOpenReceiveQort && isMainWindow && ( + { + setIsOpenReceiveQort(false); + setReceiveQortOriginRect(null); + setReceiveQortTargetRect(null); + setReceiveQortAddress(''); + }} + /> + )} + + {isShowQortalRequest && !isMainWindow && ( setAuthUnlockTransition(null)} /> )} {extState === 'download-wallet' && ( )} @@ -1237,35 +1772,13 @@ function App() { /> )} - - - void - } - onRefreshBalance={getBalanceAndUserInfoFunc} - onOpenSendQort={onOpenSendQortAndCloseDrawer} - onOpenRegisterName={onOpenRegisterName} - onCloseDrawer={onCloseDrawerProfile} - /> - - + {isMainWindow && } - {extState === 'create-wallet' && walletToBeDownloaded && ( - - - - )} - {isOpenMinting && ( )} @@ -1329,7 +1826,6 @@ function App() { onOpenSettings, onOpenDrawerLookup, onOpenWalletsApp, - onOpenDrawerProfile, onLogout: logoutFunc, getUserInfo, onOpenMinting, @@ -1350,7 +1846,10 @@ function App() { > {extState === 'authenticated' && isMainWindow && ( - + )} - {mainContent} + {globalRuntimeFault && extState === 'authenticated' && isMainWindow ? ( + + + + Hub runtime error + + + The authenticated app hit a runtime fault after login. We are + surfacing it here instead of leaving a white screen. + + + + {globalRuntimeFault.source} + + + {globalRuntimeFault.message} + + + + + ) : ( + mainContent + )} ); diff --git a/src/assets/audio/light-transition-351939.mp3 b/src/assets/audio/light-transition-351939.mp3 new file mode 100644 index 00000000..ddebee3b Binary files /dev/null and b/src/assets/audio/light-transition-351939.mp3 differ diff --git a/src/assets/minting/blue-grey-menu-button2-1.png b/src/assets/minting/blue-grey-menu-button2-1.png new file mode 100644 index 00000000..985d3371 Binary files /dev/null and b/src/assets/minting/blue-grey-menu-button2-1.png differ diff --git a/src/assets/sidebar/qortal-logo-official.png b/src/assets/sidebar/qortal-logo-official.png new file mode 100644 index 00000000..11e8b5cb Binary files /dev/null and b/src/assets/sidebar/qortal-logo-official.png differ diff --git a/src/assets/svgs/QortinoCurrent.png b/src/assets/svgs/QortinoCurrent.png new file mode 100644 index 00000000..6c13318e Binary files /dev/null and b/src/assets/svgs/QortinoCurrent.png differ diff --git a/src/assets/svgs/QortinoCurrent.svg b/src/assets/svgs/QortinoCurrent.svg new file mode 100644 index 00000000..2f957939 --- /dev/null +++ b/src/assets/svgs/QortinoCurrent.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svgs/QortinoCurrentNoAntenna.png b/src/assets/svgs/QortinoCurrentNoAntenna.png new file mode 100644 index 00000000..eda46dea Binary files /dev/null and b/src/assets/svgs/QortinoCurrentNoAntenna.png differ diff --git a/src/assets/svgs/QortinoCurrentNoAntenna.svg b/src/assets/svgs/QortinoCurrentNoAntenna.svg new file mode 100644 index 00000000..d9580811 --- /dev/null +++ b/src/assets/svgs/QortinoCurrentNoAntenna.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svgs/QortinoHead.svg b/src/assets/svgs/QortinoHead.svg new file mode 100644 index 00000000..cfbf8186 --- /dev/null +++ b/src/assets/svgs/QortinoHead.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svgs/QortinoMascot.svg b/src/assets/svgs/QortinoMascot.svg new file mode 100644 index 00000000..146537e5 --- /dev/null +++ b/src/assets/svgs/QortinoMascot.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/user-search/magnifier.svg b/src/assets/user-search/magnifier.svg new file mode 100644 index 00000000..64441fb0 --- /dev/null +++ b/src/assets/user-search/magnifier.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/user-search/qortal-logo-512.png b/src/assets/user-search/qortal-logo-512.png new file mode 100644 index 00000000..9ad9dcb9 Binary files /dev/null and b/src/assets/user-search/qortal-logo-512.png differ diff --git a/src/atoms/global.ts b/src/atoms/global.ts index ca4fe382..1a474f15 100644 --- a/src/atoms/global.ts +++ b/src/atoms/global.ts @@ -5,13 +5,15 @@ import { atomWithStorage, useAtomCallback, } from 'jotai/utils'; -import { HTTP_LOCALHOST_12391 } from '../constants/constants'; +import { HTTPS_EXT_NODE_QORTAL_LINK } from '../constants/constants'; import { ApiKey } from '../types/auth'; import { extStates } from '../App'; import { Steps } from '../components/CoreSetupDialog'; import { LOCALHOST } from '../constants/constants'; import { GlobalDownloadEntry } from '../types/resources'; import { defaultPinnedApps } from '../components/Apps/config/officialApps'; +import { getElectronPersistentStorage } from '../utils/electronPersistentStorage'; +import type { QuitterDashboardFeedCache } from '../components/Widgets/quitter/quitterFeedTypes'; export const sortablePinnedAppsAtom = atomWithReset(defaultPinnedApps); @@ -43,11 +45,12 @@ export const isDisabledEditorEnterAtom = atomWithReset(false); export const isOpenBlockedModalAtom = atomWithReset(false); export const isRunningPublicNodeAtom = atomWithReset(false); export const isUsingImportExportSettingsAtom = atomWithReset(null); -export const lastPaymentSeenTimestampAtom = atomWithReset(null); -export const mailsAtom = atomWithReset([]); + export const memberGroupsAtom = atomWithReset([]); export const mutedGroupsAtom = atomWithReset([]); export const myGroupsWhereIAmAdminAtom = atomWithReset([]); + + export const navigationControllerAtom = atomWithReset({}); export const oldPinnedAppsAtom = atomWithReset([]); export const promotionsAtom = atomWithReset([]); @@ -71,6 +74,11 @@ export type JoinRequestsCache = { adminGroupIds: number[]; } | null; export const joinRequestsCacheAtom = atom(null) as PrimitiveAtom; + +/** Quitter home-dashboard widget feed. Reset on logout. Prefer useAtom only in QuitterFeedWidget. */ +export const quitterDashboardFeedCacheAtom = + atomWithReset(null); + export const qMailLastEnteredTimestampAtom = atomWithReset(null); export const resourceDownloadControllerAtom = atomWithReset({}); export const globalDownloadsAtom = atomWithReset< @@ -99,12 +107,398 @@ export const globalChatWidgetBoundsAtom = atomWithStorage<{ { getOnInit: true } ); +/** Persisted: custom websocket notification subscriptions. Sent as a second subscribe action when the notifications socket connects. */ +export type CustomWebsocketSubscription = { + event: string; + resourceFilter?: { + service: string; + identifier: string; + name?: string; + excludeBlocked?: boolean; + mode?: string; + }; + filters?: Record; + image?: string; + link?: string; + notificationId?: string; + appName?: string; + appService?: string; + message?: Record; + [key: string]: unknown; +}; +// When in Electron, use appStorage-backed persistence; otherwise Jotai uses localStorage (undefined = default). +const electronStorage = getElectronPersistentStorage(); + +/** Persisted: custom WS subscriptions per user address (key: qortal_custom_ws_subscriptions). */ +export const CUSTOM_WS_SUBSCRIPTIONS_STORAGE_KEY = 'qortal_custom_ws_subscriptions'; +export const customWebsocketSubscriptionsByAddressAtom = atomWithStorage< + Record +>( + CUSTOM_WS_SUBSCRIPTIONS_STORAGE_KEY, + {}, + electronStorage as any +); + +/** Persisted: keys of notifications already "seen in app" (excluded from unread count), by address then notification key. Keys older than this are pruned. */ +export const NOTIFICATION_SEEN_IN_APP_STORAGE_KEY = + 'qortal_notification_seen_in_app'; +const NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; // 3 days + +/** Per-address record of notificationKey -> mark time (ms). Pruned to last 3 days when read/set. */ +export type SeenInAppKeyRecord = Record; +/** Stored shape: address -> notificationKey -> timestamp. */ +export type SeenInAppRecordByAddress = Record; + +export function parseSeenInAppStored( + raw: string | null | unknown +): SeenInAppRecordByAddress { + if (raw == null || raw === '') return {}; + let parsed: unknown = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return {}; + } + } + if (Array.isArray(parsed)) return {}; + if (!parsed || typeof parsed !== 'object') return {}; + const obj = parsed as Record; + const result: SeenInAppRecordByAddress = {}; + for (const [addr, inner] of Object.entries(obj)) { + if (inner && typeof inner === 'object' && !Array.isArray(inner)) { + const keyRecord: SeenInAppKeyRecord = {}; + for (const [k, t] of Object.entries(inner)) { + if (typeof t === 'number') keyRecord[k] = t; + } + result[addr] = keyRecord; + } + } + return result; +} + +function filterSeenInAppKeyRecordByAge( + record: SeenInAppKeyRecord +): SeenInAppKeyRecord { + const cutoff = Date.now() - NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS; + return Object.fromEntries( + Object.entries(record).filter(([, t]) => typeof t === 'number' && t > cutoff) + ); +} + +export function filterSeenInAppRecordByAge( + record: SeenInAppRecordByAddress +): SeenInAppRecordByAddress { + const out: SeenInAppRecordByAddress = {}; + for (const [addr, inner] of Object.entries(record)) { + const pruned = filterSeenInAppKeyRecordByAge(inner); + if (Object.keys(pruned).length > 0) out[addr] = pruned; + } + return out; +} + +/** Keys for one address from the last 3 days. */ +function seenInAppRecordToKeysForAddress( + record: SeenInAppRecordByAddress, + address: string | null | undefined +): string[] { + if (!address) return []; + const cutoff = Date.now() - NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS; + const byAddr = record[address] ?? {}; + return Object.keys(byAddr).filter( + (k) => typeof byAddr[k] === 'number' && byAddr[k] > cutoff + ); +} + +const seenInAppStorage = { + getItem: (key: string): SeenInAppRecordByAddress => { + const raw: string | null | unknown = + electronStorage != null + ? (electronStorage as any).getItem(key, null) + : typeof localStorage !== 'undefined' + ? localStorage.getItem(key) + : null; + const record = parseSeenInAppStored(raw); + return filterSeenInAppRecordByAge(record); + }, + setItem: ( + key: string, + value: string | SeenInAppRecordByAddress + ): void => { + const record = + typeof value === 'string' ? parseSeenInAppStored(value) : value; + const pruned = filterSeenInAppRecordByAge(record); + if (electronStorage != null) { + (electronStorage as any).setItem(key, pruned); + } else if (typeof localStorage !== 'undefined') { + localStorage.setItem(key, JSON.stringify(pruned)); + } + }, + removeItem: (key: string): void => { + if (electronStorage != null) { + (electronStorage as any).removeItem?.(key); + } else if (typeof localStorage !== 'undefined') { + localStorage.removeItem(key); + } + }, +}; + +export const notificationSeenInAppKeysRecordAtom = + atomWithStorage( + NOTIFICATION_SEEN_IN_APP_STORAGE_KEY, + {}, + seenInAppStorage as any + ); + +/** Keys for current user (from last 3 days). Set with string[] for current address or { address, keys } to merge for an address. */ +export const notificationSeenInAppKeysAtom = atom( + (get) => + seenInAppRecordToKeysForAddress( + get(notificationSeenInAppKeysRecordAtom) as SeenInAppRecordByAddress, + get(userInfoAtom)?.address + ), + (get, set, update: string[] | { address: string; keys: string[] }) => { + const full = + get(notificationSeenInAppKeysRecordAtom) as SeenInAppRecordByAddress; + const address = + typeof update === 'object' && update !== null && 'address' in update + ? (update as { address: string; keys: string[] }).address + : get(userInfoAtom)?.address; + const keys = + typeof update === 'object' && update !== null && 'keys' in update + ? (update as { address: string; keys: string[] }).keys + : (update as string[]); + if (!address || !Array.isArray(keys)) return; + const record = { ...full }; + record[address] = { ...(record[address] ?? {}) }; + const now = Date.now(); + for (const k of keys) record[address][k] = now; + const pruned = filterSeenInAppRecordByAge(record); + set(notificationSeenInAppKeysRecordAtom, pruned); + } +); + +/** Current user: seen-in-app key → mark time (ms). Same ageing as notificationSeenInAppKeysAtom. */ +export const notificationSeenInAppKeyTimesAtom = atom((get) => { + const byAddress = + get(notificationSeenInAppKeysRecordAtom) as SeenInAppRecordByAddress; + const address = get(userInfoAtom)?.address; + if (!address) return {} as SeenInAppKeyRecord; + return filterSeenInAppKeyRecordByAge(byAddress[address] ?? {}); +}); + +/** Same field order as GeneralNotifications getNotificationTimestamp (for prefix cutoff). */ +export function getNotificationSeenComparableTimeMs(notification: { + data?: { created?: unknown; timestamp?: unknown }; + timestamp?: unknown; +}): number | null { + const raw = + notification?.data?.created ?? + notification?.data?.timestamp ?? + notification?.timestamp; + if (raw == null || typeof raw !== 'number') return null; + return raw < 1e12 ? raw * 1000 : raw; +} + +/** + * Full key: that exact notification is seen. Prefix key: seen only if resource time ≤ persisted mark time + * (so new items after mark stay unread). + */ +export function isNotificationSeenInAppFromKeyTimes( + notification: { + event?: string; + data?: { + signature?: string; + identifier?: string; + created?: unknown; + timestamp?: unknown; + }; + appName?: string; + appService?: string; + notificationId?: string; + timestamp?: unknown; + }, + seenInAppByKey: SeenInAppKeyRecord | null | undefined +): boolean { + if (!seenInAppByKey || Object.keys(seenInAppByKey).length === 0) return false; + const fullKey = getNotificationSeenKey(notification); + if (typeof seenInAppByKey[fullKey] === 'number') return true; + const prefixKey = getNotificationSeenPrefixKey(notification); + const markedAt = seenInAppByKey[prefixKey]; + if (typeof markedAt !== 'number') return false; + const notifTs = getNotificationSeenComparableTimeMs(notification); + if (notifTs == null) return false; + return notifTs <= markedAt; +} + +/** Stable key for a notification (for "seen in app" matching). */ +export function getNotificationSeenKey(notification: { + event?: string; + data?: { signature?: string; identifier?: string; created?: unknown }; + appName?: string; + appService?: string; + notificationId?: string; +}): string { + if (notification?.event === 'PAYMENT_RECEIVED') { + return `PAYMENT_RECEIVED-${notification?.data?.signature ?? ''}`; + } + if (notification?.event === 'RESOURCE_PUBLISHED') { + const appName = (notification?.appName ?? '').toLowerCase(); + const appService = notification?.appService ?? 'APP'; + const notificationId = notification?.notificationId ?? ''; + const id = + notification?.data?.identifier ?? + notification?.data?.created ?? + ''; + return `RESOURCE_PUBLISHED-${appName}-${appService}-${notificationId}-${id}`; + } + return `other-${notification?.event ?? ''}-${Date.now()}`; +} + +/** Prefix key (app + notificationId only); when app marks by notificationId, we add this. */ +export function getNotificationSeenPrefixKey(notification: { + event?: string; + appName?: string; + appService?: string; + notificationId?: string; +}): string { + if (notification?.event === 'RESOURCE_PUBLISHED') { + const appName = (notification?.appName ?? '').toLowerCase(); + const appService = notification?.appService ?? 'APP'; + const notificationId = notification?.notificationId ?? ''; + return `RESOURCE_PUBLISHED-${appName}-${appService}-${notificationId}`; + } + return getNotificationSeenKey(notification as any); +} + +/** Build prefix for a subscription (same shape as getNotificationSeenPrefixKey). */ +function getSubscriptionSeenPrefix(sub: CustomWebsocketSubscription): string { + const appName = ((sub.appName as string) ?? '').toLowerCase(); + const appService = (sub.appService as string) ?? 'APP'; + const notificationId = (sub.notificationId as string) ?? ''; + return `RESOURCE_PUBLISHED-${appName}-${appService}-${notificationId}`; +} + +/** Keep only seen-in-app keys that still match a rule in customWebsocketSubscriptions. */ +export function filterSeenInAppKeysByRules( + seenKeys: string[], + customSubscriptions: CustomWebsocketSubscription[] +): string[] { + if (!Array.isArray(seenKeys) || seenKeys.length === 0) return []; + const validPrefixes = new Set( + (customSubscriptions ?? []).map(getSubscriptionSeenPrefix) + ); + if (validPrefixes.size === 0) return []; + return seenKeys.filter((key) => { + if (validPrefixes.has(key)) return true; + for (const prefix of validPrefixes) { + if (key.startsWith(prefix + '-')) return true; + } + return false; + }); +} + export const txListAtom = atomWithReset([]); -export const isOpenDialogCoreRecommendationAtom = atomWithReset(false); + + +/** Notifications per user address (in-memory only; repopulated from WebSocket). */ +export const notificationsByAddressAtom = atomWithReset>({}); + +/** Persisted: timestamp when user last "saw all" notifications, per address (Electron: appStorage). */ +export const SEEN_ALL_NOTIFICATIONS_STORAGE_KEY = 'qortal_seen_all_notifications'; +export const seenAllNotificationsByAddressAtom = atomWithStorage>( + SEEN_ALL_NOTIFICATIONS_STORAGE_KEY, + {}, + electronStorage as any +); + +/** Groups the current user is a member of – refreshed every 5 minutes. */ +export const myMemberGroupsAtom = atomWithReset([]); +/** Unix-ms timestamp of the last successful fetch for myMemberGroupsAtom. */ +export const myMemberGroupsLastFetchedAtom = atomWithReset(0); + +/** Subscriptions the current user is subscribed to (fetched globally in the title bar). */ +export const mySubscriptionsAtom = atomWithReset([]); +/** Subscriptions belonging to groups the current user manages as admin. */ +export const managedSubscriptionsAtom = atomWithReset([]); +/** Whether the global subscription fetch is currently in progress. */ +export const subscriptionsLoadingAtom = atomWithReset(false); +/** Whether the global managed subscription fetch is currently in progress. */ +export const managedSubscriptionsLoadingAtom = atomWithReset(false); + + +export const isPublicNodeUnavailableAtom = atomWithReset(false); export const isLoadingAuthenticateAtom = atomWithReset(false); export const authenticatePasswordAtom = atomWithReset(''); export const extStateAtom = atomWithReset('not-authenticated'); export const userInfoAtom = atomWithReset(null); + + + + + +/** Current user's custom WS subscriptions (derived from customWebsocketSubscriptionsByAddressAtom by address). */ +export const customWebsocketSubscriptionsAtom = atom( + (get) => { + const byAddress = get(customWebsocketSubscriptionsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return []; + return (byAddress[address] ?? []) as CustomWebsocketSubscription[]; + }, + (get, set, update: CustomWebsocketSubscription[] | ((prev: CustomWebsocketSubscription[]) => CustomWebsocketSubscription[])) => { + const byAddress = get(customWebsocketSubscriptionsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return; + const prev = (byAddress[address] ?? []) as CustomWebsocketSubscription[]; + const next = typeof update === 'function' ? update(prev) : update; + set(customWebsocketSubscriptionsByAddressAtom, { ...byAddress, [address]: next }); + } +); + +/** Current user's notifications (derived from notificationsByAddressAtom by address). */ +export const paymentNotificationsAtom = atom( + (get) => { + const byAddress = get(notificationsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return []; + return (byAddress[address] ?? []) as any[]; + }, + (get, set, update: any[] | ((prev: any[]) => any[])) => { + const byAddress = get(notificationsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return; + const prev = (byAddress[address] ?? []) as any[]; + const next = typeof update === 'function' ? update(prev) : update; + set(notificationsByAddressAtom, { ...byAddress, [address]: next }); + } +); + +/** Current user's "seen all notifications" timestamp (derived from seenAllNotificationsByAddressAtom by address). */ +export const lastPaymentSeenTimestampAtom = atom( + (get) => { + const byAddress = get(seenAllNotificationsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return null; + return (byAddress[address] ?? null) as number | null; + }, + (get, set, value: number | null) => { + const byAddress = get(seenAllNotificationsByAddressAtom); + const address = get(userInfoAtom)?.address; + if (!address) return; + set(seenAllNotificationsByAddressAtom, { ...byAddress, [address]: value }); + } +); + +export const notificationSeenInAppKeysByAddressAtom = atomWithStorage< + Record> +>('qortal_notification_seen_in_app_by_address', {}); + + + + + + + export const rawWalletAtom = atomWithReset(null); export const walletToBeDecryptedErrorAtom = atomWithReset(''); export const balanceAtom = atomWithReset(null); @@ -122,7 +516,7 @@ export const devServerDomainAtom = atomWithReset(LOCALHOST); export const devServerPortAtom = atomWithReset(''); export const nodeInfosAtom = atomWithReset({}); export const selectedNodeInfoAtom = atomWithReset({ - url: HTTP_LOCALHOST_12391, + url: HTTPS_EXT_NODE_QORTAL_LINK, apikey: '', }); export const statusesAtom = atomWithReset({ @@ -151,6 +545,9 @@ export const infoSnackGlobalAtom = atomWithReset<{ message?: string; type?: string; duration?: number | null; + compact?: boolean; + dismissible?: boolean; + sourceId?: string; } | null>(null); // Tutorial state (reduces context re-renders for tutorial UI) diff --git a/src/background/background-cases.ts b/src/background/background-cases.ts index 2525373d..49934820 100644 --- a/src/background/background-cases.ts +++ b/src/background/background-cases.ts @@ -40,10 +40,12 @@ import { kickFromGroup, leaveGroup, makeAdmin, + markAllMemberGroupsRead, notifyAdminRegenerateSecretKey, pauseAllQueues, processTransactionVersion2, registerName, + updateName, removeAdmin, resumeAllQueues, saveTempPublish, @@ -68,6 +70,7 @@ import { encryptSingle } from '../qdn/encryption/group-encryption'; import { _createPoll, _voteOnPoll } from '../qortal/get.ts'; import { createTransaction } from '../transactions/transactions'; import { getData, storeData } from '../utils/chromeStorage'; +import { getWalletErrorMessage } from '../utils/walletErrorMessages.ts'; export function versionCase(request, event) { event.source.postMessage( @@ -259,7 +262,7 @@ export async function decryptWalletCase(request, event) { { requestId: request.requestId, action: 'decryptWallet', - error: error?.message, + error: getWalletErrorMessage(error), type: 'backgroundMessageResponse', }, event.origin @@ -808,6 +811,32 @@ export async function registerNameCase(request, event) { ); } } +export async function updateNameCase(request, event) { + try { + const { newName, oldName, description } = request.payload; + const response = await updateName({ newName, oldName, description }); + + event.source.postMessage( + { + requestId: request.requestId, + action: 'updateName', + payload: response, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: 'updateName', + error: error?.message, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } +} export async function createPollCase(request, event) { try { const { pollName, pollDescription, pollOptions } = request.payload; @@ -949,6 +978,33 @@ export async function addTimestampEnterChatCase(request, event) { } } +export async function markAllMemberGroupsReadCase(request, event) { + try { + const { groupIds } = request.payload; + const response = await markAllMemberGroupsRead(groupIds); + + event.source.postMessage( + { + requestId: request.requestId, + action: 'markAllMemberGroupsRead', + payload: response, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: 'markAllMemberGroupsRead', + error: error?.message, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } +} + export async function setLocalApiKeyNotElectronCase(localApiKey) { storeData('localApiKey', localApiKey); } @@ -1426,13 +1482,17 @@ export async function encryptAndPublishSymmetricKeyGroupChatCase( event ) { try { - const { groupId, previousData } = request.payload; + const { groupId, previousData, isOwner, addKey } = request.payload; + let addKeyVar = false; + if (isOwner && addKey) { + addKeyVar = true; + } const { data, numberOfMembers } = await encryptAndPublishSymmetricKeyGroupChat({ groupId, previousData, + addKey: addKeyVar, }); - event.source.postMessage( { requestId: request.requestId, @@ -1570,7 +1630,6 @@ export async function publishOnQDNCase(request, event) { tag5, uploadType, }); - event.source.postMessage( { requestId: request.requestId, diff --git a/src/background/background.ts b/src/background/background.ts index d5840086..d3e6511c 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,5 +1,6 @@ // @ts-nocheck import '../qortal/qortal-requests.ts'; +import { getNotificationOsPushDisabled } from '../qortal/qortal-requests'; import { isArray } from 'lodash'; import { uint8ArrayToObject } from '../encryption/encryption.ts'; import Base58 from '../encryption/Base58'; @@ -16,6 +17,7 @@ import { signChat } from '../transactions/signChat'; import { createTransaction } from '../transactions/transactions'; import { decryptChatMessage } from '../utils/decryptChatMessage'; import { decryptStoredWallet } from '../utils/decryptWallet'; +import { getWalletErrorMessage } from '../utils/walletErrorMessages'; import PhraseWallet from '../utils/generateWallet/phrase-wallet'; import { RequestQueueWithPromise } from '../utils/queue/queue'; import { validateAddress } from '../utils/validateAddress'; @@ -30,7 +32,6 @@ import { LOCALHOST_12391, MIN_REQUIRED_QORTS, RESOURCE_TYPE_NUMBER_GROUP_CHAT_REACTIONS, - TIME_MINUTES_3_IN_MILLISECONDS, TIME_MINUTES_10_IN_MILLISECONDS, TIME_WEEKS_1_IN_MILLISECONDS, TIME_MINUTES_6_IN_MILLISECONDS, @@ -79,6 +80,7 @@ import { listActionsCase, ltcBalanceCase, makeAdminCase, + markAllMemberGroupsReadCase, nameCase, notifyAdminRegenerateSecretKeyCase, pauseAllQueuesCase, @@ -97,6 +99,7 @@ import { setGroupDataCase, setupGroupWebsocketCase, updateThreadActivityCase, + updateNameCase, userInfoCase, validApiCase, versionCase, @@ -136,7 +139,12 @@ export const groupApiSocketLocal = 'ws://' + LOCALHOST_12391; const timeDifferenceForNotificationChatsBackground = 86400000; const requestQueueAnnouncements = new RequestQueueWithPromise(1); +const generalNotificationPayloadById = new Map(); + function handleNotificationClick(notificationId) { + if (typeof window?.electronAPI?.focusWindow === 'function') { + window.electronAPI.focusWindow(); + } // Decode the notificationId if it was encoded const decodedNotificationId = decodeURIComponent(notificationId); @@ -147,6 +155,9 @@ function handleNotificationClick(notificationId) { '_type=group-announcement_' ); const isNewThreadPost = decodedNotificationId.includes('_type=thread-post_'); + const isGeneralNotification = decodedNotificationId.includes( + '_type=general-notification' + ); // Helper function to extract parameter values safely function getParameterValue(id, key) { @@ -155,7 +166,16 @@ function handleNotificationClick(notificationId) { } const targetOrigin = window.location.origin; // Handle specific notification types and post the message accordingly - if (isDirect) { + if (isGeneralNotification) { + const payload = generalNotificationPayloadById.get(notificationId); + generalNotificationPayloadById.delete(notificationId); + if (payload) { + window.postMessage( + { action: 'NOTIFICATION_OPEN_APP', payload }, + targetOrigin + ); + } + } else if (isDirect) { const fromValue = getParameterValue(decodedNotificationId, '_from'); window.postMessage( { action: 'NOTIFICATION_OPEN_DIRECT', payload: { from: fromValue } }, @@ -275,9 +295,9 @@ export const getCustomNodesFromStorage = async (): Promise => { const getArbitraryEndpoint = async () => { const apiKey = await getApiKeyFromStorage(); // Retrieve apiKey asynchronously if (apiKey) { - return `/arbitrary/resources/searchsimple`; + return `/arbitrary/resources/search`; } else { - return `/arbitrary/resources/searchsimple`; + return `/arbitrary/resources/search`; } }; @@ -1376,7 +1396,7 @@ export async function decryptWallet({ password, wallet, walletVersion }) { return true; } catch (error) { - throw new Error(error.message); + throw new Error(getWalletErrorMessage(error)); } } @@ -3083,6 +3103,45 @@ export async function addTimestampEnterChat({ groupId, timestamp }) { }); } +/** Marks every listed member group chat read (single read/write per store — avoids parallel races). */ +export async function markAllMemberGroupsRead(groupIds) { + if (!Array.isArray(groupIds) || groupIds.length === 0) { + return true; + } + const now = Date.now(); + const wallet = await getSaveWallet(); + const address = wallet.address0; + const enterData = (await getTimestampEnterChat()) || {}; + const announcementData = (await getTimestampGroupAnnouncement()) || {}; + + for (const groupId of groupIds) { + if (groupId == null || groupId === '') continue; + enterData[groupId] = now; + announcementData[groupId] = { + notification: now, + seentimestamp: true, + }; + } + + await new Promise((resolve, reject) => { + storeData(`enter-chat-timestamp-${address}`, enterData) + .then(() => resolve(true)) + .catch((error) => { + reject(new Error(error.message || 'Error saving data')); + }); + }); + + await new Promise((resolve, reject) => { + storeData(`group-announcement-${address}`, announcementData) + .then(() => resolve(true)) + .catch((error) => { + reject(new Error(error.message || 'Error saving data')); + }); + }); + + return true; +} + export async function addTimestampMention({ groupId, timestamp }) { const wallet = await getSaveWallet(); const address = wallet.address0; @@ -3228,6 +3287,9 @@ function setupMessageListener() { case 'registerName': registerNameCase(request, event); break; + case 'updateName': + updateNameCase(request, event); + break; case 'createPoll': createPollCase(request, event); break; @@ -3243,6 +3305,9 @@ function setupMessageListener() { case 'addTimestampEnterChat': addTimestampEnterChatCase(request, event); break; + case 'markAllMemberGroupsRead': + markAllMemberGroupsReadCase(request, event); + break; case 'setApiKey': setApiKeyCase(request, event); break; @@ -3369,10 +3434,6 @@ function setupMessageListener() { clearInterval(notificationCheckInterval); notificationCheckInterval = null; } - if (paymentsCheckInterval) { - clearInterval(paymentsCheckInterval); - paymentsCheckInterval = null; - } groupSecretkeys = {}; const wallet = await getSaveWallet(); const address = wallet.address0; @@ -3566,82 +3627,52 @@ export const checkNewMessages = async () => { } }; -export const checkPaymentsForNotifications = async (address) => { +export const fireOsNotificationPayment = async ( + notificationPayload, + title, + messageBody, + icon, + qortalLink +) => { try { const isDisableNotifications = (await getUserSettings({ key: 'disable-push-notifications' })) || false; if (isDisableNotifications) return; - let latestPayment = null; - const savedtimestamp = await getTimestampLatestPayment(); - - const url = await createEndpoint( - `/transactions/search?txType=PAYMENT&address=${address}&confirmationStatus=CONFIRMED&limit=5&reverse=true` - ); - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const responseData = await response.json(); - const latestTx = responseData.filter( - (tx) => tx?.creatorAddress !== address && tx?.recipient === address - )[0]; - if (!latestTx) { - return; // continue to the next group - } if ( - checkDifference(latestTx.timestamp) && - (!savedtimestamp || latestTx.timestamp > savedtimestamp) + notificationPayload?.event === 'RESOURCE_PUBLISHED' && + notificationPayload?.appName ) { - if (latestTx.timestamp) { - latestPayment = latestTx; - await addTimestampLatestPayment(latestTx.timestamp); - } - - // save new timestamp - } - - if (latestPayment) { - // Create a unique notification ID with type and group announcement details - const notificationId = encodeURIComponent( - 'payment_notification_' + Date.now() + '_type=payment-announcement' + const osPushDisabled = await getNotificationOsPushDisabled( + notificationPayload.appName ); + if (osPushDisabled) return; + } - const title = 'New payment!'; - const body = `You have received a new payment of ${latestPayment?.amount} QORT`; - - // Create and show the notification - const notification = new window.Notification(title, { - body, - icon: window.location.origin + '/qortal192.png', - data: { id: notificationId }, - }); + const notificationId = encodeURIComponent( + 'general_notification_' + Date.now() + '_type=general-notification' + ); - // Handle notification click with specific actions based on `notificationId` - notification.onclick = () => { - handleNotificationClick(notificationId); - notification.close(); // Clean up the notification on click - }; + generalNotificationPayloadById.set( + notificationId, + qortalLink ? { link: qortalLink } : { openWallets: true } + ); - // Automatically close the notification after 5 seconds if it’s not clicked - setTimeout(() => { - notification.close(); - }, 10000); // Close after 5 seconds + const notification = new window.Notification(title, { + body: messageBody, + icon, + data: { id: notificationId }, + }); - const targetOrigin = window.location.origin; + notification.onclick = () => { + handleNotificationClick(notificationId); + notification.close(); + }; - window.postMessage( - { - action: 'SET_PAYMENT_ANNOUNCEMENT', - payload: latestPayment, - }, - targetOrigin - ); - } + setTimeout(() => { + generalNotificationPayloadById.delete(notificationId); + notification.close(); + }, 10000); } catch (error) { console.error(error); } @@ -3814,7 +3845,6 @@ export const checkThreads = async (bringBack) => { }; let notificationCheckInterval; -let paymentsCheckInterval; const createNotificationCheck = () => { // Check if an interval already exists before creating it @@ -3834,21 +3864,6 @@ const createNotificationCheck = () => { } }, TIME_MINUTES_10_IN_MILLISECONDS); } - - if (!paymentsCheckInterval) { - paymentsCheckInterval = setInterval(async () => { - try { - // This would replace the Chrome alarm callback - const wallet = await getSaveWallet(); - const address = wallet?.address0; - if (!address) return; - - checkPaymentsForNotifications(address); - } catch (error) { - console.error('Error checking payments:', error); - } - }, TIME_MINUTES_3_IN_MILLISECONDS); - } }; // Call this function when initializing your app or after user logs in (intervals are cleared on logout) diff --git a/src/common/ErrorBoundary.tsx b/src/common/ErrorBoundary.tsx index 77ca537c..85be6fa5 100644 --- a/src/common/ErrorBoundary.tsx +++ b/src/common/ErrorBoundary.tsx @@ -2,29 +2,44 @@ import { Component, ReactNode } from 'react'; interface ErrorBoundaryProps { children: ReactNode; - fallback: ReactNode; + fallback: ReactNode | ((args: { error: Error | null; componentStack?: string }) => ReactNode); } interface ErrorBoundaryState { hasError: boolean; + error: Error | null; + componentStack?: string; } class ErrorBoundary extends Component { state: ErrorBoundaryState = { hasError: false, + error: null, }; static getDerivedStateFromError(_: Error): ErrorBoundaryState { - return { hasError: true }; + return { hasError: true, error: _ }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { - // You can log the error and errorInfo here, for example, to an error reporting service. + this.setState({ + componentStack: errorInfo.componentStack, + error, + }); console.error('Error caught in ErrorBoundary:', error, errorInfo); } render(): React.ReactNode { - if (this.state.hasError) return this.props.fallback; + if (this.state.hasError) { + if (typeof this.props.fallback === 'function') { + return this.props.fallback({ + error: this.state.error, + componentStack: this.state.componentStack, + }); + } + + return this.props.fallback; + } return this.props.children; } diff --git a/src/components/App/AuthenticatedShell.tsx b/src/components/App/AuthenticatedShell.tsx index f6e007e1..f17a6a93 100644 --- a/src/components/App/AuthenticatedShell.tsx +++ b/src/components/App/AuthenticatedShell.tsx @@ -10,11 +10,9 @@ export type AuthenticatedShellProps = { // Group desktopViewMode: string; isMain: boolean; - isOpenDrawerProfile: boolean; logoutFunc: () => Promise; myAddress: string; setDesktopViewMode: (mode: string) => void; - setIsOpenDrawerProfile: (open: boolean) => void; // AuthenticatedProfile balance: number; userInfo: any; @@ -30,7 +28,6 @@ export type AuthenticatedShellProps = { onOpenSettings: () => void; onOpenDrawerLookup: () => void; onOpenWalletsApp: () => void; - onOpenDrawerProfile: () => void; getUserInfo: (useTimer?: boolean) => Promise; onOpenMinting: () => void; showTutorial: (key: string, force?: boolean) => void; @@ -41,11 +38,9 @@ export function AuthenticatedShell({ balance, desktopViewMode, isMain, - isOpenDrawerProfile, logoutFunc, myAddress, setDesktopViewMode, - setIsOpenDrawerProfile, userInfo, rawWallet, qortBalanceLoading, @@ -59,7 +54,6 @@ export function AuthenticatedShell({ onOpenSettings, onOpenDrawerLookup, onOpenWalletsApp, - onOpenDrawerProfile, getUserInfo, onOpenMinting, showTutorial, @@ -73,11 +67,12 @@ export function AuthenticatedShell({ height: '100%', isolation: 'isolate', position: 'relative', - width: '100vw', + width: '100%', + willChange: 'opacity, transform', '&::before': { background: theme.palette.mode === 'dark' - ? 'linear-gradient(to bottom, rgba(16, 18, 22, 0.16), rgba(16, 18, 22, 0.09))' + ? 'linear-gradient(to bottom, rgba(255, 255, 255, 0.03), rgba(9, 11, 15, 0.02))' : 'linear-gradient(to bottom, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08))', content: '""', inset: 0, @@ -94,11 +89,10 @@ export function AuthenticatedShell({ void; setStoredAccount: (v: boolean) => void; onCreateAccount: () => void; - onBackupAccountConfirm: () => void; + onBackupAccountConfirm: () => Promise; + onEnterHub: () => void; exportSeedphrase: () => void; }; @@ -47,306 +43,425 @@ export function CreateWalletView({ walletToBeDownloadedPassword, walletToBeDownloadedPasswordConfirm, walletToBeDownloadedError, - showSeed, storeAccount, generatorRef, confirmRef, onReturnBack, - onShowSeed, - onHideSeed, onCreationStepNext, setWalletToBeDownloadedPassword, setWalletToBeDownloadedPasswordConfirm, setStoredAccount, onCreateAccount, onBackupAccountConfirm, + onEnterHub, exportSeedphrase, }: CreateWalletViewProps) { + const { t } = useTranslation(['auth']); const theme = useTheme(); - const { t } = useTranslation(['auth', 'core']); + const isLight = theme.palette.mode === 'light'; + const [backupDownloaded, setBackupDownloaded] = useState(false); + const [seedphraseCopied, setSeedphraseCopied] = useState(false); + const [seedphraseRevealed, setSeedphraseRevealed] = useState(false); + const [passwordStepError, setPasswordStepError] = useState(''); + const generatedSeedphrase = generatorRef.current?.parsedString || ''; + const successAccent = backupDownloaded + ? { + border: 'rgba(126,171,255,0.36)', + icon: 'rgb(126,171,255)', + surface: 'rgba(64,111,213,0.1)', + } + : { + border: 'rgba(88,199,113,0.34)', + icon: 'rgb(88,199,113)', + surface: 'rgba(88,199,113,0)', + }; - return ( - <> - {!walletToBeDownloaded && ( - <> - + const passwordsMatch = + walletToBeDownloadedPassword && + walletToBeDownloadedPasswordConfirm && + walletToBeDownloadedPassword === walletToBeDownloadedPasswordConfirm; + + const handleNextFromPassword = () => { + if (!walletToBeDownloadedPassword) { + setPasswordStepError(t('auth:create_wallet.error_enter_password')); + return; + } + + if (!walletToBeDownloadedPasswordConfirm) { + setPasswordStepError(t('auth:create_wallet.error_confirm_password')); + return; + } + + if (!passwordsMatch) { + setPasswordStepError(t('auth:create_wallet.error_passwords_mismatch')); + return; + } + + setPasswordStepError(''); + onCreationStepNext(); + }; + + const handleCopyPhrase = async () => { + if (!seedphraseRevealed) { + setSeedphraseRevealed(true); + return; + } + + if (!generatedSeedphrase) return; + try { + await navigator.clipboard.writeText(generatedSeedphrase); + setSeedphraseCopied(true); + window.setTimeout(() => setSeedphraseCopied(false), 1600); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + setBackupDownloaded(false); + }, [walletToBeDownloaded?.qortAddress]); + + const handleDownloadBackup = async () => { + const saved = await onBackupAccountConfirm(); + if (saved) { + setBackupDownloaded(true); + } + }; + + if (walletToBeDownloaded) { + return ( + + - - -
- Qortal -
- - - {t('auth:action.setup_qortal_account', { - postProcess: 'capitalizeFirstChar', - })} - - - - + - - - ), - }} - tOptions={{ postProcess: ['capitalizeFirstChar'] }} - /> - - - {t('auth:tips.view_seedphrase', { - postProcess: 'capitalizeFirstChar', - })} - - - , - }} - tOptions={{ postProcess: ['capitalizeFirstChar'] }} - /> - - - - {t('core:pagination.next', { - postProcess: 'capitalizeFirstChar', - })} - - -
- {/* @ts-expect-error custom element from randomSentenceGenerator */} - -
- + - - - - {t('auth:seed_your', { - postProcess: 'capitalizeFirstChar', - })} - - - {generatorRef.current?.parsedString} - - - {t('auth:action.export_seedphrase', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - +
+ - - - {t('auth:wallet.password', { - postProcess: 'capitalizeFirstChar', - })} - - - setWalletToBeDownloadedPassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') confirmRef.current?.focus(); - }} - /> - - - {t('auth:wallet.password_confirmation', { - postProcess: 'capitalizeFirstChar', - })} - - - - setWalletToBeDownloadedPasswordConfirm(e.target.value) - } - onKeyDown={(e) => { - if (e.key === 'Enter') onCreateAccount(); - }} - /> - - - {t('auth:message.generic.no_minimum_length', { - postProcess: 'capitalizeFirstChar', - })} - - - setStoredAccount(e.target.checked)} - checked={storeAccount} - edge="start" - tabIndex={-1} - disableRipple - sx={{ - '&.Mui-checked': { - color: theme.palette.text.secondary, - }, - '& .MuiSvgIcon-root': { - color: theme.palette.text.secondary, - }, - }} - /> - } - label={ - - - {t('auth:store_account', { - postProcess: 'capitalizeFirstChar', - })} - - - } - /> - - - {t('auth:action.create_account', { - postProcess: 'capitalizeFirstChar', - })} - + {backupDownloaded ? ( + <> + + {t('auth:create_wallet.enter_hub', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('auth:create_wallet.download_another_copy', { + postProcess: 'capitalizeFirstChar', + })} + + + ) : ( + <> + + {t('auth:create_wallet.backup_wallet', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('auth:create_wallet.enter_hub', { + postProcess: 'capitalizeFirstChar', + })} + + + )} - {walletToBeDownloadedError} - - )} - {walletToBeDownloaded && ( - <> - - - - - {t('auth:message.generic.congrats_setup', { + {t('auth:create_wallet.backup_encrypted_notice', { postProcess: 'capitalizeFirstChar', })} - - - + +
+ ); + } + + if (creationStep === 2) { + return ( + + + - - - {t('auth:tips.safe_place', { - postProcess: 'capitalizeFirstChar', - })} - - - - - {t('core:action.backup_account', { - postProcess: 'capitalizeFirstChar', - })} - - - )} - + + + + + + + {seedphraseRevealed ? generatedSeedphrase : ''} + + + + + + {!seedphraseRevealed + ? t('auth:create_wallet.reveal') + : seedphraseCopied + ? t('auth:create_wallet.copied') + : t('auth:create_wallet.copy')} + + + {t('auth:create_wallet.export')} + + + + + + + {t('auth:create_wallet.seed_warning')} + + + + + {t('auth:create_wallet.create_account')} + + + ); + } + + return ( + + + + + + + + + + {t('auth:create_wallet.wallet_password')} + + setWalletToBeDownloadedPassword(e.target.value)} + name="create-wallet-password" + suppressAutofill + sx={authPasswordFieldSx(theme)} + onKeyDown={(e) => { + if (e.key === 'Enter') confirmRef.current?.focus(); + }} + /> + + + + + {t('auth:create_wallet.confirm_password')} + + + setWalletToBeDownloadedPasswordConfirm(e.target.value) + } + name="create-wallet-password-confirmation" + suppressAutofill + sx={authPasswordFieldSx(theme)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleNextFromPassword(); + }} + /> + + + setStoredAccount(event.target.checked)} + sx={{ + color: theme.palette.text.secondary, + '&.Mui-checked': { + color: theme.palette.primary.main, + }, + }} + /> + } + label={ + + {t('auth:create_wallet.save_account_in_hub')} + + } + /> + + {passwordStepError || walletToBeDownloadedError} + + + {t('auth:create_wallet.continue')} + + ); } diff --git a/src/components/App/ElectronPersistentStorageHydration.tsx b/src/components/App/ElectronPersistentStorageHydration.tsx new file mode 100644 index 00000000..9c24b002 --- /dev/null +++ b/src/components/App/ElectronPersistentStorageHydration.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + customWebsocketSubscriptionsByAddressAtom, + filterSeenInAppRecordByAge, + notificationSeenInAppKeysRecordAtom, + parseSeenInAppStored, + seenAllNotificationsByAddressAtom, + userInfoAtom, +} from '../../atoms/global'; +import { + hydrateElectronPersistentCache, + ELECTRON_PERSISTENT_ATOM_KEYS, + primeElectronPersistentCacheKey, +} from '../../utils/electronPersistentStorage'; + +/** + * When running in Electron, loads persisted values from appStorage and sets + * the atoms so the UI shows the correct state. Renders nothing. + */ +export function ElectronPersistentStorageHydration() { + const setCustomSubscriptionsByAddress = useSetAtom(customWebsocketSubscriptionsByAddressAtom); + const setSeenInAppRecord = useSetAtom(notificationSeenInAppKeysRecordAtom); + const setSeenAllNotificationsByAddress = useSetAtom(seenAllNotificationsByAddressAtom); + const userAddress = useAtomValue(userInfoAtom)?.address; + const hydratedRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const appStorage = (window as Window & { appStorage?: { get: (k: string) => Promise } }).appStorage; + if (!appStorage || hydratedRef.current) return; + hydratedRef.current = true; + + (async () => { + await hydrateElectronPersistentCache(); + const [subsPayload, seen, seenAllPayload] = await Promise.all([ + appStorage.get(ELECTRON_PERSISTENT_ATOM_KEYS.customWsSubscriptionsByAddress), + appStorage.get(ELECTRON_PERSISTENT_ATOM_KEYS.notificationSeenInApp), + appStorage.get(ELECTRON_PERSISTENT_ATOM_KEYS.seenAllNotificationsByAddress), + ]); + if (subsPayload != null) { + if (Array.isArray(subsPayload)) { + setCustomSubscriptionsByAddress({ __legacy: subsPayload }); + } else if (typeof subsPayload === 'object' && !Array.isArray(subsPayload)) { + setCustomSubscriptionsByAddress(subsPayload as Record); + } + } + if (seen != null && typeof seen === 'object' && !Array.isArray(seen)) { + const record = filterSeenInAppRecordByAge(parseSeenInAppStored(seen)); + if (Object.keys(record).length > 0) setSeenInAppRecord(record); + } + if (seenAllPayload != null && typeof seenAllPayload === 'object' && !Array.isArray(seenAllPayload)) { + setSeenAllNotificationsByAddress(seenAllPayload as Record); + } + })(); + }, [setCustomSubscriptionsByAddress, setSeenInAppRecord, setSeenAllNotificationsByAddress]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const appStorage = (window as Window & { appStorage?: { get: (k: string) => Promise } }) + .appStorage; + if (!appStorage) return; + + let cancelled = false; + const key = ELECTRON_PERSISTENT_ATOM_KEYS.seenAllNotificationsByAddress; + void (async () => { + try { + const seenAllPayload = await appStorage.get(key); + if (cancelled) return; + if ( + seenAllPayload != null && + typeof seenAllPayload === 'object' && + !Array.isArray(seenAllPayload) + ) { + primeElectronPersistentCacheKey(key, seenAllPayload); + setSeenAllNotificationsByAddress(seenAllPayload as Record); + } + } catch (err) { + console.error( + '[ElectronPersistentStorageHydration] reload seen-all on address change:', + err + ); + } + })(); + return () => { + cancelled = true; + }; + }, [userAddress, setSeenAllNotificationsByAddress]); + + return null; +} diff --git a/src/components/App/InfoDialog.tsx b/src/components/App/InfoDialog.tsx index e246ec5f..7927a523 100644 --- a/src/components/App/InfoDialog.tsx +++ b/src/components/App/InfoDialog.tsx @@ -8,6 +8,14 @@ import { useTheme, } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { + dialogActionsSx, + dialogContentSx, + dialogContentTextSx, + dialogTitleSx, + getDialogPaperSx, + getDialogPrimaryButtonSx, +} from './dialogSurface'; type InfoDialogProps = { open: boolean; @@ -24,25 +32,31 @@ export function InfoDialog({ open, message, onClose }: InfoDialogProps) { open={open} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + PaperProps={{ + sx: getDialogPaperSx(theme, { maxWidth: 420 }), + }} > {t('tutorial:important_info', { postProcess: 'capitalizeAll' })} - - + + {message} - - diff --git a/src/components/App/NotAuthenticatedFooter.tsx b/src/components/App/NotAuthenticatedFooter.tsx index c9334125..eb761a4d 100644 --- a/src/components/App/NotAuthenticatedFooter.tsx +++ b/src/components/App/NotAuthenticatedFooter.tsx @@ -1,5 +1,6 @@ -import { Box, IconButton } from '@mui/material'; -import HubIcon from '@mui/icons-material/Hub'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; +import { useTranslation } from 'react-i18next'; import LanguageSelector from '../Language/LanguageSelector'; import ThemeSelector from '../Theme/ThemeSelector'; @@ -12,31 +13,36 @@ export function NotAuthenticatedFooter({ showCoreSetup, onOpenCoreSetup, }: NotAuthenticatedFooterProps) { + const { t } = useTranslation(['core']); return ( {showCoreSetup && ( - - - - + + + + + + )} - + + - - - ); } diff --git a/src/components/App/NotificationPermissionSlideDown.tsx b/src/components/App/NotificationPermissionSlideDown.tsx new file mode 100644 index 00000000..6f521d59 --- /dev/null +++ b/src/components/App/NotificationPermissionSlideDown.tsx @@ -0,0 +1,199 @@ +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; +import { Box, Button, Paper, Typography, alpha, useTheme } from '@mui/material'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; + +const TIMEOUT_MS = 60_000; +const TIMEOUT_S = TIMEOUT_MS / 1000; + +type PendingRequest = { + requestId: string; + appInfo: { name?: string }; + payload?: { text1?: string }; + expiresAt: number; +}; + +function sendResponse(requestId: string, accepted: boolean) { + window.postMessage( + { + action: 'NOTIFICATION_PERMISSION_RESPONSE', + requestId, + result: { accepted }, + }, + window.location.origin + ); +} + +export function NotificationPermissionSlideDown() { + const { t } = useTranslation('question'); + const theme = useTheme(); + const [queue, setQueue] = useState([]); + const [secondsLeft, setSecondsLeft] = useState(TIMEOUT_S); + const autoTimeoutRef = useRef | null>(null); + const intervalRef = useRef | null>(null); + + useEffect(() => { + const listener = (event: CustomEvent>) => { + const { requestId, appInfo, payload } = event.detail || {}; + if (!requestId || !appInfo) return; + setQueue((prev) => [ + ...prev, + { + requestId, + appInfo, + payload, + expiresAt: Date.now() + TIMEOUT_MS, + }, + ]); + }; + subscribeToEvent('show-notification-permission', listener as any); + return () => + unsubscribeFromEvent('show-notification-permission', listener as any); + }, []); + + useEffect(() => { + if (autoTimeoutRef.current) clearTimeout(autoTimeoutRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + + const current = queue[0]; + if (!current) return; + + const remaining = Math.max(0, current.expiresAt - Date.now()); + setSecondsLeft(Math.ceil(remaining / 1000)); + autoTimeoutRef.current = setTimeout(() => { + sendResponse(current.requestId, false); + setQueue((prev) => prev.slice(1)); + }, remaining); + intervalRef.current = setInterval(() => { + setSecondsLeft(Math.max(0, Math.ceil((current.expiresAt - Date.now()) / 1000))); + }, 500); + + return () => { + if (autoTimeoutRef.current) clearTimeout(autoTimeoutRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [queue]); + + const current = queue[0]; + if (!current) return null; + + const dismissCurrent = (accepted: boolean) => { + sendResponse(current.requestId, accepted); + setQueue((prev) => prev.slice(1)); + }; + + const progress = secondsLeft / TIMEOUT_S; + + return ( + + + + + + + + + {t('permission.notification_title', { + appName: current.appInfo?.name || 'App', + defaultValue: '{{appName}} notifications', + postProcess: 'capitalizeFirstChar', + })} + + + {current.payload?.text1 || + t('permission.notification', { + defaultValue: 'Allow this app to send you Hub notifications?', + postProcess: 'capitalizeFirstChar', + })} + + + + + + + + {secondsLeft}s + + + + ); +} diff --git a/src/components/App/PaymentPublishDialog.tsx b/src/components/App/PaymentPublishDialog.tsx index 5e163a0f..0a3b4523 100644 --- a/src/components/App/PaymentPublishDialog.tsx +++ b/src/components/App/PaymentPublishDialog.tsx @@ -1,10 +1,12 @@ import { + Box, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + alpha, useTheme, } from '@mui/material'; import { useTranslation } from 'react-i18next'; @@ -36,73 +38,153 @@ export function PaymentPublishDialog({ open={open} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + PaperProps={{ + sx: { + background: '#121821', + backgroundImage: 'none', + border: '1px solid rgba(169,188,216,0.18)', + borderRadius: '18px', + boxShadow: '0 26px 56px rgba(0,0,0,0.44)', + color: theme.palette.text.primary, + minWidth: 360, + overflow: 'hidden', + }, + }} sx={{ zIndex: 10001 }} > {message.paymentFee ? t('core:payment', { postProcess: 'capitalizeFirstChar' }) : t('core:publish', { postProcess: 'capitalizeFirstChar' })} - - + + {message.message} - {message?.paymentFee && ( - - {t('core:fee.payment', { postProcess: 'capitalizeFirstChar' })}:{' '} - {message.paymentFee} - - )} - {message?.publishFee && ( - - {t('core:fee.publish', { postProcess: 'capitalizeFirstChar' })}:{' '} - {message.publishFee} - + {(message?.paymentFee || message?.publishFee) && ( + + + {message?.paymentFee + ? t('core:fee.payment', { + postProcess: 'capitalizeFirstChar', + }) + : t('core:fee.publish', { + postProcess: 'capitalizeFirstChar', + })} + + + {message?.paymentFee || message.publishFee} + + )} - + diff --git a/src/components/App/ReceiveQortOverlay.tsx b/src/components/App/ReceiveQortOverlay.tsx new file mode 100644 index 00000000..7cf512a8 --- /dev/null +++ b/src/components/App/ReceiveQortOverlay.tsx @@ -0,0 +1,437 @@ +import { useMemo, useRef } from 'react'; +import { + alpha, + Box, + Button, + ButtonBase, + Portal, + Typography, + useTheme, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; +import CloseIcon from '@mui/icons-material/Close'; +import { motion, useReducedMotion } from 'framer-motion'; +import QRCode from 'react-qr-code'; +import { useTranslation } from 'react-i18next'; +import { getBlueTier1ButtonSx } from '../../styles/blueMaterial'; + +type ReceiveQortOverlayProps = { + address: string; + originRect?: { + left: number; + top: number; + width: number; + height: number; + } | null; + targetRect?: { + left: number; + top: number; + width: number; + height: number; + } | null; + onReturn: () => void; +}; + +export function ReceiveQortOverlay({ + address, + originRect = null, + targetRect = null, + onReturn, +}: ReceiveQortOverlayProps) { + const theme = useTheme(); + const { t } = useTranslation(['group']); + const td = (key: string, defaultValue: string) => + t(`group:dashboard.${key}`, { defaultValue }); + const prefersReducedMotion = useReducedMotion(); + const isDarkMode = theme.palette.mode === 'dark'; + const qrContainerRef = useRef(null); + const isAnchoredLayout = !!targetRect; + const surfaceBorder = alpha(theme.palette.divider, 0.38); + const softSectionSurface = alpha( + isDarkMode ? theme.palette.common.white : theme.palette.text.primary, + isDarkMode ? 0.022 : 0.03 + ); + const shellDivider = alpha(theme.palette.divider, 0.28); + const innerDivider = alpha(theme.palette.divider, isDarkMode ? 0.16 : 0.2); + + const fallbackPanelWidth = useMemo(() => { + if (typeof window === 'undefined') return 620; + return Math.min(700, Math.max(560, window.innerWidth - 48)); + }, []); + + const panelLayout = useMemo(() => { + if (targetRect) { + return { + height: targetRect.height, + left: targetRect.left, + top: targetRect.top, + width: targetRect.width, + }; + } + + return { + width: fallbackPanelWidth, + }; + }, [fallbackPanelWidth, targetRect]); + + const panelAnimation = useMemo(() => { + if (originRect && targetRect && !prefersReducedMotion) { + return { + initial: { + borderRadius: 10, + opacity: 0.9, + scaleX: Math.max(0.18, originRect.width / targetRect.width), + scaleY: Math.max(0.12, originRect.height / targetRect.height), + x: originRect.left - targetRect.left, + y: + originRect.top + + originRect.height - + (targetRect.top + targetRect.height), + }, + animate: { + borderRadius: 18, + opacity: 1, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + }, + exit: { + borderRadius: 10, + opacity: 0.9, + scaleX: Math.max(0.18, originRect.width / targetRect.width), + scaleY: Math.max(0.12, originRect.height / targetRect.height), + x: originRect.left - targetRect.left, + y: + originRect.top + + originRect.height - + (targetRect.top + targetRect.height), + }, + }; + } + + return { + initial: prefersReducedMotion + ? { opacity: 0 } + : { opacity: 0, y: 18, scale: 0.985 }, + animate: prefersReducedMotion + ? { opacity: 1 } + : { opacity: 1, y: 0, scale: 1 }, + exit: prefersReducedMotion + ? { opacity: 0 } + : { opacity: 0, y: 10, scale: 0.985 }, + }; + }, [originRect, prefersReducedMotion, targetRect]); + + const qrSize = useMemo(() => { + const widthBound = Math.max(170, panelLayout.width - 176); + const heightBound = Math.max(170, panelLayout.height - 320); + return Math.min(240, widthBound, heightBound); + }, [panelLayout.height, panelLayout.width]); + + const handleCopyAddress = async () => { + if (!address) return; + try { + await navigator.clipboard.writeText(address); + } catch (error) { + console.error('Failed to copy address:', error); + } + }; + + const handleDownloadQr = () => { + if (!address || !qrContainerRef.current) return; + const svg = qrContainerRef.current.querySelector('svg'); + if (!svg) return; + + const serializer = new XMLSerializer(); + const source = serializer.serializeToString(svg); + const blob = new Blob([source], { + type: 'image/svg+xml;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'qort-wallet-qr.svg'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( + + <> + + + event.stopPropagation()} + sx={{ + position: 'fixed', + inset: isAnchoredLayout ? undefined : 0, + left: isAnchoredLayout ? `${panelLayout.left}px` : undefined, + top: isAnchoredLayout ? `${panelLayout.top}px` : undefined, + alignItems: isAnchoredLayout ? undefined : 'center', + display: isAnchoredLayout ? undefined : 'flex', + justifyContent: isAnchoredLayout ? undefined : 'center', + overflow: 'visible', + p: isAnchoredLayout ? 0 : 3, + pointerEvents: 'none', + transformOrigin: targetRect ? 'bottom left' : 'center center', + height: isAnchoredLayout ? `${panelLayout.height}px` : undefined, + width: isAnchoredLayout ? `${panelLayout.width}px` : undefined, + willChange: 'transform, opacity', + zIndex: 1399, + }} + > + + + + + {td('receive_qort', 'Receive QORT')} + + + {td('receive_qort_subtitle', 'Scan to receive QORT')} + + + + + + + + + + + + + + + + + {address || '—'} + + + + + + + + + + + + + ); +} diff --git a/src/components/App/SendQortOverlay.tsx b/src/components/App/SendQortOverlay.tsx index 03f4d928..c61d7be9 100644 --- a/src/components/App/SendQortOverlay.tsx +++ b/src/components/App/SendQortOverlay.tsx @@ -1,11 +1,29 @@ -import { Box } from '@mui/material'; -import { Return } from '../../assets/Icons/Return.tsx'; -import { Spacer } from '../../common/Spacer'; +import { useMemo } from 'react'; +import { + alpha, + Box, + ButtonBase, + Portal, + Typography, + useTheme, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { motion, useReducedMotion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; import { QortPayment } from '../QortPayment'; +type SendQortOriginRect = { + left: number; + top: number; + width: number; + height: number; +} | null; + type SendQortOverlayProps = { balance: number; paymentTo: string; + originRect?: SendQortOriginRect; + targetRect?: SendQortOriginRect; onReturn: () => void; onSuccess: () => void; show: (data: any) => Promise; @@ -14,50 +32,264 @@ type SendQortOverlayProps = { export function SendQortOverlay({ balance, paymentTo, + originRect = null, + targetRect = null, onReturn, onSuccess, show, }: SendQortOverlayProps) { + const theme = useTheme(); + const { t } = useTranslation(['group']); + const td = (key: string, defaultValue: string) => + t(`group:dashboard.${key}`, { defaultValue }); + const prefersReducedMotion = useReducedMotion(); + const isDarkMode = theme.palette.mode === 'dark'; + const isAnchoredLayout = !!targetRect; + const surfaceBorder = alpha(theme.palette.divider, 0.38); + const headerDivider = alpha(theme.palette.divider, 0.28); + + const fallbackPanelWidth = useMemo(() => { + if (typeof window === 'undefined') return 620; + return Math.min(700, Math.max(560, window.innerWidth - 48)); + }, []); + + const panelLayout = useMemo(() => { + if (targetRect) { + return { + height: targetRect.height, + left: targetRect.left, + top: targetRect.top, + width: targetRect.width, + }; + } + + return { + width: fallbackPanelWidth, + }; + }, [fallbackPanelWidth, targetRect]); + + const panelAnimation = useMemo(() => { + if (originRect && targetRect && !prefersReducedMotion) { + return { + initial: { + borderRadius: 10, + opacity: 0.9, + scaleX: Math.max(0.18, originRect.width / targetRect.width), + scaleY: Math.max(0.12, originRect.height / targetRect.height), + x: originRect.left - targetRect.left, + y: + originRect.top + + originRect.height - + (targetRect.top + targetRect.height), + }, + animate: { + borderRadius: 18, + opacity: 1, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + }, + exit: { + borderRadius: 10, + opacity: 0.9, + scaleX: Math.max(0.18, originRect.width / targetRect.width), + scaleY: Math.max(0.12, originRect.height / targetRect.height), + x: originRect.left - targetRect.left, + y: + originRect.top + + originRect.height - + (targetRect.top + targetRect.height), + }, + }; + } + + return { + initial: prefersReducedMotion + ? { opacity: 0 } + : { opacity: 0, y: 18, scale: 0.985 }, + animate: prefersReducedMotion + ? { opacity: 1 } + : { opacity: 1, y: 0, scale: 1 }, + exit: prefersReducedMotion + ? { opacity: 0 } + : { opacity: 0, y: 10, scale: 0.985 }, + }; + }, [prefersReducedMotion]); + return ( - theme.palette.background.default, - display: 'flex', - flexDirection: 'column', - height: '100%', - position: 'fixed', - width: '100%', - zIndex: 10000, - }} - > - - - + <> + - - - - + + event.stopPropagation()} + sx={{ + position: 'fixed', + inset: isAnchoredLayout ? undefined : 0, + left: isAnchoredLayout ? `${panelLayout.left}px` : undefined, + top: isAnchoredLayout ? `${panelLayout.top}px` : undefined, + alignItems: isAnchoredLayout ? undefined : 'center', + display: isAnchoredLayout ? undefined : 'flex', + justifyContent: isAnchoredLayout ? undefined : 'center', + overflow: 'visible', + p: isAnchoredLayout ? 0 : 3, + pointerEvents: 'none', + transformOrigin: targetRect ? 'bottom left' : 'center center', + height: isAnchoredLayout ? `${panelLayout.height}px` : undefined, + willChange: 'transform, opacity', + width: isAnchoredLayout ? `${panelLayout.width}px` : undefined, + zIndex: 1399, + }} + > + + + + + {td('send_qort', 'Send QORT')} + + + {td( + 'send_qort_subtitle', + 'Transfer QORT to any registered name or address.' + )} + + + + + + + + + + + + + + ); } diff --git a/src/components/App/SuccessOverlay.tsx b/src/components/App/SuccessOverlay.tsx index d0c265d2..33d4c495 100644 --- a/src/components/App/SuccessOverlay.tsx +++ b/src/components/App/SuccessOverlay.tsx @@ -1,6 +1,6 @@ -import { Box, ButtonBase } from '@mui/material'; +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import { Box, ButtonBase, Typography, alpha, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { Spacer } from '../../common/Spacer'; import { CustomButton, TextP } from '../../styles/App-styles.ts'; import { SuccessIcon } from '../../assets/Icons/SuccessIcon.tsx'; @@ -22,31 +22,16 @@ export function SuccessOverlay({ fullPage = true, }: SuccessOverlayProps) { const { t } = useTranslation([messageNs, buttonLabelNs]); - - const content = ( - <> - - - - - {t(`${messageNs}:${messageKey}`, { - postProcess: 'capitalizeFirstChar', - })} - - - - - {t(`${buttonLabelNs}:${buttonLabelKey}`, { - postProcess: 'capitalizeFirstChar', - })} - - - + const theme = useTheme(); + const message = t(`${messageNs}:${messageKey}`, { + postProcess: 'capitalizeFirstChar', + }); + const buttonLabel = t(`${buttonLabelNs}:${buttonLabelKey}`, { + postProcess: 'capitalizeFirstChar', + }); + const transferSubmittedDetail = t( + `${messageNs}:message.success.transfer_submitted`, + { postProcess: 'capitalizeFirstChar' } ); if (fullPage) { @@ -54,19 +39,95 @@ export function SuccessOverlay({ theme.palette.background.default, + background: theme.palette.background.default, display: 'flex', - flexDirection: 'column', height: '100%', + justifyContent: 'center', position: 'fixed', width: '100%', zIndex: 10000, }} > - {content} + + + + + + {message} + + + {transferSubmittedDetail} + + + + {buttonLabel} + + + ); } + const content = ( + <> + + + {message} + + + + {buttonLabel} + + + + ); + return <>{content}; } diff --git a/src/components/App/UnsavedChangesDialog.tsx b/src/components/App/UnsavedChangesDialog.tsx index 7dd54b28..0e148c41 100644 --- a/src/components/App/UnsavedChangesDialog.tsx +++ b/src/components/App/UnsavedChangesDialog.tsx @@ -5,6 +5,7 @@ import { DialogContent, DialogContentText, DialogTitle, + alpha, useTheme, } from '@mui/material'; import { useTranslation } from 'react-i18next'; @@ -23,48 +24,97 @@ export function UnsavedChangesDialog({ onConfirm, }: UnsavedChangesDialogProps) { const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; const { t } = useTranslation(['core']); return ( - {t('core:action.logout', { postProcess: 'capitalizeAll' })} + {t('core:action.logout', { postProcess: 'capitalizeFirstChar' })}? {message} - + @@ -73,14 +123,14 @@ export function UnsavedChangesDialog({ onClick={onConfirm} autoFocus sx={{ - backgroundColor: theme.palette.other.positive, - color: theme.palette.text.primary, - fontWeight: 'bold', - opacity: 0.7, + bgcolor: theme.palette.primary.main, + borderRadius: '10px', + color: theme.palette.primary.contrastText, + fontWeight: 600, + minWidth: 140, + textTransform: 'none', '&:hover': { - backgroundColor: theme.palette.other.positive, - color: 'black', - opacity: 1, + bgcolor: theme.palette.primary.dark, }, }} > diff --git a/src/components/App/WalletsView.tsx b/src/components/App/WalletsView.tsx index 456948e3..226a1c32 100644 --- a/src/components/App/WalletsView.tsx +++ b/src/components/App/WalletsView.tsx @@ -1,7 +1,11 @@ -import { Box } from '@mui/material'; -import { Return } from '../../assets/Icons/Return.tsx'; -import { Spacer } from '../../common/Spacer'; +import { Box, ButtonBase } from '@mui/material'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { useTranslation } from 'react-i18next'; import { Wallets } from '../Wallets'; +import { AuthScreen } from '../Auth/AuthShell'; +import { useEffect, useState } from 'react'; + +type ImportView = 'choice' | 'backup' | 'seedphrase' | 'authenticate'; type WalletsViewProps = { onBack: () => void; @@ -16,36 +20,59 @@ export function WalletsView({ setExtState, rawWallet, }: WalletsViewProps) { + const { t } = useTranslation(['auth']); + const [importView, setImportView] = useState('choice'); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onBack(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [onBack]); + return ( - <> - - - - + + {importView === 'choice' && ( + + + + + + )} + - + ); } diff --git a/src/components/App/dialogSurface.ts b/src/components/App/dialogSurface.ts new file mode 100644 index 00000000..7a204605 --- /dev/null +++ b/src/components/App/dialogSurface.ts @@ -0,0 +1,119 @@ +import { alpha, type Theme } from '@mui/material/styles'; + +/** Backdrop for modals that overlay the auth shell — default MUI backdrop is too light here. */ +export const dialogModalBackdropSx = { + backgroundColor: 'rgba(3, 5, 12, 0.94)', +}; + +export const getDialogPaperSx = ( + theme: Theme, + options?: { + maxWidth?: number; + radius?: number; + } +) => ({ + backgroundColor: '#0C1118', + backgroundImage: 'linear-gradient(180deg, #121821 0%, #0C1118 100%)', + border: `1px solid ${alpha('#A9BCD8', 0.18)}`, + borderRadius: `${options?.radius ?? 18}px`, + boxShadow: '0 26px 56px rgba(0,0,0,0.44)', + color: theme.palette.text.primary, + maxWidth: options?.maxWidth, + overflow: 'hidden', + width: 'calc(100% - 40px)', +}); + +export const dialogTitleSx = { + borderBottom: '1px solid rgba(169,188,216,0.1)', + color: 'rgba(246,248,252,0.96)', + fontSize: '1.04rem', + fontWeight: 650, + lineHeight: 1.3, + px: 3, + py: 2, + textAlign: 'left', +}; + +export const dialogContentSx = { + px: 3, + pb: 2.65, + '&&': { + pt: 2.75, + }, +}; + +export const dialogContentTextSx = { + color: 'rgba(214,221,233,0.78)', + fontSize: '0.92rem', + lineHeight: 1.6, + m: 0, + textAlign: 'left', +}; + +export const dialogActionsSx = { + borderTop: '1px solid rgba(169,188,216,0.1)', + gap: 1.2, + justifyContent: 'flex-end', + px: 3, + py: 1.8, +}; + +export const getDialogSecondaryButtonSx = (theme: Theme) => ({ + backgroundColor: '#1A212C', + border: '1px solid rgba(169,188,216,0.16)', + borderRadius: '11px', + color: theme.palette.text.primary, + fontSize: '0.9rem', + fontWeight: 600, + minHeight: 42, + minWidth: 112, + px: 2.2, + textTransform: 'none', + '&:hover': { + backgroundColor: '#1D2633', + borderColor: 'rgba(169,188,216,0.24)', + }, +}); + +export const getDialogPrimaryButtonSx = (theme: Theme) => ({ + backgroundColor: theme.palette.primary.main, + borderRadius: '11px', + color: '#FFFFFF', + fontSize: '0.9rem', + fontWeight: 600, + minHeight: 42, + minWidth: 112, + px: 2.2, + textTransform: 'none', + '&:hover': { + backgroundColor: theme.palette.primary.main, + filter: 'brightness(1.05)', + }, +}); + +export const getDialogDangerButtonSx = () => ({ + backgroundColor: '#2A1B20', + border: '1px solid rgba(214, 112, 112, 0.18)', + borderRadius: '11px', + color: '#F2C1C1', + fontSize: '0.9rem', + fontWeight: 600, + minHeight: 42, + minWidth: 112, + px: 2.2, + textTransform: 'none', + '&:hover': { + backgroundColor: '#312026', + borderColor: 'rgba(214, 112, 112, 0.26)', + }, +}); + +export const dialogInfoCardSx = { + backgroundColor: '#1A212C', + border: '1px solid rgba(169,188,216,0.13)', + borderRadius: '12px', + display: 'grid', + gap: 0.55, + px: 1.45, + py: 1.2, +}; diff --git a/src/components/App/index.ts b/src/components/App/index.ts index 9e2f7dc3..90b859bb 100644 --- a/src/components/App/index.ts +++ b/src/components/App/index.ts @@ -1,5 +1,6 @@ export { AuthenticatedShell } from './AuthenticatedShell'; export { SendQortOverlay } from './SendQortOverlay'; +export { ReceiveQortOverlay } from './ReceiveQortOverlay'; export { SuccessOverlay } from './SuccessOverlay'; export { SuccessScreen } from './SuccessScreen'; export { WalletsView } from './WalletsView'; @@ -15,4 +16,6 @@ export { PaymentPublishDialog } from './PaymentPublishDialog'; export { InfoDialog } from './InfoDialog'; export { UnsavedChangesDialog } from './UnsavedChangesDialog'; export { QortalRequestExtensionDialog } from './QortalRequestExtensionDialog'; +export { NotificationPermissionSlideDown } from './NotificationPermissionSlideDown'; +export { ElectronPersistentStorageHydration } from './ElectronPersistentStorageHydration'; export type { MessageQortalRequestExtension } from './QortalRequestExtensionDialog'; diff --git a/src/components/Apps/AppBookmarks/AppBookmarksButton.tsx b/src/components/Apps/AppBookmarks/AppBookmarksButton.tsx new file mode 100644 index 00000000..ea045848 --- /dev/null +++ b/src/components/Apps/AppBookmarks/AppBookmarksButton.tsx @@ -0,0 +1,1292 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { MouseEvent } from 'react'; +import { + Box, + Button, + ButtonBase, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + IconButton, + Menu, + MenuItem, + Popover, + Select, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import ArrowBackIosNewRoundedIcon from '@mui/icons-material/ArrowBackIosNewRounded'; +import BookmarkAddOutlinedIcon from '@mui/icons-material/BookmarkAddOutlined'; +import BookmarkAddedIcon from '@mui/icons-material/BookmarkAdded'; +import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; +import CreateNewFolderOutlinedIcon from '@mui/icons-material/CreateNewFolderOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; +import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import ShortUniqueId from 'short-unique-id'; +import { atomWithStorage } from 'jotai/utils'; +import { useAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { executeEvent } from '../../../utils/events'; +import type { + AppBookmark, + AppBookmarkFolder, + AppBookmarksForAddress, + BookmarkableAppTab, +} from './bookmarkTypes'; +import { + buildBookmarkLink, + findBookmarkForCandidate, + getBookmarkCandidateFromTab, + loadBookmarksForAddress, + parseBookmarkLink, + removeBookmark, + removeFolder, + saveBookmarksForAddress, + upsertBookmark, + upsertFolder, +} from './bookmarkStorage'; + +const uid = new ShortUniqueId({ length: 10 }); +const BOOKMARK_VIEW_STORAGE_KEY = 'qortal_app_bookmark_view_by_address'; +const bookmarkViewByAddressAtom = atomWithStorage>( + BOOKMARK_VIEW_STORAGE_KEY, + {} +); + +type AppBookmarksButtonProps = { + address?: string | null; + buttonSx?: object; + chromeBackground?: string; + selectedTab: BookmarkableAppTab | null; + tooltipSlotProps?: any; + tooltipTitle?: (text: string) => React.ReactNode; +}; + +type BookmarkFormState = { + id?: string; + name: string; + link: string; + folderId: string | null; + createdAt?: number; +}; + +const emptyData: AppBookmarksForAddress = { + folders: [], + bookmarks: [], + updatedAt: Date.now(), +}; + +function increaseBackgroundOpacity(color: string): string { + return color.replace( + /rgba\(([^,]+),([^,]+),([^,]+),\s*([^)]+)\)/, + (_match, red, green, blue) => `rgba(${red},${green},${blue}, 0.99)` + ); +} + +export function AppBookmarksButton({ + address, + buttonSx, + chromeBackground, + selectedTab, + tooltipSlotProps, + tooltipTitle, +}: AppBookmarksButtonProps) { + const theme = useTheme(); + const { t } = useTranslation(['core']); + const [anchorEl, setAnchorEl] = useState(null); + const [data, setData] = useState(emptyData); + const [hasLoaded, setHasLoaded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [form, setForm] = useState(null); + const [folderName, setFolderName] = useState(''); + const [showCreateFolder, setShowCreateFolder] = useState(false); + const [renamingFolder, setRenamingFolder] = + useState(null); + const [deleteFolderTarget, setDeleteFolderTarget] = + useState(null); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [menuAnchorPosition, setMenuAnchorPosition] = useState<{ + left: number; + top: number; + } | null>(null); + const [moveMenuAnchorEl, setMoveMenuAnchorEl] = + useState(null); + const [menuTarget, setMenuTarget] = useState< + | { type: 'bookmark'; bookmark: AppBookmark } + | { type: 'folder'; folder: AppBookmarkFolder } + | null + >(null); + const [viewByAddress, setViewByAddress] = useAtom(bookmarkViewByAddressAtom); + + const currentFolderId = address ? viewByAddress[address] || null : null; + + const candidate = useMemo( + () => getBookmarkCandidateFromTab(selectedTab), + [selectedTab] + ); + const existingBookmark = useMemo( + () => findBookmarkForCandidate(data.bookmarks, candidate), + [candidate, data.bookmarks] + ); + const isBookmarked = !!existingBookmark; + const isOpen = Boolean(anchorEl); + const isDark = theme.palette.mode === 'dark'; + const currentFolder = + data.folders.find((folder) => folder.id === currentFolderId) || null; + const bookmarkChromeBackground = + increaseBackgroundOpacity( + chromeBackground || (isDark ? 'rgb(33, 36, 42)' : 'rgb(223, 228, 235)') + ); + const bookmarkFieldBackground = + isDark + ? 'rgba(28, 31, 37, 0.98)' + : 'rgba(232, 236, 241, 0.96)'; + const bookmarkHoverBackground = + isDark + ? 'rgba(255, 255, 255, 0.07)' + : 'rgba(0, 0, 0, 0.06)'; + const bookmarkInsetBackground = + isDark + ? 'rgba(28, 31, 37, 0.82)' + : 'rgba(232, 236, 241, 0.88)'; + const panelBorderColor = isDark + ? alpha('#A9BCD8', 0.18) + : theme.palette.divider; + const bookmarkMenuPaperSx = { + backgroundColor: bookmarkChromeBackground, + backgroundImage: 'none', + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: '8px', + color: theme.palette.text.primary, + boxShadow: + theme.palette.mode === 'dark' + ? '0 16px 34px rgba(0,0,0,0.44)' + : '0 16px 34px rgba(28,38,52,0.16)', + minWidth: 174, + p: 0.5, + '.MuiList-root': { + p: 0, + }, + } as const; + const bookmarkMenuItemSx = { + borderRadius: '7px', + color: theme.palette.text.primary, + fontSize: 13, + fontWeight: 500, + gap: 1, + minHeight: 36, + px: 1.15, + py: 0.75, + '& .MuiSvgIcon-root': { + color: theme.palette.text.secondary, + flexShrink: 0, + fontSize: 18, + mr: 0, + }, + '&:hover, &.Mui-focusVisible, &.Mui-selected, &.Mui-selected:hover': { + backgroundColor: bookmarkHoverBackground, + }, + } as const; + const bookmarkMenuDangerItemSx = { + ...bookmarkMenuItemSx, + color: isDark ? '#F2C1C1' : theme.palette.error.main, + '& .MuiSvgIcon-root': { + color: isDark ? '#F2C1C1' : theme.palette.error.main, + flexShrink: 0, + fontSize: 18, + mr: 0, + }, + } as const; + const bookmarkTextFieldSx = { + '.MuiInputBase-root': { + backgroundColor: bookmarkFieldBackground, + borderRadius: '8px', + fontSize: 13, + }, + '.MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.border.subtle, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.border.main, + }, + '& .Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: + theme.palette.mode === 'dark' + ? 'rgba(130, 185, 255, 0.42)' + : 'rgba(41, 121, 218, 0.32)', + }, + } as const; + const bookmarkFieldLabelSx = { + color: theme.palette.text.secondary, + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.04em', + lineHeight: 1, + textTransform: 'uppercase', + } as const; + + useEffect(() => { + if (!address || !currentFolderId || !hasLoaded) return; + const stillExists = data.folders.some((folder) => folder.id === currentFolderId); + if (!stillExists) { + setViewByAddress((prev) => ({ ...prev, [address]: null })); + } + }, [address, currentFolderId, data.folders, hasLoaded, setViewByAddress]); + + const setCurrentFolderId = (folderId: string | null) => { + if (!address) return; + setViewByAddress((prev) => ({ ...prev, [address]: folderId })); + setForm(null); + setShowCreateFolder(false); + setRenamingFolder(null); + }; + + const persist = async (nextData: AppBookmarksForAddress) => { + if (!address) { + setData(nextData); + return; + } + const saved = await saveBookmarksForAddress(address, nextData); + setData(saved); + }; + + const openPopover = async (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + if (!address) return; + + setIsLoading(true); + try { + const loaded = await loadBookmarksForAddress(address); + setData(loaded); + setHasLoaded(true); + const nextExisting = findBookmarkForCandidate( + loaded.bookmarks, + candidate + ); + if (candidate && !nextExisting) { + setForm({ + name: '', + link: candidate.link, + folderId: null, + }); + } + } finally { + setIsLoading(false); + } + }; + + const closePopover = () => { + setAnchorEl(null); + setForm(null); + setFolderName(''); + setShowCreateFolder(false); + setRenamingFolder(null); + setMenuAnchorEl(null); + setMenuAnchorPosition(null); + setMoveMenuAnchorEl(null); + setMenuTarget(null); + }; + + const openBookmark = (bookmark: AppBookmark) => { + executeEvent('addTab', { + data: { + service: bookmark.service, + name: bookmark.appName, + identifier: bookmark.identifier, + path: bookmark.path + }, + }); + executeEvent('open-apps-mode', {}); + closePopover(); + }; + + const startAddCurrent = () => { + if (!candidate) return; + setForm({ + name: '', + link: candidate.link, + folderId: currentFolderId, + }); + }; + + const startEdit = (bookmark: AppBookmark) => { + setForm({ + id: bookmark.id, + name: bookmark.name, + link: bookmark.link || buildBookmarkLink(bookmark), + folderId: bookmark.folderId, + createdAt: bookmark.createdAt, + }); + }; + + const saveBookmark = async () => { + if (!form) return; + + const parsed = parseBookmarkLink(form.link); + if (!form.name.trim() || !parsed) return; + + const now = Date.now(); + const bookmark: AppBookmark = { + id: form.id || uid.rnd(), + name: form.name.trim(), + service: parsed.service, + appName: parsed.appName, + identifier: parsed.identifier, + path: parsed.path, + link: buildBookmarkLink(parsed), + folderId: form.folderId, + createdAt: form.createdAt || now, + updatedAt: now, + }; + + await persist(upsertBookmark(data, bookmark)); + setForm(null); + }; + + const removeBookmarkById = async (bookmarkId: string) => { + await persist(removeBookmark(data, bookmarkId)); + if (form?.id === bookmarkId) setForm(null); + }; + + const saveFolder = async () => { + const trimmed = folderName.trim(); + if (!trimmed) return; + + const now = Date.now(); + await persist( + upsertFolder(data, { + id: uid.rnd(), + name: trimmed, + createdAt: now, + updatedAt: now, + }) + ); + setFolderName(''); + setShowCreateFolder(false); + }; + + const saveFolderRename = async () => { + if (!renamingFolder) return; + const trimmed = renamingFolder.name.trim(); + if (!trimmed) return; + + await persist( + upsertFolder(data, { + ...renamingFolder, + name: trimmed, + updatedAt: Date.now(), + }) + ); + setRenamingFolder(null); + }; + + const moveBookmark = async (bookmark: AppBookmark, folderId: string) => { + await persist( + upsertBookmark(data, { + ...bookmark, + folderId: folderId || null, + updatedAt: Date.now(), + }) + ); + }; + + const confirmDeleteFolder = async () => { + if (!deleteFolderTarget) return; + await persist(removeFolder(data, deleteFolderTarget.id)); + if (currentFolderId === deleteFolderTarget.id) { + setCurrentFolderId(null); + } + setDeleteFolderTarget(null); + }; + + const deleteFolder = async (folder: AppBookmarkFolder) => { + const hasBookmarks = data.bookmarks.some( + (bookmark) => bookmark.folderId === folder.id + ); + if (hasBookmarks) { + setDeleteFolderTarget(folder); + return; + } + await persist(removeFolder(data, folder.id)); + if (currentFolderId === folder.id) { + setCurrentFolderId(null); + } + }; + + const openItemMenu = ( + event: MouseEvent, + target: + | { type: 'bookmark'; bookmark: AppBookmark } + | { type: 'folder'; folder: AppBookmarkFolder } + ) => { + event.stopPropagation(); + setMenuAnchorPosition(null); + setMenuAnchorEl(event.currentTarget); + setMenuTarget(target); + }; + + const openItemContextMenu = ( + event: MouseEvent, + target: + | { type: 'bookmark'; bookmark: AppBookmark } + | { type: 'folder'; folder: AppBookmarkFolder } + ) => { + event.preventDefault(); + event.stopPropagation(); + setMoveMenuAnchorEl(null); + setMenuAnchorEl(null); + setMenuAnchorPosition({ left: event.clientX + 2, top: event.clientY - 6 }); + setMenuTarget(target); + }; + + const closeItemMenu = () => { + setMenuAnchorEl(null); + setMenuAnchorPosition(null); + setMenuTarget(null); + }; + + const closeMoveMenu = () => { + setMoveMenuAnchorEl(null); + setMenuTarget(null); + }; + + const renderBookmarkRow = (bookmark: AppBookmark) => ( + openBookmark(bookmark)} + onContextMenu={(event) => + openItemContextMenu(event, { type: 'bookmark', bookmark }) + } + sx={{ + alignItems: 'center', + borderRadius: '8px', + display: 'flex', + gap: 1, + justifyContent: 'flex-start', + minHeight: 42, + px: 1, + py: 0.5, + textAlign: 'left', + width: '100%', + '&:hover': { + backgroundColor: bookmarkHoverBackground, + }, + }} + > + + + + {bookmark.name} + + + {bookmark.link} + + + + + openItemMenu(event, { type: 'bookmark', bookmark }) + } + sx={{ flexShrink: 0 }} + > + + + + + ); + + const visibleBookmarks = data.bookmarks.filter( + (bookmark) => (bookmark.folderId || null) === currentFolderId + ); + const moveOptions = + menuTarget?.type === 'bookmark' + ? [ + ...(menuTarget.bookmark.folderId + ? [{ id: '', name: t('core:bookmarks.top_level') }] + : []), + ...data.folders + .filter((folder) => folder.id !== menuTarget.bookmark.folderId) + .map((folder) => ({ id: folder.id, name: folder.name })), + ] + : []; + const buttonTitle = isBookmarked + ? t('core:bookmarks.bookmarked') + : t('core:bookmarks.title'); + + return ( + <> + + + + {isBookmarked ? ( + + ) : ( + + )} + + + + + + + + {currentFolder && ( + setCurrentFolderId(null)}> + + + )} + + + {currentFolder?.name || t('core:bookmarks.title')} + + {currentFolder && ( + + {t('core:bookmarks.folder')} + + )} + + {!currentFolder && ( + + { + setShowCreateFolder((prev) => !prev); + setForm(null); + }} + > + + + + )} + {candidate && ( + + )} + + + {isLoading && ( + + + + {t('core:bookmarks.loading')} + + + )} + + {!isLoading && showCreateFolder && !currentFolder && ( + + + + {t('core:bookmarks.folder_name')} + + { + if (event.key === 'Enter') { + event.preventDefault(); + saveFolder(); + } + }} + onChange={(e) => setFolderName(e.target.value)} + /> + + + { + setShowCreateFolder(false); + setFolderName(''); + }} + size="small" + sx={{ + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: '8px', + height: 40, + width: 40, + }} + > + + + + + + )} + + {!isLoading && form && ( + + + + + + {form.id + ? t('core:bookmarks.edit_bookmark') + : t('core:bookmarks.add_bookmark')} + + + {t('core:bookmarks.save_location_without_query')} + + + + + + + {t('core:bookmarks.name')} + + setForm({ ...form, name: e.target.value })} + /> + + + + + {t('core:bookmarks.qortal_link')} + + setForm({ ...form, link: e.target.value })} + /> + + + + + {t('core:bookmarks.folder')} + + + + + + + + {form.id && ( + + )} + + + + + )} + + {!isLoading && hasLoaded && ( + <> + + + + {!currentFolder && + data.folders.map((folder) => { + const count = data.bookmarks.filter( + (bookmark) => bookmark.folderId === folder.id + ).length; + return ( + setCurrentFolderId(folder.id)} + onContextMenu={(event) => + openItemContextMenu(event, { type: 'folder', folder }) + } + sx={{ + alignItems: 'center', + borderRadius: '8px', + display: 'flex', + gap: 1, + justifyContent: 'flex-start', + minHeight: 42, + px: 1, + py: 0.5, + textAlign: 'left', + width: '100%', + '&:hover': { + backgroundColor: bookmarkHoverBackground, + }, + }} + > + + {renamingFolder?.id === folder.id ? ( + event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') saveFolderRename(); + }} + size="small" + sx={bookmarkTextFieldSx} + value={renamingFolder.name} + onChange={(e) => + setRenamingFolder({ + ...renamingFolder, + name: e.target.value, + }) + } + /> + ) : ( + + + {folder.name} + + + {t('core:bookmarks.bookmark_count', { count })} + + + )} + {renamingFolder?.id === folder.id ? ( + + ) : ( + + openItemMenu(event, { type: 'folder', folder }) + } + sx={{ flexShrink: 0 }} + > + + + )} + + ); + })} + + {visibleBookmarks.map(renderBookmarkRow)} + + {visibleBookmarks.length === 0 && + (currentFolder || data.folders.length === 0) && ( + + {currentFolder + ? t('core:bookmarks.no_bookmarks_folder') + : t('core:bookmarks.no_bookmarks')} + + )} + + + )} + + + + + {menuTarget?.type === 'bookmark' && [ + { + startEdit(menuTarget.bookmark); + closeItemMenu(); + }} + > + + {t('core:bookmarks.edit')} + , + ...(moveOptions.length > 0 + ? [ + { + setMoveMenuAnchorEl(event.currentTarget); + setMenuAnchorEl(null); + }} + > + + {t('core:bookmarks.move')} + , + ] + : []), + { + removeBookmarkById(menuTarget.bookmark.id); + closeItemMenu(); + }} + > + + {t('core:bookmarks.delete')} + , + ]} + {menuTarget?.type === 'folder' && [ + { + setRenamingFolder(menuTarget.folder); + closeItemMenu(); + }} + > + + {t('core:bookmarks.rename')} + , + { + deleteFolder(menuTarget.folder); + closeItemMenu(); + }} + > + + {t('core:bookmarks.delete')} + , + ]} + + + + {menuTarget?.type === 'bookmark' && + moveOptions.map((option) => ( + { + moveBookmark(menuTarget.bookmark, option.id); + closeMoveMenu(); + }} + > + {option.name} + + ))} + + + setDeleteFolderTarget(null)} + aria-labelledby="delete-bookmark-folder-title" + aria-describedby="delete-bookmark-folder-description" + PaperProps={{ + sx: isDark + ? { + bgcolor: '#111820', + backgroundImage: 'none', + border: `1px solid ${alpha('#A9BCD8', 0.18)}`, + borderRadius: '18px', + boxShadow: `0 24px 58px ${alpha('#000', 0.42)}`, + maxWidth: 360, + width: 'calc(100% - 40px)', + } + : { + bgcolor: theme.palette.background.paper, + backgroundImage: 'none', + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: '18px', + boxShadow: `0 22px 48px ${alpha('#000', 0.09)}, 0 0 0 1px ${alpha(theme.palette.divider, 0.45)}`, + color: theme.palette.text.primary, + maxWidth: 360, + width: 'calc(100% - 40px)', + }, + }} + > + + {t('core:bookmarks.delete_folder_title')} + + + + {t('core:bookmarks.delete_folder_message')} + + + + + + + + + ); +} diff --git a/src/components/Apps/AppBookmarks/bookmarkStorage.ts b/src/components/Apps/AppBookmarks/bookmarkStorage.ts new file mode 100644 index 00000000..ebe64131 --- /dev/null +++ b/src/components/Apps/AppBookmarks/bookmarkStorage.ts @@ -0,0 +1,229 @@ +import { QORTAL_PROTOCOL } from '../../../constants/constants'; +import { extractComponents } from '../../Chat/MessageDisplay'; +import type { + AppBookmark, + AppBookmarkFolder, + AppBookmarksByAddress, + AppBookmarksForAddress, + BookmarkableAppTab, +} from './bookmarkTypes'; + +export const APP_BOOKMARKS_STORAGE_KEY = 'qortal_app_bookmarks_by_address'; + +const INTERNAL_TAB_SERVICE = 'INTERNAL'; + +const emptyBookmarks = (): AppBookmarksForAddress => ({ + folders: [], + bookmarks: [], + updatedAt: Date.now(), +}); + +function asBookmarksByAddress(value: unknown): AppBookmarksByAddress { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as AppBookmarksByAddress; +} + +function appStorage() { + if (typeof window === 'undefined') return undefined; + return window.appStorage; +} + +export function normalizeBookmarkPath(path?: string): string { + const raw = (path || '').trim(); + if (!raw) return ''; + + const queryIndex = raw.indexOf('?'); + const hashIndex = raw.indexOf('#'); + const endIndex = [queryIndex, hashIndex] + .filter((index) => index >= 0) + .sort((a, b) => a - b)[0]; + const stripped = endIndex === undefined ? raw : raw.slice(0, endIndex); + + return stripped.replace(/^\/+/, '').replace(/\/+$/, ''); +} + +function encodeAppName(name: string): string { + return name.trim().replace(/ /g, '%20'); +} + +export function buildBookmarkLink(bookmark: { + service: string; + appName: string; + identifier?: string; + path?: string; +}): string { + const path = normalizeBookmarkPath(bookmark.path); + const identifier = (bookmark.identifier || '').trim(); + const pathPart = path ? `/${path}` : ''; + const identifierPart = identifier + ? `?identifier=${encodeURIComponent(identifier)}` + : ''; + + return `${QORTAL_PROTOCOL}${bookmark.service}/${encodeAppName(bookmark.appName)}${pathPart}${identifierPart}`; +} + +export function parseBookmarkLink(link: string) { + const parsed = extractComponents(link.trim()); + if (!parsed?.service || !parsed?.name) return null; + + return { + service: parsed.service, + appName: parsed.name, + identifier: parsed.identifier, + path: normalizeBookmarkPath(parsed.path), + }; +} + +export function getBookmarkKey(bookmark: { + service?: string; + appName?: string; + name?: string; + identifier?: string; + path?: string; +}): string { + return [ + (bookmark.service || '').toUpperCase(), + (bookmark.appName || bookmark.name || '').toLowerCase(), + bookmark.identifier || '', + normalizeBookmarkPath(bookmark.path), + ].join(':'); +} + +export function getBookmarkCandidateFromTab( + tab: BookmarkableAppTab | null | undefined +) { + if (!tab?.service || !tab?.name) return null; + if (tab.internal || tab.service === INTERNAL_TAB_SERVICE) return null; + + const path = normalizeBookmarkPath(tab.path); + const candidate = { + service: tab.service.toUpperCase(), + appName: tab.name, + identifier: tab.identifier, + path, + }; + + return { + ...candidate, + name: tab.name, + link: buildBookmarkLink(candidate), + }; +} + +export function findBookmarkForCandidate( + bookmarks: AppBookmark[], + candidate: ReturnType +) { + if (!candidate) return null; + const key = getBookmarkKey(candidate); + return bookmarks.find((bookmark) => getBookmarkKey(bookmark) === key) || null; +} + +export async function loadBookmarksForAddress( + address: string +): Promise { + const storage = appStorage(); + if (!storage || !address) return emptyBookmarks(); + + const raw = await storage.get(APP_BOOKMARKS_STORAGE_KEY); + const byAddress = asBookmarksByAddress(raw); + const data = byAddress[address]; + + return { + folders: Array.isArray(data?.folders) ? data.folders : [], + bookmarks: Array.isArray(data?.bookmarks) ? data.bookmarks : [], + updatedAt: typeof data?.updatedAt === 'number' ? data.updatedAt : Date.now(), + }; +} + +export async function saveBookmarksForAddress( + address: string, + data: AppBookmarksForAddress +): Promise { + const storage = appStorage(); + const nextData = { + folders: data.folders, + bookmarks: data.bookmarks, + updatedAt: Date.now(), + }; + + if (!storage || !address) return nextData; + + const raw = await storage.get(APP_BOOKMARKS_STORAGE_KEY); + const byAddress = asBookmarksByAddress(raw); + await storage.set(APP_BOOKMARKS_STORAGE_KEY, { + ...byAddress, + [address]: nextData, + }); + + return nextData; +} + +export function upsertBookmark( + data: AppBookmarksForAddress, + bookmark: AppBookmark +): AppBookmarksForAddress { + const key = getBookmarkKey(bookmark); + const index = data.bookmarks.findIndex( + (existing) => getBookmarkKey(existing) === key || existing.id === bookmark.id + ); + const bookmarks = [...data.bookmarks]; + + if (index >= 0) { + bookmarks[index] = bookmark; + } else { + bookmarks.push(bookmark); + } + + return { + ...data, + bookmarks, + updatedAt: Date.now(), + }; +} + +export function removeBookmark( + data: AppBookmarksForAddress, + bookmarkId: string +): AppBookmarksForAddress { + return { + ...data, + bookmarks: data.bookmarks.filter((bookmark) => bookmark.id !== bookmarkId), + updatedAt: Date.now(), + }; +} + +export function upsertFolder( + data: AppBookmarksForAddress, + folder: AppBookmarkFolder +): AppBookmarksForAddress { + const index = data.folders.findIndex((existing) => existing.id === folder.id); + const folders = [...data.folders]; + + if (index >= 0) { + folders[index] = folder; + } else { + folders.push(folder); + } + + return { + ...data, + folders, + updatedAt: Date.now(), + }; +} + +export function removeFolder( + data: AppBookmarksForAddress, + folderId: string +): AppBookmarksForAddress { + return { + ...data, + folders: data.folders.filter((folder) => folder.id !== folderId), + bookmarks: data.bookmarks.map((bookmark) => + bookmark.folderId === folderId ? { ...bookmark, folderId: null } : bookmark + ), + updatedAt: Date.now(), + }; +} + diff --git a/src/components/Apps/AppBookmarks/bookmarkTypes.ts b/src/components/Apps/AppBookmarks/bookmarkTypes.ts new file mode 100644 index 00000000..35a5cb8f --- /dev/null +++ b/src/components/Apps/AppBookmarks/bookmarkTypes.ts @@ -0,0 +1,37 @@ +export type AppBookmarkFolder = { + id: string; + name: string; + createdAt: number; + updatedAt: number; +}; + +export type AppBookmark = { + id: string; + name: string; + service: string; + appName: string; + identifier?: string; + path: string; + link: string; + folderId: string | null; + createdAt: number; + updatedAt: number; +}; + +export type AppBookmarksForAddress = { + folders: AppBookmarkFolder[]; + bookmarks: AppBookmark[]; + updatedAt: number; +}; + +export type AppBookmarksByAddress = Record; + +export type BookmarkableAppTab = { + tabId?: string; + name?: string; + service?: string; + identifier?: string; + path?: string; + internal?: string; +}; + diff --git a/src/components/Apps/AppBookmarks/index.ts b/src/components/Apps/AppBookmarks/index.ts new file mode 100644 index 00000000..b923b738 --- /dev/null +++ b/src/components/Apps/AppBookmarks/index.ts @@ -0,0 +1,2 @@ +export { AppBookmarksButton } from './AppBookmarksButton'; + diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index 135e5ac2..d3d34f7f 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -1,22 +1,23 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { Box } from '@mui/material'; import { getBaseApiReact } from '../../App'; import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; import { useFrame } from 'react-frame-component'; import { useQortalMessageListener } from '../../hooks/useQortalMessageListener'; import { useThemeContext } from '../Theme/ThemeContext'; import { useTranslation } from 'react-i18next'; -import { buildQortalResourceLink } from '../../utils/qortalLink'; +import { QORTAL_PROTOCOL } from '../../constants/constants'; +import { appHeighOffsetPx } from '../Desktop/CustomTitleBar'; type AppViewerProps = { app: any; - customHeight?: string; hide: boolean; isDevMode: boolean; skipAuth?: boolean; }; export const AppViewer = forwardRef( - ({ app, customHeight, hide, isDevMode, skipAuth }, iframeRef) => { + ({ app, hide, isDevMode, skipAuth }, iframeRef) => { const { window: frameWindow } = useFrame(); const { path, history, changeCurrentIndex, resetHistory } = useQortalMessageListener( @@ -26,7 +27,6 @@ export const AppViewer = forwardRef( isDevMode, isDevMode ? 'devapp' : app?.name, app?.service, - app?.identifier, skipAuth ); @@ -133,11 +133,8 @@ export const AppViewer = forwardRef( const copyLinkFunc = (e) => { const { tabId } = e.detail; if (tabId === app?.tabId) { - let link = buildQortalResourceLink({ - service: app?.service, - name: app?.name, - identifier: app?.identifier, - }); + let link = + QORTAL_PROTOCOL + app?.service + '/' + app?.name.replace(/ /g, '%20'); if (path && path.startsWith('/')) { link = link + removeTrailingSlash(path); } @@ -146,9 +143,7 @@ export const AppViewer = forwardRef( } navigator.clipboard .writeText(link) - .then(() => { - console.log('Path copied to clipboard:', path); - }) + .then(() => undefined) .catch((error) => { console.error('Failed to copy path:', error); }); @@ -292,8 +287,6 @@ export const AppViewer = forwardRef( ); // iframeRef.current.contentWindow.location.href = previousPath; // Fallback URL update } - } else { - console.log('Iframe not accessible or does not have a content window.'); } }; @@ -313,61 +306,99 @@ export const AppViewer = forwardRef( }; }, [app, history, themeMode, currentLang]); - // Function to navigate back in iframe - const navigateForwardInIframe = async () => { - if (iframeRef.current && iframeRef.current.contentWindow) { + const navigateToPathFunc = useCallback( + async (e) => { + const { path: targetPath = '' } = e.detail; + if (!iframeRef.current?.contentWindow) return; + const targetOrigin = iframeRef.current ? new URL(iframeRef.current.src).origin : '*'; - iframeRef.current.contentWindow.postMessage( - { action: 'NAVIGATE_FORWARD' }, - targetOrigin - ); - } else { - console.log('Iframe not accessible or does not have a content window.'); - } - }; - const navigateForwardAppFunc = () => { - navigateForwardInIframe(); - }; + const navigationPromise = new Promise((resolve, reject) => { + function handleNavigationSuccess(event) { + if ( + event.data?.action === 'NAVIGATION_SUCCESS' && + event.data.path === targetPath + ) { + frameWindow.removeEventListener( + 'message', + handleNavigationSuccess + ); + resolve(undefined); + } + } + + frameWindow.addEventListener('message', handleNavigationSuccess); + + setTimeout(() => { + frameWindow.removeEventListener('message', handleNavigationSuccess); + reject(new Error('navigation_timeout')); + }, 250); + iframeRef.current.contentWindow.postMessage( + { + action: 'NAVIGATE_TO_PATH', + path: targetPath, + requestedHandler: 'UI', + }, + targetOrigin + ); + }); + + try { + await navigationPromise; + } catch { + if (isDevMode) { + setUrl( + `${url}${targetPath}?theme=${themeMode}&lang=${currentLang}&time=${new Date().getMilliseconds()}&isManualNavigation=false` + ); + return; + } + setUrl( + `${getBaseApiReact()}/render/${app?.service}/${app?.name}/${targetPath}?theme=${themeMode}&lang=${currentLang}&identifier=${app?.identifier != null && app?.identifier != 'null' ? app?.identifier : ''}&time=${new Date().getMilliseconds()}&isManualNavigation=false` + ); + } + }, + [app, frameWindow, iframeRef, isDevMode, url, themeMode, currentLang] + ); useEffect(() => { if (!app?.tabId) return; - subscribeToEvent( - `navigateForwardApp-${app?.tabId}`, - navigateForwardAppFunc - ); + subscribeToEvent(`navigateToPath-${app?.tabId}`, navigateToPathFunc); return () => { unsubscribeFromEvent( - `navigateForwardApp-${app?.tabId}`, - navigateForwardAppFunc + `navigateToPath-${app?.tabId}`, + navigateToPathFunc ); }; - }, [app?.tabId]); + }, [app?.tabId, navigateToPathFunc]); + + // Function to navigate back in iframe + const navigateForwardInIframe = async () => { + if (iframeRef.current && iframeRef.current.contentWindow) { + const targetOrigin = iframeRef.current + ? new URL(iframeRef.current.src).origin + : '*'; + iframeRef.current.contentWindow.postMessage( + { action: 'NAVIGATE_FORWARD' }, + targetOrigin + ); + } + }; return ( -
-
+
); } ); diff --git a/src/components/Apps/Apps-styles.tsx b/src/components/Apps/Apps-styles.tsx index ce9c6c84..91b4481e 100644 --- a/src/components/Apps/Apps-styles.tsx +++ b/src/components/Apps/Apps-styles.tsx @@ -286,8 +286,8 @@ export const AppsHorizontalTabBar = styled(Box)(({ theme }) => ({ alignItems: 'center', backgroundColor: theme.palette.mode === 'dark' - ? alpha(theme.palette.common.black, 0.12) - : alpha(theme.palette.common.white, 0.58), + ? alpha(theme.palette.common.white, 0.03) + : alpha(theme.palette.common.black, 0.03), borderBottom: `1px solid ${theme.palette.border.subtle}`, color: theme.palette.text.primary, display: 'flex', @@ -300,13 +300,17 @@ export const AppsHorizontalTabBar = styled(Box)(({ theme }) => ({ '&::before': { backgroundColor: theme.palette.mode === 'dark' - ? alpha(theme.palette.common.white, 0.035) - : alpha(theme.palette.common.black, 0.025), + ? alpha(theme.palette.common.white, 0.02) + : alpha(theme.palette.common.black, 0.018), content: '""', inset: 0, pointerEvents: 'none', position: 'absolute', }, + boxShadow: + theme.palette.mode === 'dark' + ? `inset 0 1px 0 ${alpha(theme.palette.common.white, 0.025)}` + : `inset 0 1px 0 ${alpha(theme.palette.common.white, 0.35)}`, })); export const AppsHorizontalTabScroller = styled(Box)(({ theme }) => ({ @@ -315,10 +319,14 @@ export const AppsHorizontalTabScroller = styled(Box)(({ theme }) => ({ backgroundColor: 'transparent', color: theme.palette.text.primary, display: 'flex', + flexWrap: 'nowrap', gap: '2px', height: 'auto', + justifyContent: 'flex-start', minHeight: 0, - overflow: 'hidden', + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', padding: '0 10px 0 6px', position: 'relative', width: '100%', @@ -338,14 +346,15 @@ export const AppsHorizontalTabButton = styled(ButtonBase)(({ theme }) => ({ borderRadius: '8px', color: theme.palette.text.primary, display: 'flex', - flex: '1 1 0', + flex: '0 1 180px', gap: '8px', height: '36px', justifyContent: 'flex-start', - maxWidth: '240px', - minWidth: '104px', - padding: '0 9px', + minWidth: 0, + padding: '0 10px', position: 'relative', + transition: + 'background-color 180ms ease, color 180ms ease, border-color 180ms ease', })); export const AppsHorizontalTabLabel = styled(Typography)(({ theme }) => ({ @@ -354,6 +363,7 @@ export const AppsHorizontalTabLabel = styled(Typography)(({ theme }) => ({ fontSize: '12.5px', fontWeight: 600, letterSpacing: '0.01em', + minWidth: 0, overflow: 'hidden', textAlign: 'left', textOverflow: 'ellipsis', diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index 54ad9016..6d092926 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -2,11 +2,30 @@ import { createRef, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react'; + +import { flushSync } from 'react-dom'; + +import type { ReactNode } from 'react'; +import { + closestCenter, + DndContext, + DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, +} from '@dnd-kit/sortable'; import { AppsHomeDesktop } from './AppsHomeDesktop'; +import { AppsDevModeHome } from './AppsDevModeHome'; import { Spacer } from '../../common/Spacer'; import { getBaseApiReact } from '../../App'; import { AppInfo } from './AppInfo'; @@ -27,7 +46,10 @@ import AppViewerContainer from './AppViewerContainer'; import TabComponent from './TabComponent'; import ShortUniqueId from 'short-unique-id'; import { AppPublish } from './AppPublish'; -import { RatingsCacheInitializer, useAppRatings } from '../../hooks/useAppRatings'; +import { + RatingsCacheInitializer, + useAppRatings, +} from '../../hooks/useAppRatings'; import { AppsLibraryDesktop } from './AppsLibraryDesktop'; import { AppsCategoryDesktop } from './AppsCategoryDesktop'; import AddRoundedIcon from '@mui/icons-material/AddRounded'; @@ -40,7 +62,9 @@ import { DialogContent, DialogContentText, DialogTitle, + useTheme, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import { navigationControllerAtom, isNewTabWindowAtom, @@ -53,9 +77,86 @@ import { TIME_MINUTES_20_IN_MILLISECONDS } from '../../constants/constants'; import { appChromeOffsetPx } from '../Desktop/CustomTitleBar'; import { extractComponents } from '../Chat/MessageDisplay'; import { QORTAL_PROTOCOL } from '../../constants/constants'; +import { QCHAT_INTERNAL_TAB_ID } from '../../utils/openQChatTab'; +import { + dialogActionsSx, + dialogContentSx, + dialogContentTextSx, + dialogTitleSx, + getDialogDangerButtonSx, + getDialogPaperSx, + getDialogSecondaryButtonSx, +} from '../App/dialogSurface'; const uid = new ShortUniqueId({ length: 8 }); const MAX_OPEN_APP_TABS = 10; +/** Bounded MRU stack (tab ids only) for "last visited" after closing the active tab */ +const MAX_TAB_MRU_DEPTH = 64; + +function pickNextTabFromMru( + remainingTabs: { tabId: string }[], + mruIds: string[] +): { tabId: string } | null { + if (!remainingTabs.length) return null; + const ids = new Set(remainingTabs.map((t) => t.tabId)); + for (let i = mruIds.length - 1; i >= 0; i--) { + const id = mruIds[i]; + if (ids.has(id)) { + return remainingTabs.find((t) => t.tabId === id) ?? null; + } + } + return null; +} + +/** Prefer the tab that was to the right of the closed tab; if closed tab was last, use the new last tab */ +function positionalTabAfterClose( + remainingTabs: { tabId: string }[], + removedTabId: string, + tabsBeforeRemove: { tabId: string }[] +): { tabId: string } | null { + if (!remainingTabs.length) return null; + const removedIndex = tabsBeforeRemove.findIndex( + (tab) => tab?.tabId === removedTabId + ); + if (removedIndex === -1) { + return remainingTabs[remainingTabs.length - 1] ?? remainingTabs[0] ?? null; + } + if (removedIndex < remainingTabs.length) { + return remainingTabs[removedIndex] ?? null; + } + return remainingTabs[removedIndex - 1] ?? null; +} +const SIDEBAR_CHROME_TRANSITION = '200ms cubic-bezier(0.2, 0, 0, 1)'; +/** Match `AppsHorizontalTabButton` ideal width for strip width math */ +const TAB_STRIP_IDEAL_TAB_PX = 180; +const TAB_STRIP_INNER_GAP_PX = 2; + +const restrictTabsToHorizontalAxis = ({ transform }) => ({ + ...transform, + y: 0, +}); + +const restrictTabsToStrip = ({ + activeNodeRect, + containerNodeRect, + transform, +}) => { + if (!activeNodeRect || !containerNodeRect) { + return { + ...transform, + y: 0, + }; + } + + const minX = containerNodeRect.left - activeNodeRect.left; + const maxX = containerNodeRect.right - activeNodeRect.right; + + return { + ...transform, + x: Math.min(Math.max(transform.x, minX), maxX), + y: 0, + }; +}; function normalizeQortalInput(value: string) { const trimmed = (value || '').trim(); @@ -64,7 +165,48 @@ function normalizeQortalInput(value: string) { return `${QORTAL_PROTOCOL}${trimmed}`; } -export const AppsDesktop = ({ mode, setMode, show }) => { +/** Local dev / preview iframe tab (not a published Q-App); has no `service` (Q-Chat uses INTERNAL). */ +function isLocalDevTab(tab: { service?: string } | null | undefined) { + return !!tab && !tab.service; +} + +const DEV_MODE_SIDEBAR_SAFE_INSET_PX = 88; + +type InternalTabVisibilityArgs = { + isVisible: boolean; + tab: any; +}; + +type RenderInternalTabArgs = { + hide: boolean; + isSelected: boolean; + tab: any; +}; + +type AppsDesktopProps = { + mode: string; + setMode: (mode: string) => void; + devMode: string; + setDevMode: (mode: string) => void; + desktopViewMode: string; + setDesktopViewMode: (mode: string) => void; + onInternalTabVisibilityChange?: (args: InternalTabVisibilityArgs) => void; + renderInternalTab?: (args: RenderInternalTabArgs) => ReactNode; + show: boolean; +}; + +export const AppsDesktop = ({ + mode, + setMode, + devMode, + setDevMode, + desktopViewMode, + setDesktopViewMode, + onInternalTabVisibilityChange, + renderInternalTab, + show, +}: AppsDesktopProps) => { + const theme = useTheme(); const navigationController = useAtomValue(navigationControllerAtom); const userInfo = useAtomValue(userInfoAtom); const publishEditTarget = useAtomValue(publishEditTargetAtom); @@ -75,10 +217,12 @@ export const AppsDesktop = ({ mode, setMode, show }) => { const [selectedAppInfo, setSelectedAppInfo] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null); const [tabs, setTabs] = useState([]); + const [viewerTabOrder, setViewerTabOrder] = useState([]); const [selectedTab, setSelectedTab] = useState(null); const [isNewTabWindow, setIsNewTabWindow] = useAtom(isNewTabWindowAtom); const [categories, setCategories] = useState([]); const iframeRefs = useRef({}); + const tabsTokenRef = useRef(0); const { refreshRatings } = useAppRatings(); const [showCloseTabDialog, setShowCloseTabDialog] = useState(false); const [pendingTabToRemove, setPendingTabToRemove] = useState(null); @@ -87,10 +231,42 @@ export const AppsDesktop = ({ mode, setMode, show }) => { message: string; type: 'warning' | 'error' | 'success' | 'info'; } | null>(null); + const [sidebarOffsetPx, setSidebarOffsetPx] = useState(0); + const [isAddTabFocused, setIsAddTabFocused] = useState(false); + const [isAddTabWaitingForPointerMove, setIsAddTabWaitingForPointerMove] = + useState(false); + const addTabPointerOriginRef = useRef<{ x: number; y: number } | null>(null); + const latestPointerPositionRef = useRef<{ x: number; y: number } | null>( + null + ); + const [pendingVisualTabActivationId, setPendingVisualTabActivationId] = + useState(null); + const [delayedVisualActiveTabId, setDelayedVisualActiveTabId] = useState< + string | null + >(null); + const [enteringTabIds, setEnteringTabIds] = useState([]); + const [tabStripCompresses, setTabStripCompresses] = useState(false); + const tabScrollerRef = useRef(null); + const tabAddButtonRef = useRef(null); const [librarySearchRequest, setLibrarySearchRequest] = useState<{ nonce: number; query: string; }>({ nonce: 0, query: '' }); + const tabsToNavTimeoutRef = useRef(null); + const tabActivationTimeoutRef = useRef(null); + const tabEntryCleanupTimeoutRef = useRef(null); + const recentTabIdsRef = useRef([]); + const tabPointerUnlockTimerRef = useRef | null>( + null + ); + const tabInteractionLockedRef = useRef(false); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); const { t } = useTranslation([ 'auth', 'core', @@ -122,17 +298,243 @@ export const AppsDesktop = ({ mode, setMode, show }) => { }, [myName, availableQapps]); useEffect(() => { - setTimeout(() => { + setViewerTabOrder((prev) => { + const nextIds = tabs + .map((tab) => tab?.tabId) + .filter((tabId): tabId is string => !!tabId); + const nextIdSet = new Set(nextIds); + const kept = prev.filter((tabId) => nextIdSet.has(tabId)); + const keptSet = new Set(kept); + const appended = nextIds.filter((tabId) => !keptSet.has(tabId)); + const next = [...kept, ...appended]; + + if ( + next.length === prev.length && + next.every((tabId, index) => tabId === prev[index]) + ) { + return prev; + } + + return next; + }); + }, [tabs]); + + const tabsById = useMemo(() => { + const byId = new Map(); + tabs.forEach((tab) => { + if (tab?.tabId) { + byId.set(tab.tabId, tab); + } + }); + return byId; + }, [tabs]); + + const viewerTabs = useMemo(() => { + return viewerTabOrder.map((tabId) => tabsById.get(tabId)).filter(Boolean); + }, [tabsById, viewerTabOrder]); + + useEffect(() => { + if (tabsToNavTimeoutRef.current !== null) { + window.clearTimeout(tabsToNavTimeoutRef.current); + } + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; + tabsToNavTimeoutRef.current = window.setTimeout(() => { executeEvent('setTabsToNav', { data: { tabs: tabs, selectedTab: selectedTab, isNewTabWindow: isNewTabWindow, + tabsToken, }, }); }, 100); + return () => { + if (tabsToNavTimeoutRef.current !== null) { + window.clearTimeout(tabsToNavTimeoutRef.current); + tabsToNavTimeoutRef.current = null; + } + }; }, [show, tabs, selectedTab, isNewTabWindow]); + useEffect(() => { + if ( + desktopViewMode !== 'dev' || + devMode !== 'home' || + isNewTabWindow + ) { + return; + } + + if (selectedTab) { + setSelectedTab(null); + } + + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; + executeEvent('setTabsToNav', { + data: { + tabs, + selectedTab: null, + isNewTabWindow: false, + tabsToken, + }, + }); + executeEvent('forceNavClear', { data: { tabsToken } }); + executeEvent('clearNavInput', {}); + }, [desktopViewMode, devMode, isNewTabWindow, selectedTab, tabs]); + + useEffect(() => { + const id = selectedTab?.tabId; + if (!id) return; + const prev = recentTabIdsRef.current; + const filtered = prev.filter((x) => x !== id); + filtered.push(id); + recentTabIdsRef.current = + filtered.length > MAX_TAB_MRU_DEPTH + ? filtered.slice(-MAX_TAB_MRU_DEPTH) + : filtered; + }, [selectedTab?.tabId]); + + useEffect(() => { + return () => { + if (tabActivationTimeoutRef.current !== null) { + window.clearTimeout(tabActivationTimeoutRef.current); + } + if (tabEntryCleanupTimeoutRef.current !== null) { + window.clearTimeout(tabEntryCleanupTimeoutRef.current); + } + if (tabPointerUnlockTimerRef.current !== null) { + clearTimeout(tabPointerUnlockTimerRef.current); + tabPointerUnlockTimerRef.current = null; + } + tabInteractionLockedRef.current = false; + }; + }, []); + + useEffect(() => { + onInternalTabVisibilityChange?.({ + isVisible: + !!show && + !isNewTabWindow && + selectedTab?.internal === QCHAT_INTERNAL_TAB_ID, + tab: selectedTab, + }); + }, [isNewTabWindow, onInternalTabVisibilityChange, selectedTab, show]); + + const updateTabStripCompression = useCallback(() => { + const scroller = tabScrollerRef.current; + if (!show || !scroller || tabs.length === 0) { + setTabStripCompresses(false); + return; + } + if (scroller.clientWidth < 32) { + return; + } + const addEl = tabAddButtonRef.current; + const addW = addEl?.offsetWidth ?? 36; + const cs = getComputedStyle(scroller); + const padL = parseFloat(cs.paddingLeft) || 0; + const padR = parseFloat(cs.paddingRight) || 0; + const rowGap = parseFloat(cs.gap) || TAB_STRIP_INNER_GAP_PX; + const available = scroller.clientWidth - padL - padR - addW - rowGap; + + const tabsNeed = + tabs.length * TAB_STRIP_IDEAL_TAB_PX + + Math.max(0, tabs.length - 1) * TAB_STRIP_INNER_GAP_PX; + + setTabStripCompresses(tabsNeed > available + 0.5); + }, [show, tabs.length]); + + useLayoutEffect(() => { + updateTabStripCompression(); + const id = window.requestAnimationFrame(() => updateTabStripCompression()); + return () => window.cancelAnimationFrame(id); + }, [updateTabStripCompression, sidebarOffsetPx]); + + useLayoutEffect(() => { + const scroller = tabScrollerRef.current; + const addBtn = tabAddButtonRef.current; + if (!scroller) return; + const ro = new ResizeObserver(() => updateTabStripCompression()); + ro.observe(scroller); + if (addBtn) { + ro.observe(addBtn); + } + return () => ro.disconnect(); + }, [updateTabStripCompression]); + + useEffect(() => { + const trackPointer = (event: PointerEvent) => { + latestPointerPositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + }; + + window.addEventListener('pointermove', trackPointer, { passive: true }); + + return () => { + window.removeEventListener('pointermove', trackPointer); + }; + }, []); + + useEffect(() => { + if (!isAddTabWaitingForPointerMove) return; + + const handlePointerMove = (event: PointerEvent) => { + const origin = addTabPointerOriginRef.current; + if (origin) { + const dx = Math.abs(event.clientX - origin.x); + const dy = Math.abs(event.clientY - origin.y); + if (dx < 6 && dy < 6) { + return; + } + } + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + }; + + window.addEventListener('pointermove', handlePointerMove, { + passive: true, + }); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + }; + }, [isAddTabWaitingForPointerMove]); + + const scheduleVisualTabActivation = useCallback( + (tabId: string) => { + if (tabActivationTimeoutRef.current !== null) { + window.clearTimeout(tabActivationTimeoutRef.current); + } + if (tabEntryCleanupTimeoutRef.current !== null) { + window.clearTimeout(tabEntryCleanupTimeoutRef.current); + } + + setPendingVisualTabActivationId(tabId); + setDelayedVisualActiveTabId(null); + setEnteringTabIds((prev) => + prev.includes(tabId) ? prev : [...prev, tabId] + ); + addTabPointerOriginRef.current = latestPointerPositionRef.current; + setIsAddTabWaitingForPointerMove(true); + + tabActivationTimeoutRef.current = window.setTimeout(() => { + setDelayedVisualActiveTabId(tabId); + setPendingVisualTabActivationId(null); + }, 85); + + tabEntryCleanupTimeoutRef.current = window.setTimeout(() => { + setEnteringTabIds((prev) => prev.filter((id) => id !== tabId)); + setDelayedVisualActiveTabId((prev) => (prev === tabId ? null : prev)); + }, 220); + }, + [setIsAddTabFocused] + ); + const getCategories = useCallback(async () => { try { const url = `${getBaseApiReact()}/arbitrary/categories`; @@ -246,7 +648,39 @@ export const AppsDesktop = ({ mode, setMode, show }) => { }; }, []); - const navigateBackFunc = (e) => { + const navigateBackFunc = useCallback(() => { + if (desktopViewMode === 'dev') { + if ( + [ + 'category', + 'appInfo-from-category', + 'appInfo', + 'library', + 'publish', + ].includes(devMode) + ) { + if (devMode === 'category') { + setDevMode('library'); + setSelectedCategory(null); + } else if (devMode === 'appInfo-from-category') { + setDevMode('category'); + } else if (devMode === 'appInfo') { + setDevMode('library'); + } else if (devMode === 'library') { + if (isNewTabWindow) { + setDevMode('viewer'); + } else { + setDevMode('home'); + } + } else if (devMode === 'publish') { + setDevMode('library'); + } + } else if (selectedTab?.tabId) { + executeEvent(`navigateBackApp-${selectedTab.tabId}`, {}); + } + return; + } + if ( [ 'category', @@ -258,7 +692,6 @@ export const AppsDesktop = ({ mode, setMode, show }) => { 'publish-website', ].includes(mode) ) { - // Handle the various modes as needed if (mode === 'category') { setMode('library'); setSelectedCategory(null); @@ -281,9 +714,18 @@ export const AppsDesktop = ({ mode, setMode, show }) => { setMode('library'); } } else if (selectedTab?.tabId) { - executeEvent(`navigateBackApp-${selectedTab?.tabId}`, {}); + executeEvent(`navigateBackApp-${selectedTab.tabId}`, {}); } - }; + }, [ + desktopViewMode, + devMode, + isNewTabWindow, + mode, + selectedTab?.tabId, + setDevMode, + setMode, + setPublishEditTarget, + ]); useEffect(() => { subscribeToEvent('navigateBack', navigateBackFunc); @@ -291,10 +733,38 @@ export const AppsDesktop = ({ mode, setMode, show }) => { return () => { unsubscribeFromEvent('navigateBack', navigateBackFunc); }; - }, [mode, selectedTab]); + }, [navigateBackFunc]); + + useEffect(() => { + subscribeToEvent('devModeNavigateBack', navigateBackFunc); + + return () => { + unsubscribeFromEvent('devModeNavigateBack', navigateBackFunc); + }; + }, [navigateBackFunc]); const addTabFunc = (e) => { const data = e.detail?.data; + if (data?.internal) { + const existingInternalTab = tabs.find( + (tab) => tab?.internal === data.internal + ); + if (existingInternalTab) { + setPendingVisualTabActivationId(null); + setDelayedVisualActiveTabId(null); + setEnteringTabIds((prev) => + prev.filter((id) => id !== existingInternalTab.tabId) + ); + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + setSelectedTab(existingInternalTab); + setMode('viewer'); + setDesktopViewMode('apps'); + setIsNewTabWindow(false); + return; + } + } if (tabs.length >= MAX_OPEN_APP_TABS) { setInfoSnack({ message: 'Maximum number of tabs reached. Close one to open another.', @@ -303,14 +773,71 @@ export const AppsDesktop = ({ mode, setMode, show }) => { setOpenSnack(true); return; } + + if (data?.navigateIfAlreadyOpen) { + const { navigateIfAlreadyOpen, path, ...tabIdentity } = data; + const existingTab = tabs.find( + (tab) => + tab.service === tabIdentity.service && + tab.name?.toLowerCase() === tabIdentity.name?.toLowerCase() && + (tabIdentity.identifier == null || + tab.identifier === tabIdentity.identifier) + ); + + if (existingTab) { + flushSync(() => { + setDesktopViewMode('apps'); + setSelectedTab(existingTab); + setMode('viewer'); + setIsNewTabWindow(false); + }); + + setTimeout(() => { + executeEvent(`navigateToPath-${existingTab.tabId}`, { + path: path || '', + }); + }, 200); + + return; + } + } const newTab = { ...data, tabId: uid.rnd(), }; - setTabs((prev) => [...prev, newTab]); + const shouldUseSmoothHandoff = isAddTabFocused; + if (!shouldUseSmoothHandoff) { + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + } + setTabs((prev) => { + const afterTabId = data?.afterTabId; + if (!afterTabId) { + return [...prev, newTab]; + } + + const sourceIndex = prev.findIndex((tab) => tab?.tabId === afterTabId); + if (sourceIndex === -1) { + return [...prev, newTab]; + } + + const nextTabs = [...prev]; + nextTabs.splice(sourceIndex + 1, 0, newTab); + return nextTabs; + }); setSelectedTab(newTab); setMode('viewer'); + setDesktopViewMode('apps'); setIsNewTabWindow(false); + if (shouldUseSmoothHandoff) { + scheduleVisualTabActivation(newTab.tabId); + } else { + setPendingVisualTabActivationId(null); + setDelayedVisualActiveTabId(null); + setEnteringTabIds((prev) => prev.filter((id) => id !== newTab.tabId)); + setIsAddTabFocused(false); + } }; useEffect(() => { @@ -321,17 +848,89 @@ export const AppsDesktop = ({ mode, setMode, show }) => { }; }, [tabs]); - const setSelectedTabFunc = (e) => { + const addDevTabFunc = (e) => { const data = e.detail?.data; - if (e.detail?.isDevMode) return; + if (tabs.length >= MAX_OPEN_APP_TABS) { + setInfoSnack({ + message: 'Maximum number of tabs reached. Close one to open another.', + type: 'warning', + }); + setOpenSnack(true); + return; + } + const newTab = { + ...data, + tabId: uid.rnd(), + }; + setTabs((prev) => [...prev, newTab]); + setSelectedTab(newTab); + setDevMode('viewer'); + setDesktopViewMode('dev'); + setIsNewTabWindow(false); + }; + + useEffect(() => { + subscribeToEvent('appsDevModeAddTab', addDevTabFunc); + + return () => { + unsubscribeFromEvent('appsDevModeAddTab', addDevTabFunc); + }; + }, [tabs]); + const updateDevTabFunc = (e) => { + const data = e.detail?.data; + if (!data.tabId) return; + const findIndexTab = tabs.findIndex((tab) => tab?.tabId === data?.tabId); + if (findIndexTab === -1) return; + const copyTabs = [...tabs]; + const newTab = { + ...copyTabs[findIndexTab], + url: data.url, + }; + copyTabs[findIndexTab] = newTab; + + setTabs(copyTabs); + setSelectedTab(newTab); + setDevMode('viewer'); + setDesktopViewMode('dev'); + setIsNewTabWindow(false); + }; + + useEffect(() => { + subscribeToEvent('appsDevModeUpdateTab', updateDevTabFunc); + + return () => { + unsubscribeFromEvent('appsDevModeUpdateTab', updateDevTabFunc); + }; + }, [tabs]); + + const setSelectedTabFunc = (e) => { + const data = e.detail?.data; + const isDev = e.detail?.isDevMode; + + setPendingVisualTabActivationId(null); + setDelayedVisualActiveTabId(null); + setEnteringTabIds((prev) => prev.filter((id) => id !== data?.tabId)); + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; setSelectedTab(data); + if (isDev) { + setDesktopViewMode('dev'); + setDevMode('viewer'); + } else { + setDesktopViewMode('apps'); + setMode('viewer'); + } + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; setTimeout(() => { executeEvent('setTabsToNav', { data: { tabs: tabs, selectedTab: data, isNewTabWindow: isNewTabWindow, + tabsToken, }, }); }, 100); @@ -392,19 +991,76 @@ export const AppsDesktop = ({ mode, setMode, show }) => { // Clear session permissions for this tab clearSessionPermissionsByTabId(tabId); + recentTabIdsRef.current = recentTabIdsRef.current.filter( + (id) => id !== tabId + ); + + const wasClosingActive = selectedTab?.tabId === tabId; const copyTabs = [...tabs].filter((tab) => tab?.tabId !== tabId); + const remainingIds = new Set(copyTabs.map((t) => t.tabId)); + + setEnteringTabIds((prev) => prev.filter((id) => id !== tabId)); + setPendingVisualTabActivationId((prev) => (prev === tabId ? null : prev)); + setDelayedVisualActiveTabId((prev) => (prev === tabId ? null : prev)); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; if (copyTabs?.length === 0) { - setMode('home'); + recentTabIdsRef.current = []; + setTabs(copyTabs); + setSelectedTab(null); + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; + executeEvent('setTabsToNav', { + data: { + tabs: copyTabs, + selectedTab: null, + tabsToken, + }, + }); + executeEvent('forceNavClear', { data: { tabsToken } }); + executeEvent('clearNavInput', {}); + returnFromAppsMode(); + window.setTimeout(() => { + setMode('home'); + setDevMode('home'); + }, 0); + return; + } + + let nextTab = null; + if ( + !wasClosingActive && + selectedTab?.tabId && + remainingIds.has(selectedTab.tabId) + ) { + nextTab = selectedTab; + } else if (wasClosingActive) { + nextTab = + pickNextTabFromMru(copyTabs, recentTabIdsRef.current) || + positionalTabAfterClose(copyTabs, tabId, tabs); } else { - setSelectedTab(copyTabs[0]); + nextTab = copyTabs[0] ?? null; } + setTabs(copyTabs); - setSelectedTab(copyTabs[0]); + setSelectedTab(nextTab); + if (wasClosingActive && nextTab) { + if (isLocalDevTab(nextTab)) { + setDesktopViewMode('dev'); + setDevMode('viewer'); + } else { + setDesktopViewMode('apps'); + setMode('viewer'); + } + } + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; setTimeout(() => { executeEvent('setTabsToNav', { data: { tabs: copyTabs, - selectedTab: copyTabs[0], + selectedTab: nextTab, + tabsToken, }, }); }, 400); @@ -446,9 +1102,19 @@ export const AppsDesktop = ({ mode, setMode, show }) => { }; }, [tabs]); + useEffect(() => { + subscribeToEvent('removeTabDevMode', removeTabFunc); + + return () => { + unsubscribeFromEvent('removeTabDevMode', removeTabFunc); + }; + }, [tabs]); + const setNewTabWindowFunc = (e) => { setIsNewTabWindow(true); + setIsAddTabFocused(true); setSelectedTab(null); + setDesktopViewMode('apps'); }; useEffect(() => { @@ -459,45 +1125,104 @@ export const AppsDesktop = ({ mode, setMode, show }) => { }; }, [tabs]); - const openAppsLibrarySearchFunc = useCallback((e) => { - const query = e.detail?.data?.query || ''; + const devModeNewTabWindowFunc = () => { + setIsNewTabWindow(true); setSelectedTab(null); - setIsNewTabWindow(false); - setLibrarySearchRequest({ - nonce: Date.now(), - query, - }); - setMode('library'); + setDevMode('viewer'); + setDesktopViewMode('dev'); + }; + + useEffect(() => { + subscribeToEvent('devModeNewTabWindow', devModeNewTabWindowFunc); + + return () => { + unsubscribeFromEvent('devModeNewTabWindow', devModeNewTabWindowFunc); + }; }, []); + const openAppsLibrarySearchFunc = useCallback( + (e) => { + const query = e.detail?.data?.query || ''; + setDesktopViewMode('apps'); + setIsAddTabFocused(true); + setSelectedTab(null); + setIsNewTabWindow(false); + setLibrarySearchRequest({ + nonce: Date.now(), + query, + }); + setMode('library'); + }, + [setDesktopViewMode, setMode] + ); + useEffect(() => { subscribeToEvent('openAppsLibrarySearch', openAppsLibrarySearchFunc); + return () => { + unsubscribeFromEvent('openAppsLibrarySearch', openAppsLibrarySearchFunc); + }; + }, [openAppsLibrarySearchFunc]); + + useEffect(() => { + const handleSidebarOverlayVisibility = (e: CustomEvent) => { + const isVisible = !!e.detail?.data?.isVisible; + const width = Number(e.detail?.data?.width || 0); + setSidebarOffsetPx(isVisible ? width : 0); + }; + + subscribeToEvent( + 'sidebarOverlayVisibility', + handleSidebarOverlayVisibility + ); + return () => { unsubscribeFromEvent( - 'openAppsLibrarySearch', - openAppsLibrarySearchFunc + 'sidebarOverlayVisibility', + handleSidebarOverlayVisibility ); }; - }, [openAppsLibrarySearchFunc]); + }, []); const appsContentHeight = `calc(100vh - ${appChromeOffsetPx} - ${APPS_HORIZONTAL_TAB_HEIGHT_PX}px)`; const openDashboardFromTabs = useCallback(() => { + setIsAddTabFocused(true); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + setPendingVisualTabActivationId(null); + setDelayedVisualActiveTabId(null); setSelectedTab(null); setLibrarySearchRequest({ nonce: Date.now(), query: '', }); - setMode('viewer'); - setIsNewTabWindow(true); executeEvent('open-apps-mode', {}); + setMode('home'); + setIsNewTabWindow(false); }, [setIsNewTabWindow, setMode]); + const returnFromAppsMode = useCallback(() => { + executeEvent('return-from-apps-mode', {}); + }, []); + const duplicateTab = useCallback( (tab) => { if (!tab) return; + if (isLocalDevTab(tab)) { + executeEvent('appsDevModeAddTab', { + data: { + afterTabId: tab?.tabId, + url: tab.url, + customIcon: tab.customIcon, + name: tab.name, + }, + }); + executeEvent('open-dev-mode', {}); + return; + } + const currentLink = tab?.tabId ? navigationController?.[tab.tabId]?.currentLink || '' : ''; @@ -507,6 +1232,7 @@ export const AppsDesktop = ({ mode, setMode, show }) => { executeEvent('addTab', { data: { + afterTabId: tab?.tabId, ...tab, identifier: parsedLink?.identifier ?? tab?.identifier, name: parsedLink?.name ?? tab?.name, @@ -519,12 +1245,191 @@ export const AppsDesktop = ({ mode, setMode, show }) => { [navigationController] ); + const closeAllTabs = useCallback(() => { + tabs.forEach((tab) => { + if (tab?.tabId) { + clearSessionPermissionsByTabId(tab.tabId); + } + }); + recentTabIdsRef.current = []; + setTabs([]); + setSelectedTab(null); + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + setIsNewTabWindow(false); + executeEvent('clearNavInput', {}); + tabsTokenRef.current += 1; + executeEvent('setTabsToNav', { + data: { + tabs: [], + selectedTab: null, + isNewTabWindow: false, + tabsToken: tabsTokenRef.current, + }, + }); + executeEvent('forceNavClear', { + data: { tabsToken: tabsTokenRef.current }, + }); + setMode('home'); + setDevMode('home'); + returnFromAppsMode(); + window.setTimeout(() => { + setMode('home'); + setDevMode('home'); + }, 0); + }, [tabs, returnFromAppsMode, setIsNewTabWindow, setMode, setDevMode]); + + useEffect(() => { + if (!show) { + setIsAddTabFocused(false); + setIsAddTabWaitingForPointerMove(false); + addTabPointerOriginRef.current = null; + setPendingVisualTabActivationId(null); + setDelayedVisualActiveTabId(null); + } + }, [show]); + + useEffect(() => { + if (tabs.length === 0 && selectedTab) { + setSelectedTab(null); + } + + if ( + tabs.length === 0 && + !isNewTabWindow && + (mode === 'viewer' || devMode === 'viewer') + ) { + returnFromAppsMode(); + window.setTimeout(() => { + setMode('home'); + setDevMode('home'); + }, 0); + } + }, [ + tabs.length, + selectedTab, + isNewTabWindow, + mode, + devMode, + returnFromAppsMode, + setMode, + setDevMode, + ]); + + useEffect(() => { + const inViewer = + desktopViewMode === 'dev' ? devMode === 'viewer' : mode === 'viewer'; + if (!inViewer || isNewTabWindow || tabs.length === 0) { + return; + } + + const selectedTabId = selectedTab?.tabId; + const selectedTabStillExists = + !!selectedTabId && tabs.some((tab) => tab?.tabId === selectedTabId); + + if (selectedTabStillExists) { + return; + } + + const fallbackTab = + pickNextTabFromMru(tabs, recentTabIdsRef.current) || + tabs[tabs.length - 1] || + tabs[0]; + if (!fallbackTab) { + return; + } + + setSelectedTab(fallbackTab); + if (isLocalDevTab(fallbackTab)) { + setDesktopViewMode('dev'); + setDevMode('viewer'); + } else { + setDesktopViewMode('apps'); + setMode('viewer'); + } + tabsTokenRef.current += 1; + const tabsToken = tabsTokenRef.current; + window.setTimeout(() => { + executeEvent('setTabsToNav', { + data: { + tabs, + selectedTab: fallbackTab, + isNewTabWindow: false, + tabsToken, + }, + }); + }, 0); + }, [ + isNewTabWindow, + mode, + devMode, + desktopViewMode, + selectedTab, + tabs, + setDesktopViewMode, + setDevMode, + setMode, + ]); + + const scheduleTabPointerUnlock = useCallback(() => { + if (tabPointerUnlockTimerRef.current !== null) { + clearTimeout(tabPointerUnlockTimerRef.current); + } + tabPointerUnlockTimerRef.current = setTimeout(() => { + tabInteractionLockedRef.current = false; + tabPointerUnlockTimerRef.current = null; + }, 200); + }, []); + + const handleTabDragStart = useCallback(() => { + if (tabPointerUnlockTimerRef.current !== null) { + clearTimeout(tabPointerUnlockTimerRef.current); + tabPointerUnlockTimerRef.current = null; + } + tabInteractionLockedRef.current = true; + }, []); + + const handleTabDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setTabs((prev) => { + const oldIndex = prev.findIndex((tab) => tab?.tabId === active.id); + const newIndex = prev.findIndex((tab) => tab?.tabId === over.id); + + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + } + scheduleTabPointerUnlock(); + }, + [scheduleTabPointerUnlock] + ); + + const handleTabDragCancel = useCallback(() => { + scheduleTabPointerUnlock(); + }, [scheduleTabPointerUnlock]); + + const hasOpenTabs = tabs.length > 0; + const hideAppsShellOffScreen = !show && hasOpenTabs; + return ( @@ -536,38 +1441,126 @@ export const AppsDesktop = ({ mode, setMode, show }) => { width: '100%', }} > - - - {tabs.map((tab) => ( - duplicateTab(tab)} - onClose={() => { - executeEvent('removeTab', { - data: tab, - }); - }} - onSelect={() => { - executeEvent('open-apps-mode', {}); - executeEvent('setSelectedTab', { - data: tab, - }); - }} - /> - ))} + + + + tab?.tabId)} + strategy={horizontalListSortingStrategy} + > + + {tabs.map((tab) => ( + duplicateTab(tab)} + onClose={() => { + executeEvent('removeTab', { + data: tab, + }); + }} + onSelect={() => { + if (isLocalDevTab(tab)) { + executeEvent('open-dev-mode', {}); + executeEvent('setSelectedTab', { + data: tab, + isDevMode: true, + }); + } else { + executeEvent('open-apps-mode', {}); + executeEvent('setSelectedTab', { + data: tab, + }); + } + }} + /> + ))} + + + ({ + backgroundColor: isAddTabFocused + ? alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.78 : 0.88 + ) + : undefined, + borderColor: isAddTabFocused ? 'transparent' : undefined, + color: isAddTabFocused + ? theme.palette.mode === 'dark' + ? theme.palette.common.white + : theme.palette.primary.contrastText + : undefined, '&:hover': { - backgroundColor: - theme.palette.mode === 'dark' + backgroundColor: isAddTabFocused + ? alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.86 : 0.94 + ) + : theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)', - color: theme.palette.text.primary, + color: isAddTabFocused + ? theme.palette.mode === 'dark' + ? theme.palette.common.white + : theme.palette.primary.contrastText + : theme.palette.text.primary, }, })} > @@ -584,7 +1577,7 @@ export const AppsDesktop = ({ mode, setMode, show }) => { width: '100%', }} > - {mode === 'home' && ( + {desktopViewMode === 'apps' && mode === 'home' && ( { )} + {desktopViewMode === 'dev' && + devMode === 'home' && + !isNewTabWindow && ( + + + + + + )} + { refreshRatings(); }} hasPublishApp={!!(myApp || myWebsite)} - isShow={mode === 'library' && !selectedTab} + isShow={ + desktopViewMode === 'apps' && mode === 'library' && !selectedTab + } myName={myName} myAddress={myAddress} setMode={setMode} /> - {mode === 'appInfo' && !selectedTab && ( + {desktopViewMode === 'apps' && mode === 'appInfo' && !selectedTab && ( { )} - {mode === 'appInfo-from-category' && !selectedTab && ( - - - - )} + {desktopViewMode === 'apps' && + mode === 'appInfo-from-category' && + !selectedTab && ( + + + + )} - {(mode === 'publish' || - mode === 'publish-app' || - mode === 'publish-website') && + {desktopViewMode === 'apps' && + (mode === 'publish' || + mode === 'publish-app' || + mode === 'publish-website') && !selectedTab && ( { )} - {tabs.map((tab) => { + {viewerTabs.map((tab) => { + const hideContentForDevHome = + desktopViewMode === 'dev' && + devMode === 'home' && + !isNewTabWindow; + const internalTabContent = renderInternalTab?.({ + hide: isNewTabWindow, + isSelected: tab?.tabId === selectedTab?.tabId, + tab, + }); + if (internalTabContent) { + const isInternalTabActive = + tab?.tabId === selectedTab?.tabId && + !isNewTabWindow && + !hideContentForDevHome; + return ( + + {internalTabContent} + + ); + } if (!iframeRefs.current[tab.tabId]) { iframeRefs.current[tab.tabId] = createRef(); } @@ -692,8 +1755,8 @@ export const AppsDesktop = ({ mode, setMode, show }) => { { ); })} - {isNewTabWindow && mode === 'viewer' && ( - - + {isNewTabWindow && + desktopViewMode === 'apps' && + mode === 'viewer' && ( + + - - - )} + + + )} + + {isNewTabWindow && + desktopViewMode === 'dev' && + devMode === 'viewer' && ( + + + + + + )}
@@ -731,14 +1820,20 @@ export const AppsDesktop = ({ mode, setMode, show }) => { onClose={handleCloseTabDialogCancel} aria-labelledby="close-tab-dialog-title" aria-describedby="close-tab-dialog-description" + PaperProps={{ + sx: getDialogPaperSx(theme, { maxWidth: 430 }), + }} > - + {t('question:permission.close_tab_confirmation', { postProcess: 'capitalizeFirstChar', })} - - + + {t('question:permission.close_tab_permission', { postProcess: 'capitalizeFirstChar', })} @@ -746,26 +1841,31 @@ export const AppsDesktop = ({ mode, setMode, show }) => { {pendingTabToRemove?.lockMessage && ( {pendingTabToRemove.lockMessage} )} - - + + + + + { + setEditingNodeUrl(null); + setShowManual(false); + }} + maxWidth="sm" + fullWidth + slotProps={{ + paper: { + sx: connectionModeManualPaperSx(theme), + }, + }} + > + + { + setEditingNodeUrl(null); + setShowManual(false); + }} + sx={{ color: theme.palette.text.secondary }} + > + + + + {editingNodeUrl + ? t('auth:connection_mode.manual_edit_title') + : t('auth:connection_mode.manual_add_title')} + + + + + + + + {t('auth:connection_mode.field_name')} + + setManualNodeName(event.target.value)} + placeholder={t('auth:connection_mode.placeholder_node_name')} + /> + + + + {t('auth:connection_mode.field_node_url')} + + setManualNodeUrl(event.target.value)} + placeholder={t('auth:connection_mode.placeholder_node_url')} + /> + + + + {t('auth:connection_mode.field_api_key_optional')} + + setManualApiKey(event.target.value)} + placeholder={t('auth:connection_mode.placeholder_api_key')} + /> + + + + { + setEditingNodeUrl(null); + setShowManual(false); + }} + sx={{ + color: theme.palette.text.secondary, + fontSize: '0.86rem', + textDecoration: 'none', + }} + > + {t('core:action.return', { postProcess: 'capitalizeFirstChar' })} + + + {t('core:action.save', { postProcess: 'capitalizeFirstChar' })} + + + + + + ); +} + +function connectionModeMainPaperSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + background: '#0d1117', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '10px', + boxShadow: '0 24px 50px rgba(0,0,0,0.32)', + maxHeight: 'calc(100vh - 48px)', + maxWidth: '712px', + }; + } + return { + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: '10px', + boxShadow: + '0 22px 50px rgba(15, 23, 42, 0.075), 0 0 0 1px rgba(28, 36, 52, 0.045)', + maxHeight: 'calc(100vh - 48px)', + maxWidth: '712px', + color: theme.palette.text.primary, + }; +} + +function connectionModeManualPaperSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + background: '#0d1117', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '10px', + boxShadow: '0 24px 50px rgba(0,0,0,0.32)', + maxWidth: '460px', + }; + } + return { + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: '10px', + boxShadow: + '0 22px 50px rgba(15, 23, 42, 0.075), 0 0 0 1px rgba(28, 36, 52, 0.045)', + maxWidth: '460px', + color: theme.palette.text.primary, + }; +} + +function modeRowSx(theme: Theme, active: boolean) { + if (theme.palette.mode === 'dark') { + return { + alignItems: 'center', + background: active + ? 'linear-gradient(180deg, rgba(13,22,37,0.82), rgba(12,19,29,0.82))' + : 'rgba(255,255,255,0.012)', + border: active + ? '1px solid rgba(69, 132, 255, 0.95)' + : '1px solid rgba(255,255,255,0.075)', + borderRadius: '8px', + boxShadow: active ? '0 0 0 1px rgba(69,132,255,0.08)' : 'none', + display: 'flex', + gap: { xs: 2, sm: 2.5 }, + justifyContent: 'space-between', + minHeight: { xs: 116, sm: 118 }, + px: { xs: 1.75, sm: 2.5 }, + py: { xs: 2, sm: 2.15 }, + textAlign: 'left', + transition: + 'background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease', + width: '100%', + '&:hover': { + backgroundColor: active ? undefined : 'rgba(255,255,255,0.025)', + borderColor: active + ? 'rgba(69,132,255,0.95)' + : 'rgba(255,255,255,0.12)', + }, + }; + } + + const pm = theme.palette.primary.main; + return { + alignItems: 'center', + background: active + ? `linear-gradient(180deg, ${alpha(pm, 0.14)}, ${alpha(pm, 0.06)})` + : theme.palette.background.surface, + border: active + ? `1px solid ${alpha(pm, 0.72)}` + : `1px solid ${theme.palette.border.subtle}`, + borderRadius: '8px', + boxShadow: active ? `0 0 0 1px ${alpha(pm, 0.12)}` : 'none', + display: 'flex', + gap: { xs: 2, sm: 2.5 }, + justifyContent: 'space-between', + minHeight: { xs: 116, sm: 118 }, + px: { xs: 1.75, sm: 2.5 }, + py: { xs: 2, sm: 2.15 }, + textAlign: 'left', + transition: + 'background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease', + width: '100%', + '&:hover': { + backgroundColor: active ? undefined : theme.palette.action.hover, + borderColor: active ? alpha(pm, 0.85) : theme.palette.border.main, + }, + }; +} + +function modeRadioSx(theme: Theme, active: boolean) { + if (theme.palette.mode === 'dark') { + return { + alignItems: 'center', + border: `2px solid ${active ? '#3E82FF' : 'rgba(214,221,233,0.22)'}`, + borderRadius: '999px', + display: 'inline-flex', + flexShrink: 0, + height: 24, + justifyContent: 'center', + width: 24, + }; + } + + const pm = theme.palette.primary.main; + return { + alignItems: 'center', + border: `2px solid ${ + active ? pm : alpha(theme.palette.text.primary, 0.22) + }`, + borderRadius: '999px', + display: 'inline-flex', + flexShrink: 0, + height: 24, + justifyContent: 'center', + width: 24, + }; +} + +function modeRadioDotSx(theme: Theme, active: boolean) { + if (theme.palette.mode === 'dark') { + return { + backgroundColor: active ? '#3E82FF' : 'transparent', + borderRadius: '999px', + display: 'block', + height: 12, + width: 12, + }; + } + + return { + backgroundColor: active ? theme.palette.primary.main : 'transparent', + borderRadius: '999px', + display: 'block', + height: 12, + width: 12, + }; +} + +function modeTitleSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + color: 'rgba(246,248,252,0.96)', + fontSize: '0.98rem', + fontWeight: 800, + lineHeight: 1.25, + }; + } + return { + color: theme.palette.text.primary, + fontSize: '0.98rem', + fontWeight: 800, + lineHeight: 1.25, + }; +} + +function recommendedPillSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + backgroundColor: 'rgba(62,130,255,0.18)', + borderRadius: '999px', + color: '#5390FF', + fontSize: '0.72rem', + fontWeight: 700, + lineHeight: 1, + px: 1, + py: 0.52, + }; + } + + const pm = theme.palette.primary.main; + return { + backgroundColor: alpha(pm, 0.14), + borderRadius: '999px', + color: theme.palette.primary.dark, + fontSize: '0.72rem', + fontWeight: 700, + lineHeight: 1, + px: 1, + py: 0.52, + }; +} + +function modeCopySx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + color: 'rgba(214,221,233,0.72)', + fontSize: '0.82rem', + lineHeight: 1.62, + }; + } + return { + color: theme.palette.text.secondary, + fontSize: '0.82rem', + lineHeight: 1.62, + }; +} + +const modeTrailingSx = { + alignItems: 'center', + display: { xs: 'none', sm: 'inline-flex' }, + flexShrink: 0, + gap: 1, + justifyContent: 'flex-end', + ml: 1, +}; + +const statusDotSx = (color: string) => ({ + backgroundColor: color, + borderRadius: '999px', + height: 8, + width: 8, +}); + +const statusTextSx = (color: string) => ({ + color, + fontSize: '0.82rem', + fontWeight: 800, + whiteSpace: 'nowrap', +}); + +function sectionDividerSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { borderTop: '1px solid rgba(255,255,255,0.055)' }; + } + return { borderTop: `1px solid ${theme.palette.divider}` }; +} + +function sectionTitleSx(theme: Theme) { + return modeTitleSx(theme); +} + +function addCustomNodeSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + alignItems: 'center', + alignSelf: 'center', + color: '#5390FF', + display: 'inline-flex', + fontSize: '0.86rem', + fontWeight: 600, + gap: 0.45, + lineHeight: 1, + minHeight: 26, + p: 0, + '&:hover': { + color: '#7FAAFF', + }, + }; + } + + return { + alignItems: 'center', + alignSelf: 'center', + color: theme.palette.primary.main, + display: 'inline-flex', + fontSize: '0.86rem', + fontWeight: 600, + gap: 0.45, + lineHeight: 1, + minHeight: 26, + p: 0, + '&:hover': { + color: theme.palette.primary.dark, + }, + }; +} + +function customNodeRowSx(theme: Theme, active: boolean, isDragOver = false) { + if (theme.palette.mode === 'dark') { + return { + alignItems: 'center', + backgroundColor: isDragOver + ? 'rgba(118,165,255,0.08)' + : active + ? 'rgba(255,255,255,0.032)' + : 'rgba(255,255,255,0.012)', + border: `1px solid ${ + isDragOver + ? 'rgba(118,165,255,0.4)' + : active + ? 'rgba(92,145,255,0.3)' + : 'rgba(255,255,255,0.075)' + }`, + borderRadius: '8px', + cursor: 'grab', + display: 'flex', + gap: 1.5, + justifyContent: 'space-between', + minHeight: 84, + outline: 'none', + opacity: isDragOver ? 0.78 : 1, + px: 2, + py: 2, + transition: + 'background-color 160ms ease, border-color 160ms ease, opacity 140ms ease', + '&:active': { + cursor: 'grabbing', + }, + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.032)', + borderColor: active + ? 'rgba(92,145,255,0.34)' + : 'rgba(255,255,255,0.12)', + }, + '&:focus-visible': { + borderColor: 'rgba(118,165,255,0.42)', + }, + }; + } + + const pm = theme.palette.primary.main; + return { + alignItems: 'center', + backgroundColor: isDragOver + ? alpha(pm, 0.12) + : active + ? alpha(pm, 0.06) + : theme.palette.background.surface, + border: `1px solid ${ + isDragOver + ? alpha(pm, 0.38) + : active + ? alpha(pm, 0.32) + : theme.palette.border.subtle + }`, + borderRadius: '8px', + cursor: 'grab', + display: 'flex', + gap: 1.5, + justifyContent: 'space-between', + minHeight: 84, + outline: 'none', + opacity: isDragOver ? 0.78 : 1, + px: 2, + py: 2, + transition: + 'background-color 160ms ease, border-color 160ms ease, opacity 140ms ease', + '&:active': { + cursor: 'grabbing', + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + borderColor: active ? alpha(pm, 0.42) : theme.palette.border.main, + }, + '&:focus-visible': { + borderColor: alpha(pm, 0.45), + }, + }; +} + +function modalFooterSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + alignItems: 'center', + borderTop: '1px solid rgba(255,255,255,0.055)', + display: 'flex', + justifyContent: 'space-between', + mt: 2.75, + pt: 3, + }; + } + return { + alignItems: 'center', + borderTop: `1px solid ${theme.palette.divider}`, + display: 'flex', + justifyContent: 'space-between', + mt: 2.75, + pt: 3, + }; +} + +function manualNodeLinkSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + color: '#5390FF', + fontSize: '0.86rem', + fontWeight: 500, + textDecoration: 'none', + '&:hover': { + color: '#7FAAFF', + }, + }; + } + + return { + color: theme.palette.primary.main, + fontSize: '0.86rem', + fontWeight: 500, + textDecoration: 'none', + '&:hover': { + color: theme.palette.primary.dark, + }, + }; +} + +function saveSettingsButtonSx(_theme: Theme) { + return { + alignItems: 'center', + background: + 'linear-gradient(180deg, rgba(62,107,214,0.98), rgba(39,83,184,0.98))', + border: '1px solid rgba(92,145,255,0.24)', + borderRadius: '6px', + color: '#f6f8fc', + display: 'inline-flex', + fontSize: '0.86rem', + fontWeight: 600, + letterSpacing: 0, + lineHeight: 1.75, + minHeight: 40, + minWidth: 174, + px: 2.4, + textTransform: 'none', + transition: + 'background 160ms ease, border-color 160ms ease, transform 160ms ease', + '& .MuiButton-startIcon': { + mr: 0.8, + }, + '&:hover': { + background: + 'linear-gradient(180deg, rgba(69,115,224,1), rgba(44,90,193,1))', + borderColor: 'rgba(118,165,255,0.3)', + transform: 'translateY(-1px)', + }, + }; +} + +function fieldLabelSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + color: 'rgba(214,221,233,0.62)', + fontSize: '0.74rem', + fontWeight: 700, + letterSpacing: '0.08em', + mb: 0.75, + textTransform: 'uppercase', + }; + } + return { + color: theme.palette.text.secondary, + fontSize: '0.74rem', + fontWeight: 700, + letterSpacing: '0.08em', + mb: 0.75, + textTransform: 'uppercase', + }; +} diff --git a/src/components/Auth/DownloadWallet.tsx b/src/components/Auth/DownloadWallet.tsx index 909ad37e..0541ff8c 100644 --- a/src/components/Auth/DownloadWallet.tsx +++ b/src/components/Auth/DownloadWallet.tsx @@ -17,11 +17,12 @@ import { useState } from 'react'; import { decryptStoredWallet } from '../../utils/decryptWallet'; import PhraseWallet from '../../utils/generateWallet/phrase-wallet'; import { crypto, walletVersion } from '../../constants/decryptWallet'; +import { executeEvent } from '../../utils/events'; +import { getWalletErrorMessage } from '../../utils/walletErrorMessages'; export const DownloadWallet = ({ returnToMain, setIsLoading, - showInfo, rawWallet, setWalletToBeDownloaded, walletToBeDownloaded, @@ -29,6 +30,9 @@ export const DownloadWallet = ({ const [walletToBeDownloadedPassword, setWalletToBeDownloadedPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); + const [isCurrentPasswordEditable, setIsCurrentPasswordEditable] = + useState(false); + const [isNewPasswordEditable, setIsNewPasswordEditable] = useState(false); const [keepCurrentPassword, setKeepCurrentPassword] = useState(true); const theme = useTheme(); const [walletToBeDownloadedError, setWalletToBeDownloadedError] = @@ -43,7 +47,14 @@ export const DownloadWallet = ({ walletToBeDownloaded.qortAddress ); } catch (error: any) { - setWalletToBeDownloadedError(error?.message); + setWalletToBeDownloadedError( + getWalletErrorMessage( + error, + t('auth:wallet_errors.unable_to_save_backup', { + postProcess: 'capitalizeFirstChar', + }) + ) + ); } }; @@ -101,7 +112,14 @@ export const DownloadWallet = ({ newPasswordForWallet ); } catch (error: any) { - setWalletToBeDownloadedError(error?.message); + setWalletToBeDownloadedError( + getWalletErrorMessage( + error, + t('auth:wallet_errors.unable_to_prepare_backup', { + postProcess: 'capitalizeFirstChar', + }) + ) + ); } finally { setIsLoading(false); } @@ -181,6 +199,24 @@ export const DownloadWallet = ({ id="standard-adornment-password" value={walletToBeDownloadedPassword} onChange={(e) => setWalletToBeDownloadedPassword(e.target.value)} + autoComplete="new-password" + name="download-wallet-current-confirmation" + onFocus={() => setIsCurrentPasswordEditable(true)} + onMouseDown={() => setIsCurrentPasswordEditable(true)} + onBlur={() => { + if (!walletToBeDownloadedPassword) { + setIsCurrentPasswordEditable(false); + } + }} + InputProps={{ + readOnly: !isCurrentPasswordEditable, + }} + inputProps={{ + autoComplete: 'new-password', + 'data-1p-ignore': 'true', + 'data-lpignore': 'true', + spellCheck: 'false', + }} /> @@ -233,6 +269,24 @@ export const DownloadWallet = ({ id="standard-adornment-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} + autoComplete="new-password" + name="download-wallet-new-passphrase" + onFocus={() => setIsNewPasswordEditable(true)} + onMouseDown={() => setIsNewPasswordEditable(true)} + onBlur={() => { + if (!newPassword) { + setIsNewPasswordEditable(false); + } + }} + InputProps={{ + readOnly: !isNewPasswordEditable, + }} + inputProps={{ + autoComplete: 'new-password', + 'data-1p-ignore': 'true', + 'data-lpignore': 'true', + spellCheck: 'false', + }} /> @@ -254,10 +308,12 @@ export const DownloadWallet = ({ { await saveFileToDiskFunc(); - await showInfo({ + executeEvent('openGlobalSnackBar', { message: t('auth:message.generic.keep_secure', { postProcess: 'capitalizeFirstChar', }), + type: 'info', + duration: 5600, }); }} > diff --git a/src/components/Auth/NewNodeReloadRequiredDialog.tsx b/src/components/Auth/NewNodeReloadRequiredDialog.tsx new file mode 100644 index 00000000..9cca09b7 --- /dev/null +++ b/src/components/Auth/NewNodeReloadRequiredDialog.tsx @@ -0,0 +1,96 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + alpha, + useTheme, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +type NewNodeReloadRequiredDialogProps = { + open: boolean; + onReload: () => void; +}; + +export function NewNodeReloadRequiredDialog({ + open, + onReload, +}: NewNodeReloadRequiredDialogProps) { + const theme = useTheme(); + const { t } = useTranslation(['auth']); + + return ( + { + /* Must reload via primary action — same UX contract as blocking confirmations */ + }} + aria-labelledby="new-node-reload-dialog-title" + aria-describedby="new-node-reload-dialog-description" + PaperProps={{ + sx: { + bgcolor: '#111820', + backgroundImage: 'none', + border: `1px solid ${alpha('#A9BCD8', 0.18)}`, + borderRadius: '18px', + boxShadow: `0 24px 58px ${alpha('#000', 0.42)}`, + maxWidth: 360, + width: 'calc(100% - 40px)', + }, + }} + > + + {t('auth:connection_mode.reload_after_new_node_title', { + postProcess: 'capitalizeFirstChar', + })} + + + + {t('auth:connection_mode.reload_after_new_node_message')} + + + + + + + ); +} diff --git a/src/components/AuthenticationForm.tsx b/src/components/AuthenticationForm.tsx index 026cbb4d..d624588f 100644 --- a/src/components/AuthenticationForm.tsx +++ b/src/components/AuthenticationForm.tsx @@ -1,20 +1,36 @@ -import { useEffect, useRef, useState } from 'react'; -import { Box, Typography, useTheme } from '@mui/material'; -import Avatar from '@mui/material/Avatar'; -import PersonIcon from '@mui/icons-material/Person'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + Avatar, + Box, + ButtonBase, + Divider, + Typography, + useTheme, +} from '@mui/material'; +import { alpha, type Theme } from '@mui/material/styles'; +import PersonIcon from '@mui/icons-material/Person'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; +import SettingsEthernetRoundedIcon from '@mui/icons-material/SettingsEthernetRounded'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import { useAtom } from 'jotai'; import { authenticatePasswordAtom } from '../atoms/global'; -import { Return } from '../assets/Icons/Return.tsx'; -import Logo1Dark from '../assets/svgs/Logo1Dark.svg'; -import { isLocalNodeUrl } from '../constants/constants'; -import { nodeDisplay } from '../utils/helpers.ts'; -import { CustomButton, CustomLabel, TextP } from '../styles/App-styles.ts'; -import { Spacer } from '../common/Spacer'; import { PasswordField, ErrorText } from './index'; import type { ApiKey } from '../types/auth'; import { getBaseApiReactForAvatar } from '../App'; import { getPrimaryNameForAvatar } from './Group/groupApi'; +import { AuthButton, AuthFrame, authPasswordFieldSx } from './Auth/AuthShell'; +import { + HTTPS_EXT_NODE_QORTAL_LINK, + isLocalNodeUrl, +} from '../constants/constants'; +import { ConnectionModeModal } from './Auth/ConnectionModeModal'; +import type { + AuthUnlockTransitionSnapshot, + SharedElementRect, +} from '../types/authTransition'; type RawWallet = { name?: string; @@ -26,182 +42,624 @@ type AuthenticationFormProps = { rawWallet: RawWallet; selectedNode: ApiKey | null; walletToBeDecryptedError: string; + unlockTransition?: AuthUnlockTransitionSnapshot | null; onBack: () => void; onAuthenticate: () => Promise; + onUnlockTransitionComplete?: () => void; +}; + +const shortenAddress = (address?: string) => { + if (!address) return ''; + if (address.length <= 20) return address; + return `${address.slice(0, 8)}...${address.slice(-8)}`; +}; + +const isAddressLikeLabel = (value?: string, walletAddress?: string) => { + const trimmedValue = value?.trim(); + const trimmedWalletAddress = walletAddress?.trim(); + if (!trimmedValue) return false; + if (trimmedWalletAddress && trimmedValue === trimmedWalletAddress) return true; + return /^Q[a-zA-Z0-9]{24,}$/.test(trimmedValue) && !trimmedValue.includes(' '); +}; + +const parsefilenameQortal = (filename?: string) => { + if (!filename) return ''; + return filename.startsWith('qortal_backup_') ? filename.slice(14) : filename; }; export const AuthenticationForm = ({ rawWallet, selectedNode, walletToBeDecryptedError, + unlockTransition, onBack, onAuthenticate, + onUnlockTransitionComplete, }: AuthenticationFormProps) => { const theme = useTheme(); - const { t } = useTranslation(['auth', 'core']); + const isLight = theme.palette.mode === 'light'; + const { t } = useTranslation(['auth']); const [authenticatePassword, setAuthenticatePassword] = useAtom( authenticatePasswordAtom ); const passwordRef = useRef(null); - const [primaryName, setPrimaryName] = useState(null); + const avatarRef = useRef(null); + const initialPrimaryNameRef = useRef({ + address: unlockTransition?.walletAddress, + name: unlockTransition?.primaryName ?? null, + }); + const [primaryName, setPrimaryName] = useState( + initialPrimaryNameRef.current.address === rawWallet?.address0 + ? initialPrimaryNameRef.current.name + : null + ); + const [isConnectionModeOpen, setIsConnectionModeOpen] = useState(false); + const [sharedTransition, setSharedTransition] = useState<{ + isRunning: boolean; + snapshot: AuthUnlockTransitionSnapshot; + targetAvatarRect: SharedElementRect; + } | null>(null); - // Fetch primary name for this address first; only then can we construct the avatar URL. - // Use getPrimaryNameForAvatar so the request uses the avatar-friendly base URL (e.g. HTTP when local HTTPS). useEffect(() => { if (!rawWallet?.address0) { setPrimaryName(null); return; } + + const seededPrimaryName = + initialPrimaryNameRef.current.address === rawWallet.address0 + ? initialPrimaryNameRef.current.name + : null; + + if (seededPrimaryName) { + setPrimaryName(seededPrimaryName); + return; + } + + setPrimaryName(null); + let isMounted = true; getPrimaryNameForAvatar(rawWallet.address0) - .then((name) => setPrimaryName(name || null)) - .catch(() => setPrimaryName(null)); + .then((name) => { + if (isMounted) setPrimaryName(name || null); + }) + .catch(() => { + if (isMounted) setPrimaryName(null); + }); + + return () => { + isMounted = false; + }; }, [rawWallet?.address0]); - // Avatar URL is built only from the fetched primary name (each address has its own primary name). - // Use getBaseApiReactForAvatar so local HTTPS uses HTTP for avatars (avoids cert issues). + useEffect(() => { + passwordRef.current?.focus(); + }, []); + const avatarSrc = primaryName ? `${getBaseApiReactForAvatar()}/arbitrary/THUMBNAIL/${primaryName}/qortal_avatar?async=true` : undefined; + const walletAddress = rawWallet?.address0?.trim() || ''; + const addressLabel = shortenAddress(walletAddress); + const parsedFilenameLabel = parsefilenameQortal(rawWallet?.filename).trim(); + const preferredIdentityLabel = + primaryName?.trim() || rawWallet?.name?.trim() || parsedFilenameLabel || ''; + const unnamedAccountLabel = t('auth:authentication_form.unnamed_account', { + postProcess: 'capitalizeFirstChar', + }); const displayLabel = - primaryName || - rawWallet?.name || - rawWallet?.filename || - rawWallet?.address0 || - ''; + preferredIdentityLabel || addressLabel || unnamedAccountLabel; + const titleLabel = isAddressLikeLabel(displayLabel, walletAddress) + ? addressLabel || unnamedAccountLabel + : displayLabel; + const usingLocalNode = isLocalNodeUrl(selectedNode?.url); + const customNodeStatusLabel = + selectedNode?.name?.trim() || selectedNode?.url?.trim() || ''; + const connectionLabel = usingLocalNode + ? t('auth:authentication_form.using_local_node', { + postProcess: 'capitalizeFirstChar', + }) + : selectedNode?.url === HTTPS_EXT_NODE_QORTAL_LINK + ? t('auth:authentication_form.using_public_node', { + postProcess: 'capitalizeFirstChar', + }) + : customNodeStatusLabel + ? t('auth:authentication_form.using_custom_node_named', { + label: customNodeStatusLabel, + defaultValue: 'Using {{label}}', + postProcess: 'capitalizeFirstChar', + }) + : t('auth:authentication_form.using_custom_node', { + postProcess: 'capitalizeFirstChar', + }); + const isSharedTransitionActive = Boolean(sharedTransition); + const storedAnimationPreference = + typeof window !== 'undefined' + ? window.localStorage.getItem('hub_ui_animations_enabled') + : null; + const shouldReduceMotion = + typeof window !== 'undefined' && + (storedAnimationPreference === 'false' || + (storedAnimationPreference === null && + window.matchMedia('(prefers-reduced-motion: reduce)').matches)); - useEffect(() => { - passwordRef.current?.focus(); - }, []); + useLayoutEffect(() => { + if ( + !unlockTransition || + shouldReduceMotion || + !avatarRef.current + ) { + return; + } - return ( - <> - - - - + const rectToObject = (rect: DOMRect) => ({ + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }); - + setSharedTransition({ + isRunning: false, + snapshot: unlockTransition, + targetAvatarRect: rectToObject(avatarRef.current.getBoundingClientRect()), + }); -
- -
+ let firstFrame = 0; + let secondFrame = 0; + const finishTimer = window.setTimeout(() => { + setSharedTransition(null); + onUnlockTransitionComplete?.(); + }, 430); - + firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + setSharedTransition((current) => + current ? { ...current, isRunning: true } : current + ); + }); + }); - + return () => { + window.clearTimeout(finishTimer); + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + }; + }, [onUnlockTransitionComplete, shouldReduceMotion, unlockTransition]); + + const avatarSharedOpacitySx = { + opacity: isSharedTransitionActive ? 0 : 1, + transition: 'none', + }; + const revealFormSx = unlockTransition + ? { + animation: + 'authUnlockFormReveal 320ms cubic-bezier(0.4, 0, 0.2, 1) 110ms both', + } + : {}; + + return ( + <> + - - - - {displayLabel} - + + + + + - + + + + + + {titleLabel} + + + + {addressLabel} + + { + if (rawWallet?.address0) { + void navigator.clipboard?.writeText(rawWallet.address0).catch(() => {}); + } + }} + sx={{ + color: isLight + ? alpha(theme.palette.text.secondary, 0.85) + : 'rgba(214,221,233,0.34)', + minWidth: 0, + p: 0, + '&:hover': { + color: isLight + ? theme.palette.primary.main + : 'rgba(214,221,233,0.7)', + }, + }} + > + + + + - - {t('auth:authentication', { - postProcess: 'capitalizeFirstChar', - })} - - + - + + + + {t('auth:wallet.password', { postProcess: 'capitalizeFirstChar' })} + + setAuthenticatePassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onAuthenticate(); + } + }} + ref={passwordRef} + placeholder={t('auth:authentication_form.password_placeholder', { + postProcess: 'capitalizeFirstChar', + })} + sx={authPasswordFieldSx(theme)} + /> + - <> - - {t('auth:wallet.password', { - postProcess: 'capitalizeFirstChar', - })} - - - - - setAuthenticatePassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onAuthenticate(); - } - }} - ref={passwordRef} - /> + + {walletToBeDecryptedError} + - <> - + + {t('auth:authentication_form.unlock', { + postProcess: 'capitalizeFirstChar', + })} + + - - {t('auth:node.using', { - postProcess: 'capitalizeFirstChar', - })} - : {nodeDisplay(selectedNode?.url)} - - + + {t('auth:authentication_form.choose_another_account', { + postProcess: 'capitalizeFirstChar', + })} + + - + - - {t('auth:action.authenticate', { - postProcess: 'capitalizeFirstChar', - })} - + + + + + {connectionLabel} + + + setIsConnectionModeOpen(true)} + sx={{ + alignItems: 'center', + color: isLight + ? theme.palette.text.secondary + : 'rgba(214,221,233,0.62)', + display: 'inline-flex', + fontSize: '0.76rem', + fontWeight: 500, + gap: 0.38, + justifyContent: 'center', + minWidth: 0, + p: 0, + width: '100%', + '&:hover': { + color: isLight + ? theme.palette.text.primary + : 'rgba(214,221,233,0.78)', + }, + }} + > + + + {t('auth:connection_mode.title')} + + + + + + + + setIsConnectionModeOpen(false)} + /> + {sharedTransition && ( + + )} + + ); +}; + +function walletUnlockSubmitButtonSx(theme: Theme) { + if (theme.palette.mode === 'dark') { + return { + border: '1px solid rgba(105,139,225,0.34)', + borderRadius: '10px', + boxShadow: '0 12px 30px rgba(10,18,36,0.24)', + fontSize: '0.92rem', + fontWeight: 600, + height: 52, + '&:disabled': { + background: + 'linear-gradient(180deg, rgba(51,83,151,0.84) 0%, rgba(35,62,120,0.84) 100%)', + borderColor: 'rgba(105,139,225,0.22)', + color: 'rgba(230,236,247,0.58)', + opacity: 1, + }, + }; + } + + return { + border: `1px solid ${alpha(theme.palette.primary.main, 0.34)}`, + borderRadius: '10px', + boxShadow: '0 10px 28px rgba(45, 72, 112, 0.11)', + fontSize: '0.92rem', + fontWeight: 600, + height: 52, + '&:disabled': { + background: `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.28)}, ${alpha(theme.palette.primary.dark, 0.32)})`, + borderColor: alpha(theme.palette.primary.main, 0.22), + color: alpha(theme.palette.text.secondary, 0.82), + opacity: 1, + }, + }; +} - {walletToBeDecryptedError} - +const buildSharedTransform = ( + originRect: SharedElementRect, + targetRect: SharedElementRect, + isRunning: boolean, + shouldScale = false +) => { + const translateX = targetRect.left - originRect.left; + const translateY = targetRect.top - originRect.top; + const scaleX = shouldScale ? targetRect.width / originRect.width : 1; + const scaleY = shouldScale ? targetRect.height / originRect.height : 1; + + return isRunning + ? `translate3d(${translateX}px, ${translateY}px, 0) scale(${scaleX}, ${scaleY})` + : 'translate3d(0, 0, 0) scale(1, 1)'; +}; + +const sharedOverlayBaseSx = { + opacity: 1, + pointerEvents: 'none', + position: 'fixed', + transformOrigin: 'top left', + transition: + 'transform 400ms cubic-bezier(0.4, 0, 0.2, 1), opacity 140ms cubic-bezier(0.4, 0, 0.2, 1)', + zIndex: 5200, +}; + +const SharedUnlockTransitionOverlay = ({ + transition, +}: { + transition: { + isRunning: boolean; + snapshot: AuthUnlockTransitionSnapshot; + targetAvatarRect: SharedElementRect; + }; +}) => { + const { isRunning, snapshot, targetAvatarRect } = transition; + + return ( + <> + + + ); }; diff --git a/src/components/BuyQortInformation.tsx b/src/components/BuyQortInformation.tsx index c7d3b85a..a7878919 100644 --- a/src/components/BuyQortInformation.tsx +++ b/src/components/BuyQortInformation.tsx @@ -12,9 +12,9 @@ import { ListItemText, List, Typography, + alpha, useTheme, } from '@mui/material'; -import { Spacer } from '../common/Spacer'; import qTradeLogo from '../assets/Icons/q-trade-logo.webp'; import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import { @@ -56,14 +56,31 @@ export const BuyQortInformation = ({ balance }) => { open={isOpen} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + PaperProps={{ + sx: { + background: '#121821', + backgroundImage: 'none', + border: '1px solid rgba(169,188,216,0.18)', + borderRadius: '18px', + boxShadow: '0 26px 56px rgba(0,0,0,0.44)', + color: theme.palette.text.primary, + overflow: 'hidden', + width: '100%', + maxWidth: 460, + }, + }} > {t('core:action.get_qort', { @@ -71,21 +88,20 @@ export const BuyQortInformation = ({ balance }) => { })} - + - + {t('core:message.generic.get_qort_trade_portal', { postProcess: 'capitalizeFirstChar', })} @@ -93,11 +109,20 @@ export const BuyQortInformation = ({ balance }) => { { executeEvent('addTab', { @@ -109,27 +134,44 @@ export const BuyQortInformation = ({ balance }) => { > - - {t('core:action.trade_qort', { - postProcess: 'capitalizeFirstChar', - })} - + + + {t('core:action.trade_qort', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('core:message.generic.open_q_trade_subtitle', { + postProcess: 'capitalizeFirstChar', + })} + + - - {t('core:message.generic.benefits_qort', { @@ -139,41 +181,85 @@ export const BuyQortInformation = ({ balance }) => { - - + + - - + + - + + {secretKey && ( + + setAddNewKey(e.target.checked)} + size="small" + color="primary" + sx={{ py: 0, '& .MuiSvgIcon-root': { fontSize: 16 } }} + /> + } + label={ + + {t('group:message.generic.add_new_key_option', { + postProcess: 'capitalizeFirstChar', + })} + + } + sx={{ alignItems: 'flex-start', mr: 0 }} + /> + + {t('group:message.generic.add_new_key_sparingly', { + postProcess: 'capitalizeFirstChar', + })} + + + )} + { 'title', 'width', 'height', - 'style', 'align', 'valign', 'colspan', @@ -203,12 +202,7 @@ export const MessageDisplay = ({ htmlContent, isReply = false }) => { const target = e.target; if (target.tagName === 'A') { - const href = target.getAttribute('href'); - if (window?.electronAPI) { - window.electronAPI.openExternal(href); - } else { - window.open(href, '_system'); - } + openHttpUrlExternally(target.getAttribute('href')); } else if (target.getAttribute('data-url')) { const url = target.getAttribute('data-url'); diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index 561137ea..a967df3c 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -399,63 +399,153 @@ export const MessageItemComponent = ({ width: '100%', }} > - {/* Header: sender name + timestamp + edited label inline */} + {/* Header: sender name + timestamp + edited label inline, toolbar on the right */} - - - {message?.senderName || message?.sender} - - + + {message?.senderName || message?.sender} + + + + {!isUpdating && !isTemp && ( + + {formatTimestamp(message.timestamp)} + + )} - {!isUpdating && !isTemp && ( - + {t('core:message.generic.edited', { + postProcess: 'capitalizeFirstChar', + })} + + )} + + + {/* Action toolbar in header row so it never overlaps message body */} + {!isShowingAsReply && ( + - {formatTimestamp(message.timestamp)} - - )} + {message?.sender === myAddress && + (!message?.isNotEncrypted || isPrivate === false) && ( + + { + onEdit(message); + }} + > + + + + )} - {message?.isEdit && !isUpdating && !isTemp && ( - - {t('core:message.generic.edited', { - postProcess: 'capitalizeFirstChar', - })} - + + { + onReply(message); + }} + > + + + + + {handleReaction && ( + { + if ( + reactions && + reactions[val] && + reactions[val]?.find((item) => item?.sender === myAddress) + ) { + handleReaction(val, message, false); + } else { + handleReaction(val, message, true); + } + }} + /> + )} + )} @@ -948,86 +1038,6 @@ export const MessageItemComponent = ({ )} - - {/* Floating action toolbar — visible on hover (via CSS so only one row is hovered) */} - {!isShowingAsReply && ( - - {message?.sender === myAddress && - (!message?.isNotEncrypted || isPrivate === false) && ( - - { - onEdit(message); - }} - > - - - - )} - - - { - onReply(message); - }} - > - - - - - {handleReaction && ( - { - if ( - reactions && - reactions[val] && - reactions[val]?.find((item) => item?.sender === myAddress) - ) { - handleReaction(val, message, false); - } else { - handleReaction(val, message, true); - } - }} - /> - )} - - )} - i === 0 + const content = parts.flatMap((t, i) => { + // ProseMirror does not allow empty text nodes; skip empty parts + if (t === '') { + return i === 0 ? [] : [schema.nodes.hardBreak.create()]; + } + return i === 0 ? [schema.text(t)] : [ schema.nodes.hardBreak.create(), schema.text(t), - ] - ); + ]; + }); return schema.nodes.paragraph.create( null, Fragment.from(content) diff --git a/src/components/Chat/chat.css b/src/components/Chat/chat.css index f62b4909..23e5f381 100644 --- a/src/components/Chat/chat.css +++ b/src/components/Chat/chat.css @@ -114,6 +114,62 @@ margin: 0px; } +.tiptap strong, +.tiptap b { + font-weight: 700; + color: inherit; +} + +.tiptap em, +.tiptap i { + font-style: italic; + color: inherit; +} + +.tiptap s { + text-decoration: line-through; + color: inherit; +} + +/* Tables — layout from HTML attributes (align, border, etc.); look from CSS */ +.tiptap table { + border-collapse: collapse; + border-spacing: 0; + margin: 0.75rem 0; + width: 100%; + table-layout: auto; + color: var(--text-primary); +} + +.tiptap th, +.tiptap td { + border: 1px solid var(--code-block-border, rgba(128, 128, 128, 0.35)); + padding: 0.35em 0.6em; + vertical-align: top; + text-align: start; +} + +.tiptap th { + font-weight: 600; + background: color-mix(in srgb, var(--background-secondary, #fff) 88%, transparent); +} + +.tiptap ul { + list-style-type: disc; +} + +.tiptap ol { + list-style-type: decimal; +} + +.tiptap li { + color: var(--text-primary); +} + +.tiptap li::marker { + color: var(--text-secondary); +} + /* Space between paragraphs so pasted multi-paragraph text stays readable */ .tiptap p + p { margin-top: 0.75em; @@ -152,6 +208,7 @@ border-radius: 4px; padding: 0.15em 0.4em; font-weight: 500; + cursor: pointer; transition: background 0.15s ease, opacity 0.15s ease; @@ -197,20 +254,23 @@ opacity: 1; background: none !important; } -.tiptap.isReply [data-type='mention'] { +.tiptap.isReply [data-type='mention'], +.tiptap.isReply .mention { color: inherit !important; background: none !important; border-radius: 0; padding: 0; font-weight: inherit; } -.tiptap.isReply [data-type='mention']:hover { +.tiptap.isReply [data-type='mention']:hover, +.tiptap.isReply .mention:hover { background: none !important; opacity: 1; } -/* @mentions */ -.tiptap [data-type='mention'] { +/* @mentions — class survives DOMPurify; data-type may be stripped */ +.tiptap [data-type='mention'], +.tiptap .mention { box-decoration-break: clone; color: var(--primary-main, var(--text-secondary)); background: color-mix(in srgb, var(--primary-main, #5f9ea0) 12%, transparent); @@ -221,7 +281,8 @@ background 0.15s ease, opacity 0.15s ease; } -.tiptap [data-type='mention']:hover { +.tiptap [data-type='mention']:hover, +.tiptap .mention:hover { background: color-mix(in srgb, var(--primary-main, #5f9ea0) 20%, transparent); opacity: 0.9; } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index c15c4b31..1b9a881b 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useMemo } from 'react'; import { + Divider, ListItemIcon, Menu, MenuItem, @@ -9,23 +10,23 @@ import { } from '@mui/material'; import MailOutlineIcon from '@mui/icons-material/MailOutline'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; +import DoneAllRoundedIcon from '@mui/icons-material/DoneAllRounded'; +import { useTranslation } from 'react-i18next'; import { executeEvent } from '../utils/events'; import { mutedGroupsAtom } from '../atoms/global'; import { useAtom } from 'jotai'; const CustomStyledMenu = styled(Menu)(({ theme }) => ({ '& .MuiPaper-root': { - // backgroundColor: '#f9f9f9', borderRadius: '12px', padding: theme.spacing(1), boxShadow: '0 5px 15px rgba(0, 0, 0, 0.2)', }, '& .MuiMenuItem-root': { - fontSize: '14px', // Smaller font size for the menu item text - // color: '#444', + fontSize: '14px', transition: '0.3s background-color', '&:hover': { - backgroundColor: theme.palette.action.hover, // Explicit hover state + backgroundColor: theme.palette.action.hover, }, }, })); @@ -33,20 +34,19 @@ const CustomStyledMenu = styled(Menu)(({ theme }) => ({ export const ContextMenu = ({ children, groupId, getUserSettings }) => { const [menuPosition, setMenuPosition] = useState(null); const longPressTimeout = useRef(null); - const preventClick = useRef(false); // Flag to prevent click after long-press or right-click + const preventClick = useRef(false); const theme = useTheme(); const [mutedGroups] = useAtom(mutedGroupsAtom); + const { t } = useTranslation(['group']); const isMuted = useMemo(() => { return mutedGroups.includes(groupId); }, [mutedGroups, groupId]); - // Handle right-click (context menu) for desktop const handleContextMenu = (event) => { event.preventDefault(); - event.stopPropagation(); // Prevent parent click + event.stopPropagation(); - // Set flag to prevent any click event after right-click preventClick.current = true; setMenuPosition({ @@ -55,16 +55,15 @@ export const ContextMenu = ({ children, groupId, getUserSettings }) => { }); }; - // Handle long-press for mobile const handleTouchStart = (event) => { longPressTimeout.current = setTimeout(() => { - preventClick.current = true; // Prevent the next click after long-press - event.stopPropagation(); // Prevent parent click + preventClick.current = true; + event.stopPropagation(); setMenuPosition({ mouseX: event.touches[0].clientX, mouseY: event.touches[0].clientY, }); - }, 500); // Long press duration + }, 500); }; const handleTouchEnd = (event) => { @@ -72,8 +71,8 @@ export const ContextMenu = ({ children, groupId, getUserSettings }) => { if (preventClick.current) { event.preventDefault(); - event.stopPropagation(); // Prevent synthetic click after long-press - preventClick.current = false; // Reset the flag + event.stopPropagation(); + preventClick.current = false; } }; @@ -95,8 +94,6 @@ export const ContextMenu = ({ children, groupId, getUserSettings }) => { .then((response) => { if (response?.error) { console.error('Error adding user settings:', response.error); - } else { - console.log('User settings added successfully'); } }) .catch((error) => { @@ -120,9 +117,9 @@ export const ContextMenu = ({ children, groupId, getUserSettings }) => { return (
{children} @@ -158,7 +155,7 @@ export const ContextMenu = ({ children, groupId, getUserSettings }) => { /> - Mark As Read + {t('group:context_menu.mark_as_read')} { variant="inherit" sx={{ fontSize: '14px', color: isMuted && 'red' }} > - {isMuted ? 'Unmute ' : 'Mute '}Push Notifications + {isMuted + ? t('group:context_menu.unmute_push_notifications') + : t('group:context_menu.mute_push_notifications')} + + + + { + handleClose(e); + executeEvent('markAllMemberGroupsRead', {}); + }} + > + + + + + {t('group:context_menu.mark_all_read')}
); -}; // TODO translate +}; diff --git a/src/components/CoreSettingUp.tsx b/src/components/CoreSettingUp.tsx index ba067ddc..1786eb20 100644 --- a/src/components/CoreSettingUp.tsx +++ b/src/components/CoreSettingUp.tsx @@ -1,4 +1,5 @@ import { + Box, Button, CircularProgress, Dialog, @@ -6,21 +7,47 @@ import { DialogContent, DialogTitle, Typography, + useTheme, } from '@mui/material'; import { useAtom } from 'jotai'; import { isOpenSettingUpLocalCoreAtom } from '../atoms/global'; import { useTranslation } from 'react-i18next'; -import { getDefaultLocalNodeUrl } from '../constants/constants'; +import { HTTP_LOCALHOST_12391 } from '../constants/constants'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { + dialogActionsSx, + dialogContentSx, + dialogContentTextSx, + dialogModalBackdropSx, + dialogTitleSx, + getDialogPaperSx, + getDialogSecondaryButtonSx, +} from './App/dialogSurface'; export function CoreSettingUp() { + const theme = useTheme(); const { t } = useTranslation(['node', 'core']); const [canContinue, setCanContinue] = useState(false); const [open, setOpen] = useAtom(isOpenSettingUpLocalCoreAtom); const intervalRef = useRef(null); const isCallingRef = useRef(false); + const continueButtonSx = { + borderRadius: '11px', + fontSize: '0.9rem', + fontWeight: 600, + minHeight: 42, + minWidth: 112, + px: 2.2, + textTransform: 'none' as const, + '&.Mui-disabled': { + backgroundColor: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(169,188,216,0.12)', + color: 'rgba(214,221,233,0.38)', + }, + }; + const cleanUp = useCallback(() => { setCanContinue(false); isCallingRef.current = false; @@ -34,7 +61,8 @@ export function CoreSettingUp() { try { if (isCallingRef.current) return; isCallingRef.current = true; - const res = await fetch(getDefaultLocalNodeUrl() + '/admin/status'); + // HTTP only: Core exposes /admin/status over HTTP; HTTPS can fail before TLS cert is ready. + const res = await fetch(`${HTTP_LOCALHOST_12391}/admin/status`); if (!res?.ok) return false; cleanUp(); @@ -47,6 +75,11 @@ export function CoreSettingUp() { } }, [cleanUp]); + useEffect(() => { + if (!open?.isShow) return; + void getStatus(); + }, [open?.isShow, getStatus]); + useEffect(() => { if (intervalRef.current) return; if (open?.isShow) { @@ -66,56 +99,73 @@ export function CoreSettingUp() { cleanUp(); } }; + + const titleKey = !canContinue + ? 'node:NotFullyStarted.titleNotReady' + : 'node:NotFullyStarted.titleReady'; + const descKey = !canContinue + ? 'node:NotFullyStarted.descNotReady' + : 'node:NotFullyStarted.descReady'; + return ( - {!canContinue && ( - <> - - {t('node:NotFullyStarted.titleNotReady', { - postProcess: 'capitalizeEachFirstChar', - })} - - - - {t('node:NotFullyStarted.descNotReady', { + + {t(titleKey, { + postProcess: 'capitalizeEachFirstChar', + })} + + + + {!canContinue ? ( + + + {t(descKey, { postProcess: 'capitalizeEachFirstChar', })} - - - - )} - - {canContinue && ( - <> - - {t('node:NotFullyStarted.titleReady', { + + + ) : ( + + {t(descKey, { postProcess: 'capitalizeEachFirstChar', })} - - - - {t('node:NotFullyStarted.descReady', { - postProcess: 'capitalizeEachFirstChar', - })} - - - - )} + + )} + - + - ) : ( - - )} - - + + + {t('node:coreSetupDialog.title', { + postProcess: 'capitalizeFirstChar', + })} + + {onClose && ( + + + + )} + + + + + + + + + {t('node:coreSetupDialog.introTitle', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('node:coreSetupDialog.introSubtitle', { + postProcess: 'capitalizeFirstChar', + })} + + + + + {publicNodeUnavailable && ( + + + + {t('node:coreSetupDialog.publicNodeUnavailable', { + postProcess: 'capitalizeFirstChar', + })} + + )} - - {stepStates.map(({ key, label, state }, idx) => { + + {stepStates.map(({ key, label, state }) => { const prog = resolveProgress(state); const isIndeterminate = prog === undefined && (state.status === 'active' || state.status === 'error'); + const isNextStep = key === nextStepKey; + const statusLabel = + isNextStep && state.status === 'idle' + ? key === 'downloadedCore' + ? t('node:coreSetupDialog.readyToDownload', { + postProcess: 'capitalizeFirstChar', + }) + : t('node:coreSetupDialog.readyToStart', { + postProcess: 'capitalizeFirstChar', + }) + : key === 'coreRunning' && + state.status === 'done' && + isCoreSyncing + ? t('node:coreSetupDialog.syncing', { + postProcess: 'capitalizeFirstChar', + }) + : statusText(state.status); + const helperText = + key === 'downloadedCore' + ? state.status === 'done' + ? t('node:coreSetupDialog.helpers.downloadDone', { + postProcess: 'capitalizeFirstChar', + }) + : state.status === 'active' + ? t('node:coreSetupDialog.helpers.downloadActive', { + postProcess: 'capitalizeFirstChar', + }) + : t('node:coreSetupDialog.helpers.downloadIdle', { + postProcess: 'capitalizeFirstChar', + }) + : state.status === 'done' + ? isCoreSyncing + ? t('node:coreSetupDialog.helpers.runningDoneSyncing', { + postProcess: 'capitalizeFirstChar', + }) + : t('node:coreSetupDialog.helpers.runningDone', { + postProcess: 'capitalizeFirstChar', + }) + : downloaded + ? t('node:coreSetupDialog.helpers.runningWaiting', { + postProcess: 'capitalizeFirstChar', + }) + : t('node:coreSetupDialog.helpers.runningBlocked', { + postProcess: 'capitalizeFirstChar', + }); return ( - - - {statusText(state.status)} - - } - > - + + + + {renderStepStatusIcon(state.status)} + + + {label} - {label} + {statusLabel} - - - - - - - - - - {prog !== undefined - ? `${prog}%` - : isIndeterminate - ? '...' - : '0%'} + {isNextStep && ( + + {helperText} - + )} + + {isNextStep && ( + + {t('node:coreSetupDialog.nextStepPill', { + postProcess: 'capitalizeFirstChar', + })} + + )} + + + + + + + {prog !== undefined + ? `${prog}%` + : isIndeterminate + ? '...' + : '0%'} + + - {state.message ? ( - - {t(`node:messages.${state.message}`, { - postProcess: 'capitalizeFirstChar', - })} - - ) : null} - - - + {state.message && !isNextStep ? ( + + {t(`node:messages.${state.message}`, { + postProcess: 'capitalizeFirstChar', + })} + + ) : null} + ); })} - - - - - - - - {errorStop} + + + {coreLocationDescription} + - - + - {errorBootstrap} + {customQortalPath && ( + + )} - - - {errorDeleteDB} + + + {t('node:coreSetupDialog.advancedSubtitle', { + postProcess: 'capitalizeFirstChar', + })} + - - + {isExtended ? ( + + ) : ( + + )} + + + + {advancedCoreToolsDisabled && ( + + {t('node:coreSetupDialog.maintenanceUnavailable', { + postProcess: 'capitalizeFirstChar', + })} + + )} + + + + {t('node:coreSetupDialog.stopCoreTitle', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('node:coreSetupDialog.stopCoreBody', { + postProcess: 'capitalizeFirstChar', + })} + + {errorStop && ( + {errorStop} + )} + + + + + + + + {t('node:bootstrapChainHelp.title', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('node:bootstrapChainHelp.body', { + postProcess: 'capitalizeFirstChar', + })} + + {errorBootstrapChain && ( + + {errorBootstrapChain} + + )} + + + + + + - - {onClose && !running && ( - - )} + + + {onClose && ( + + )} - + {hasContextualAction ? ( + + ) : ( + + )} + )} @@ -808,39 +1438,31 @@ export function CoreSetupDialog(props: CoreSetupDialogProps) { onClose={onCancel} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + PaperProps={{ + sx: getDialogPaperSx(theme, { maxWidth: 420 }), + }} > - - - - + + {t('node:coreSetupDialog.confirmTitle', { + postProcess: 'capitalizeFirstChar', + })} + + + + {message?.message} - + - {isElectron ? ( - - ) : ( - - )} - - - ); -} diff --git a/src/components/CoreSetupResetApikeyDialog.tsx b/src/components/CoreSetupResetApikeyDialog.tsx index c963f26e..13b970e7 100644 --- a/src/components/CoreSetupResetApikeyDialog.tsx +++ b/src/components/CoreSetupResetApikeyDialog.tsx @@ -1,11 +1,12 @@ import { + Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, - TextField, Typography, + useTheme, } from '@mui/material'; import { useAuth } from '../hooks/useAuth'; import { useAtom, useSetAtom } from 'jotai'; @@ -17,6 +18,18 @@ import { import { useTranslation } from 'react-i18next'; import { getDefaultLocalNodeUrl } from '../constants/constants'; import { useState } from 'react'; +import { AuthInput, AuthSectionLabel } from './Auth/AuthShell'; +import { + dialogActionsSx, + dialogContentSx, + dialogContentTextSx, + dialogInfoCardSx, + dialogModalBackdropSx, + dialogTitleSx, + getDialogPaperSx, + getDialogPrimaryButtonSx, + getDialogSecondaryButtonSx, +} from './App/dialogSurface'; const isElectron = !!window?.coreSetup; @@ -29,6 +42,7 @@ export function CoreSetupResetApikeyDialog() { handleSaveNodeInfo, } = useAuth(); const { t } = useTranslation(['node', 'core']); + const theme = useTheme(); const [newApiKey, setNewApiKey] = useState(''); const setOpenSnackGlobal = useSetAtom(openSnackGlobalAtom); const setInfoSnackCustom = useSetAtom(infoSnackGlobalAtom); @@ -75,27 +89,43 @@ export function CoreSetupResetApikeyDialog() { fullWidth maxWidth="sm" aria-labelledby="core-setup-title" + slotProps={{ + backdrop: { sx: dialogModalBackdropSx }, + }} + PaperProps={{ + sx: getDialogPaperSx(theme, { maxWidth: 460 }), + }} > - + {t('node:invalidKey.title', { postProcess: 'capitalizeFirstChar', })} - - - {t('node:invalidKey.description', { - postProcess: 'capitalizeFirstChar', - })} - - setNewApiKey(e.target.value)} - /> + + + + {t('node:invalidKey.description', { + postProcess: 'capitalizeFirstChar', + })} + + + + API key + setNewApiKey(e.target.value)} + /> + - - + + + + + {t('node:invalidKey.description', { + postProcess: 'capitalizeFirstChar', + })} + + + + + Node + + - - + + {t('core:action.close', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('node:actions.continue', { + postProcess: 'capitalizeFirstChar', + })} + + + ); } diff --git a/src/components/Desktop/CustomTitleBar.tsx b/src/components/Desktop/CustomTitleBar.tsx index 081a6e7e..0c3bd3bc 100644 --- a/src/components/Desktop/CustomTitleBar.tsx +++ b/src/components/Desktop/CustomTitleBar.tsx @@ -10,21 +10,8 @@ import MenuIcon from '@mui/icons-material/Menu'; import RemoveIcon from '@mui/icons-material/Remove'; import CropSquareIcon from '@mui/icons-material/CropSquare'; import FilterNoneIcon from '@mui/icons-material/FilterNone'; -import LogoutIcon from '@mui/icons-material/Logout'; -import SettingsIcon from '@mui/icons-material/Settings'; -import PersonSearchIcon from '@mui/icons-material/PersonSearch'; -import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; -import EngineeringIcon from '@mui/icons-material/Engineering'; -import HelpIcon from '@mui/icons-material/Help'; -import DownloadIcon from '@mui/icons-material/Download'; import QortalLogo from '../../assets/svgs/Logo1Dark.svg'; import { WalletIcon } from '../../assets/Icons/WalletIcon'; -import { QMailStatus } from '../QMailStatus'; -import { GeneralNotifications } from '../GeneralNotifications'; -import { Save } from '../Save/Save'; -import { TaskManager } from '../TaskManager/TaskManager'; -import { GlobalActions } from '../GlobalActions/GlobalActions'; -import { ChatWidgetReopenIcon } from '../Profile/ChatWidgetReopenIcon'; const TITLE_BAR_HEIGHT = 32; export const APP_NAV_BAR_HEIGHT = 48; @@ -43,6 +30,9 @@ declare global { windowMaximize?: () => Promise; windowClose?: () => Promise; getWindowState?: () => Promise<{ isMaximized: boolean }>; + onWindowStateChange?: ( + callback: (state: { isMaximized: boolean }) => void + ) => () => void; getPlatform?: () => Promise; showAppMenu?: (x?: number, y?: number) => Promise; }; @@ -62,7 +52,6 @@ export type CustomTitleBarRightNavProps = { onOpenSettings: () => void; onOpenDrawerLookup: () => void; onOpenWalletsApp: () => void; - onOpenDrawerProfile: () => void; onLogout: () => void; getUserInfo: (useTimer?: boolean) => Promise; onOpenMinting: () => void; @@ -127,10 +116,11 @@ export function CustomTitleBar(props?: { }, [isElectron, refreshMaximized]); useEffect(() => { - if (!isElectron || typeof window.electronAPI?.getWindowState !== 'function') + if (!isElectron || typeof window.electronAPI?.onWindowStateChange !== 'function') return; - const interval = setInterval(refreshMaximized, 500); - return () => clearInterval(interval); + return window.electronAPI.onWindowStateChange((state) => { + setIsMaximized(Boolean(state?.isMaximized)); + }); }, [isElectron, refreshMaximized]); const handleMinimize = useCallback(() => { @@ -158,10 +148,22 @@ export function CustomTitleBar(props?: { }, [platform, refreshMaximized]); const bg = - theme.palette.mode === 'dark' ? '#27282c' : theme.palette.background.paper; - const borderColor = theme.palette.divider; + theme.palette.mode === 'dark' + ? 'rgba(34, 37, 43, 0.97)' + : theme.palette.background.paper; + const borderColor = + theme.palette.mode === 'dark' + ? theme.palette.border.subtle + : theme.palette.divider; const controlColor = theme.palette.text.secondary; - const controlHover = theme.palette.action.hover; + const controlHover = + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.09)' + : theme.palette.action.hover; + const toolbarShadow = + theme.palette.mode === 'dark' + ? '0 1px 0 rgba(255,255,255,0.05), 0 10px 20px rgba(0,0,0,0.14)' + : '0 1px 0 rgba(15,23,42,0.06), 0 6px 14px rgba(15,23,42,0.05)'; const macColors = { close: '#ff5f57', @@ -308,7 +310,13 @@ export function CustomTitleBar(props?: { height: TITLE_BAR_HEIGHT, padding: 0, WebkitAppRegion: 'no-drag', + transition: + 'background-color 140ms ease, color 140ms ease, transform 120ms ease', '&:hover': { backgroundColor: controlHover }, + '&:focus-visible': { + outline: `1px solid ${theme.palette.primary.main}`, + outlineOffset: '-1px', + }, }} aria-label="Application menu" > @@ -367,258 +375,17 @@ export function CustomTitleBar(props?: { ); - const navIconSx = { - color: controlColor, - width: 32, - height: 32, - borderRadius: 1, - '&:hover': { backgroundColor: controlHover }, - }; - - /** Uniform 32x32 cell for widgets so all title-bar icons align; scales inner icons to 20px */ - const navCellSx = { - width: 32, - height: 32, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - overflow: 'hidden', - '& .MuiSvgIcon-root': { fontSize: 20 }, - '& .MuiIconButton-root': { width: 32, height: 32, padding: 0, minWidth: 0 }, - '& svg': { width: 20, height: 20 }, - }; - - const navSectionSx = { - alignItems: 'center' as const, - display: 'flex', - flexShrink: 0, - gap: 0.5, - height: '100%', - pl: 0.5, - pr: 0.5, - ...(isElectron && { WebkitAppRegion: 'no-drag' as const }), - }; - - const leftNavSection = rightNav && ( - - - - - - - - - - - - - - - - - - - - {rightNav.extState === 'authenticated' && ( - - - - )} - - - - - - - - {rightNav.desktopViewMode !== 'home' && ( - - - - - - )} - - ); - - const rightNavSection = rightNav && ( - - - {/* {rightNav.extState === 'authenticated' && rightNav.isMainWindow && ( */} - <> - - - - - - - - - - {/* )} */} - - - - - - - {/* {(rightNav.desktopViewMode === 'apps' || rightNav.desktopViewMode === 'home') && ( - - (rightNav.desktopViewMode === 'apps' ? rightNav.showTutorial('qapps', true) : rightNav.showTutorial('getting-started', true))} - sx={navIconSx} - aria-label={t('core:tutorial')} - > - - - - )} */} - - - - - - - - - - - - - ); - - const titleBarVerticalDivider = ( + const leftPushCluster = (children: React.ReactNode) => ( - ); - - const leftOffsetSpacer = ( - + > + {children} + ); return ( @@ -630,6 +397,7 @@ export function CustomTitleBar(props?: { borderBottom: '1px solid', borderColor, backgroundColor: bg, + boxShadow: toolbarShadow, display: 'flex', flexDirection: 'row', height: TITLE_BAR_HEIGHT, @@ -642,40 +410,21 @@ export function CustomTitleBar(props?: { {isElectron && (isMac ? ( <> - {macWindowControls} - {menuButton} - {rightNav && ( - <> - {leftOffsetSpacer} - {titleBarVerticalDivider} - {leftNavSection} - - )} + {leftPushCluster(<>{macWindowControls}{menuButton})} - {rightNavSection} ) : ( <> - {menuButton} - {rightNav && ( - <> - {leftOffsetSpacer} - {titleBarVerticalDivider} - {leftNavSection} - - )} + {leftPushCluster(<>{menuButton})} - {rightNavSection} - {titleBarVerticalDivider} {winWindowControls} ))} {!isElectron && ( <> - {rightNav && <>{leftNavSection}} + {leftPushCluster()} - {rightNavSection} )} diff --git a/src/components/Desktop/DesktopHeader.tsx b/src/components/Desktop/DesktopHeader.tsx index 2eef507a..86421dac 100644 --- a/src/components/Desktop/DesktopHeader.tsx +++ b/src/components/Desktop/DesktopHeader.tsx @@ -74,7 +74,6 @@ export const DesktopHeader = ({ chatMode, openDrawerGroups, goToHome, - setIsOpenDrawerProfile, mobileViewMode, setMobileViewMode, setMobileViewModeKeepOpen, diff --git a/src/components/Desktop/DesktopLeftSideBar.tsx b/src/components/Desktop/DesktopLeftSideBar.tsx index fe6abcf2..71bd3b54 100644 --- a/src/components/Desktop/DesktopLeftSideBar.tsx +++ b/src/components/Desktop/DesktopLeftSideBar.tsx @@ -1,235 +1,634 @@ -import { Box, ButtonBase, useTheme } from '@mui/material'; +import { + alpha, + Box, + ButtonBase, + Divider, + Typography, + useTheme, +} from '@mui/material'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import { useTranslation } from 'react-i18next'; import { HomeIcon } from '../../assets/Icons/HomeIcon'; -import { Save } from '../Save/Save'; -import { IconWrapper } from './DesktopFooter'; +import { AppsIcon } from '../../assets/Icons/AppsIcon'; +import { MessagingIconFilled } from '../../assets/Icons/MessagingIconFilled'; +import qortalLogoOfficial from '../../assets/sidebar/qortal-logo-official.png'; import { enabledDevModeAtom, hasUnreadGroupsAtom, + isNewTabWindowAtom, } from '../../atoms/global'; -import { useAtom, useAtomValue } from 'jotai'; -import { AppsIcon } from '../../assets/Icons/AppsIcon'; -import ThemeSelector from '../Theme/ThemeSelector'; +import { keyframes } from '@mui/material/styles'; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from '../../utils/events'; +import { openQChatTab, QCHAT_INTERNAL_TAB_ID } from '../../utils/openQChatTab'; import { CoreSyncStatus } from '../CoreSyncStatus'; import LanguageSelector from '../Language/LanguageSelector'; -import { MessagingIconFilled } from '../../assets/Icons/MessagingIconFilled'; -import { useTranslation } from 'react-i18next'; -import { AppsDevModeNavBar } from '../Apps/AppsDevModeNavBar'; -import { executeEvent } from '../../utils/events'; -import { appChromeOffsetPx } from './CustomTitleBar'; +import ThemeSelector from '../Theme/ThemeSelector'; + +type DesktopSideBarProps = { + desktopViewMode: string; + goToHome: any; + hasUnreadDirects: any; + isApps: boolean; + setAppsModeDev: Dispatch>; + setDesktopViewMode: (mode: string) => void; + isDirects?: any; + isGroups?: any; + lastQappViewMode?: any; + mode?: any; + setDesktopSideView?: any; + setMode?: any; + toggleSideViewDirects?: any; + toggleSideViewGroups?: any; +}; + +const SIDEBAR_WIDTH_PX = 72; +const EDGE_SENSOR_WIDTH_PX = 8; +const EDGE_SENSOR_HEIGHT_PX = 124; +const TRIGGER_WIDTH_PX = 10; +const TRIGGER_HEIGHT_PX = 96; +const ITEM_WIDTH_PX = 56; +const ITEM_MIN_HEIGHT_PX = 58; +const ICON_WRAP_SIZE_PX = 40; +const ICON_SIZE_PX = 24; +const ITEM_GAP_PX = 6; +const ITEM_PADDING_Y = 1; +const OVERLAY_TRANSITION = '200ms cubic-bezier(0.2, 0, 0, 1)'; +const SIDEBAR_OPEN_DELAY_MS = 0; +const SIDEBAR_CLOSE_DELAY_MS = 140; + +const pulse = keyframes` + 0%, 100% { + transform: translateY(-50%) scale(1); + opacity: 1; + } + 50% { + transform: translateY(-50%) scale(1.3); + opacity: 0.6; + } +`; + +const DevModeIcon = ({ + color = 'currentColor', + height = 24, + width = 24, +}: { + color?: string; + height?: number; + width?: number; +}) => { + const dotRadius = 2.3; + + return ( + + ); +}; + +const SidebarItem = ({ + active = false, + children, + isInfo = false, + dataTheme, + itemClassName, + label, + onClick, +}: { + active?: boolean; + children: React.ReactNode; + isInfo?: boolean; + dataTheme?: string; + itemClassName?: string; + label: string; + onClick?: () => void; +}) => { + const theme = useTheme(); + const content = ( + <> + + {children} + + + {label} + + + ); + + const sharedSx = { + alignItems: 'center', + backgroundColor: active + ? alpha( + theme.palette.action.selected, + theme.palette.mode === 'dark' ? 0.78 : 0.88 + ) + : 'transparent', + borderRadius: '14px', + color: active ? theme.palette.text.primary : theme.palette.text.secondary, + display: 'flex', + flexDirection: 'column', + gap: `${ITEM_GAP_PX}px`, + justifyContent: 'flex-start', + minHeight: `${ITEM_MIN_HEIGHT_PX}px`, + py: ITEM_PADDING_Y, + transition: + 'background-color 180ms ease, color 180ms ease, box-shadow 140ms ease, transform 120ms ease', + width: `${ITEM_WIDTH_PX}px`, + '& .sidebarItemIconWrap': { + transition: 'transform 150ms ease, color 180ms ease, opacity 180ms ease', + }, + '& .sidebarItemLabel': { + transition: 'color 180ms ease, opacity 180ms ease', + }, + ...((onClick || isInfo) && { + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.18)}, inset 0 1px 0 ${alpha(theme.palette.common.white, theme.palette.mode === 'dark' ? 0.03 : 0.12)}`, + '& .sidebarItemIconWrap': { + transform: 'translateY(-1px)', + }, + }, + '&:active': { + transform: 'translateY(0)', + }, + }), + '&:focus-visible': { + backgroundColor: alpha( + theme.palette.action.hover, + theme.palette.mode === 'dark' ? 0.72 : 0.82 + ), + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.22)}, inset 0 1px 0 ${alpha(theme.palette.common.white, theme.palette.mode === 'dark' ? 0.03 : 0.12)}`, + color: theme.palette.text.primary, + '& .sidebarItemIconWrap': { + transform: 'translateY(-1px)', + }, + }, + ...(active && { + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.primary.light, 0.14)}, inset 0 1px 0 ${alpha(theme.palette.common.white, theme.palette.mode === 'dark' ? 0.02 : 0.1)}`, + }), + } as const; + + if (!onClick) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; export const DesktopSideBar = ({ goToHome, - setDesktopSideView, - toggleSideViewDirects, hasUnreadDirects, - isDirects, - toggleSideViewGroups, - isGroups, isApps, setDesktopViewMode, desktopViewMode, - lastQappViewMode, - mode, - setMode, -}) => { + setAppsModeDev, +}: DesktopSideBarProps) => { + const isEnabledDevMode = useAtomValue(enabledDevModeAtom); + const setIsNewTabWindow = useSetAtom(isNewTabWindowAtom); const hasUnreadGroups = useAtomValue(hasUnreadGroupsAtom); - const [isEnabledDevMode, setIsEnabledDevMode] = useAtom(enabledDevModeAtom); const theme = useTheme(); - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); + const { t } = useTranslation(['core']); + const [isVisible, setIsVisible] = useState(false); + const [selectedAppsTab, setSelectedAppsTab] = useState(null); + const [isInfoActive, setIsInfoActive] = useState(false); + const openTimerRef = useRef(null); + const closeTimerRef = useRef(null); + const hasUnreadChat = hasUnreadDirects || hasUnreadGroups; + const effectiveUnreadChat = hasUnreadChat; + const isQChatActive = + desktopViewMode === 'apps' && + selectedAppsTab?.internal === QCHAT_INTERNAL_TAB_ID; + + const unreadAccent = useMemo( + () => + theme.palette.mode === 'dark' + ? 'rgba(255, 110, 140, 0.9)' + : 'rgba(235, 95, 125, 0.92)', + [theme.palette.mode] + ); + + const sidebarSurfaceColor = + theme.palette.mode === 'dark' + ? 'rgb(36, 39, 45)' + : theme.palette.background.paper; + const sidebarSurfaceShadow = + theme.palette.mode === 'dark' + ? '8px 0 18px rgba(0, 0, 0, 0.12)' + : '6px 0 14px rgba(0,0,0,0.04)'; + + useEffect(() => { + const handleTabsToNav = (e: CustomEvent) => { + setSelectedAppsTab(e.detail?.data?.selectedTab || null); + }; + + subscribeToEvent('setTabsToNav', handleTabsToNav); + return () => { + unsubscribeFromEvent('setTabsToNav', handleTabsToNav); + }; + }, []); + + const emitOverlayState = (nextVisible: boolean) => { + executeEvent('sidebarOverlayVisibility', { + data: { + isVisible: nextVisible, + width: nextVisible ? SIDEBAR_WIDTH_PX : 0, + }, + }); + }; + + const clearHoverTimers = () => { + if (openTimerRef.current !== null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + + const showSidebar = () => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + if (isVisible || openTimerRef.current !== null) return; + openTimerRef.current = window.setTimeout(() => { + openTimerRef.current = null; + setIsVisible((prev) => { + if (!prev) { + emitOverlayState(true); + } + return true; + }); + }, SIDEBAR_OPEN_DELAY_MS); + }; + + const showSidebarImmediate = () => { + clearHoverTimers(); + setIsVisible((prev) => { + if (!prev) { + emitOverlayState(true); + } + return true; + }); + }; + + const hideSidebar = () => { + if (openTimerRef.current !== null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + } + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + setIsVisible((prev) => { + if (prev) { + emitOverlayState(false); + } + return false; + }); + }, SIDEBAR_CLOSE_DELAY_MS); + }; + + const runSidebarAction = (fn: () => void) => { + fn(); + hideSidebar(); + }; + + useEffect(() => { + emitOverlayState(false); + return () => { + clearHoverTimers(); + emitOverlayState(false); + }; + }, []); return ( - - + - - + /> - { - goToHome(); - }} - > - - - - { - executeEvent('newTabWindow', {}); - if (!isApps) { - setDesktopViewMode('apps'); - } + position: 'fixed', + left: 0, + top: '50%', + transform: isVisible + ? 'translateY(-50%) translateX(-4px)' + : 'translateY(-50%) translateX(0)', + width: `${TRIGGER_WIDTH_PX}px`, + height: `${TRIGGER_HEIGHT_PX}px`, + borderRadius: '0 10px 10px 0', + background: + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, 0.14)' + : 'rgba(17, 24, 39, 0.12)', + boxShadow: + theme.palette.mode === 'dark' + ? '0 0 0 1px rgba(255,255,255,0.08)' + : '0 0 0 1px rgba(17,24,39,0.08)', + opacity: isVisible ? 0 : 1, + pointerEvents: isVisible ? 'none' : 'auto', + transition: isVisible + ? 'opacity 100ms ease, transform 100ms ease, background 200ms ease, box-shadow 200ms ease' + : 'opacity 120ms ease 110ms, transform 120ms ease 110ms, background 200ms ease, box-shadow 200ms ease', + zIndex: 9997, + '&::after': + effectiveUnreadChat && !isVisible + ? { + content: '""', + position: 'absolute', + top: '50%', + right: 2, + transform: 'translateY(-50%)', + width: 6, + height: 6, + borderRadius: '50%', + background: unreadAccent, + boxShadow: `0 0 0 2px ${alpha(unreadAccent, 0.14)}`, + animation: `${pulse} 1.2s ease-out 2`, + } + : undefined, }} - > - - - - - - + + { - setDesktopViewMode('chat'); + position: 'fixed', + left: 0, + top: 0, + bottom: 0, + width: `${SIDEBAR_WIDTH_PX}px`, + backgroundColor: sidebarSurfaceColor, + borderRight: `1px solid ${theme.palette.border.subtle}`, + boxShadow: sidebarSurfaceShadow, + opacity: isVisible ? 1 : 0, + transform: isVisible ? 'translateX(0)' : 'translateX(-100%)', + pointerEvents: 'none', + transition: `transform ${OVERLAY_TRANSITION}, opacity ${OVERLAY_TRANSITION}, box-shadow 200ms ease`, + overflow: isVisible ? 'visible' : 'hidden', + zIndex: 9998, }} > - - - - - - {isEnabledDevMode && ( - { - desktopViewMode === 'dev' - ? executeEvent('devModeNewTabWindow', {}) - : setDesktopViewMode('dev'); + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: `${SIDEBAR_WIDTH_PX}px`, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + overflow: 'visible', + pointerEvents: isVisible ? 'auto' : 'none', + '& .sidebarItem:hover .sidebarInfoLogo, & .sidebarItem:focus-visible .sidebarInfoLogo, & .sidebarItem.isOpen .sidebarInfoLogo': + { + filter: 'grayscale(0) saturate(1) brightness(1) contrast(1)', + opacity: 1, + }, }} > - - - - - )} + + runSidebarAction(goToHome)} + > + + + + - {lastQappViewMode === 'dev' ? ( - - ) : null} + + runSidebarAction(() => { + executeEvent('newTabWindow', {}); + setDesktopViewMode('apps'); + }) + } + > + + - - - - + + runSidebarAction(() => openQChatTab())} + > + + + + {effectiveUnreadChat ? ( + + ) : null} + + + {isEnabledDevMode ? ( + + runSidebarAction(() => { + setDesktopViewMode('dev'); + setAppsModeDev('home'); + setIsNewTabWindow(false); + }) + } + > + + + ) : null} + + + - - + + setIsInfoActive(true)} + onMouseLeave={() => setIsInfoActive(false)} + onFocus={() => setIsInfoActive(true)} + onBlur={() => setIsInfoActive(false)} + > + + + } + /> + + + + + + - + ); }; diff --git a/src/components/Desktop/GlobalQortalNavBar.tsx b/src/components/Desktop/GlobalQortalNavBar.tsx index c63b550a..d465fa81 100644 --- a/src/components/Desktop/GlobalQortalNavBar.tsx +++ b/src/components/Desktop/GlobalQortalNavBar.tsx @@ -1,34 +1,81 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, ButtonBase, InputBase, useTheme } from '@mui/material'; +import { Box, ButtonBase, IconButton, InputBase, Tooltip, useTheme } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import ArrowOutwardIcon from '@mui/icons-material/ArrowOutward'; import RefreshIcon from '@mui/icons-material/Refresh'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; import ArrowBackIosNewRoundedIcon from '@mui/icons-material/ArrowBackIosNewRounded'; -import { useAtomValue } from 'jotai'; +import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; +import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; import { extractComponents } from '../Chat/MessageDisplay'; -import { navigationControllerAtom } from '../../atoms/global'; +import { + infoSnackGlobalAtom, + navigationControllerAtom, + openSnackGlobalAtom, + txListAtom, + userInfoAtom, +} from '../../atoms/global'; import { executeEvent, subscribeToEvent, unsubscribeFromEvent, } from '../../utils/events'; import { QORTAL_PROTOCOL } from '../../constants/constants'; -import { APP_NAV_BAR_HEIGHT } from './CustomTitleBar'; +import { APP_NAV_BAR_HEIGHT, type CustomTitleBarRightNavProps } from './CustomTitleBar'; +import { QMailStatus } from '../QMailStatus'; +import { GeneralNotifications } from '../GeneralNotifications'; +import { TaskManager } from '../TaskManager/TaskManager'; +import { GlobalActions } from '../GlobalActions/GlobalActions'; +import { ChatWidgetReopenIcon } from '../Profile/ChatWidgetReopenIcon'; +import { SubscriptionsStatus } from './SubscriptionsStatus'; +import { AppBookmarksButton } from '../Apps/AppBookmarks'; type GlobalQortalNavBarProps = { desktopViewMode: string; + utilityNav?: CustomTitleBarRightNavProps | null; }; +/** Hub-owned surfaces in the app tab strip (e.g. Q-Chat), not arbitrary Q-Apps */ +const INTERNAL_TAB_SERVICE = 'INTERNAL'; + type SelectedTab = { tabId: string; name: string; service: string; identifier?: string; path?: string; + internal?: string; refreshFunc?: (tabId?: string) => void; } | null; +const QAppsNavIcon = ({ color = 'currentColor' }: { color?: string }) => ( + - + ); } diff --git a/src/components/Desktop/SubscriptionsStatus.tsx b/src/components/Desktop/SubscriptionsStatus.tsx new file mode 100644 index 00000000..eefa889d --- /dev/null +++ b/src/components/Desktop/SubscriptionsStatus.tsx @@ -0,0 +1,685 @@ +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import ListAltRoundedIcon from '@mui/icons-material/ListAltRounded'; +import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; +import { + Box, + ButtonBase, + CircularProgress, + Divider, + Popover, + Tooltip, + Typography, + alpha, + useTheme, +} from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + managedSubscriptionsAtom, + managedSubscriptionsLoadingAtom, + mySubscriptionsAtom, + subscriptionsLoadingAtom, +} from '../../atoms/global'; +import { useInitializeMySubscriptions } from '../../subscriptions/useInitializeMySubscriptions'; +import { executeEvent } from '../../utils/events'; + +type SubscriptionsStatusProps = { + buttonSx?: any; + compact?: boolean; + iconSx?: any; + tooltipPlacement?: 'bottom' | 'left' | 'right' | 'top'; +}; + +export function SubscriptionsStatus({ + buttonSx, + compact = false, + iconSx, + tooltipPlacement = 'bottom', +}: SubscriptionsStatusProps) { + useInitializeMySubscriptions(); + + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + const { t } = useTranslation(['group']); + + const formatTimeUntil = useCallback( + (timestamp: number | null | undefined) => { + if (!timestamp) return ''; + const diff = timestamp - Date.now(); + if (diff <= 0) return t('group:subscription.relative_soon'); + const minutes = Math.floor(diff / 60_000); + if (minutes < 60) { + return t('group:subscription.relative_in_minutes', { count: minutes }); + } + const hours = Math.floor(diff / 3_600_000); + if (hours < 24) { + return t('group:subscription.relative_in_hours', { count: hours }); + } + const days = Math.floor(diff / 86_400_000); + if (days < 30) { + return t('group:subscription.relative_in_days', { count: days }); + } + const months = Math.floor(days / 30); + return t('group:subscription.relative_in_months', { count: months }); + }, + [t] + ); + + const openSubscriptionTab = useCallback( + (path?: string) => { + executeEvent('addTab', { + data: { + name: t('group:subscription.tab_subscriptions'), + navigateIfAlreadyOpen: true, + path, + service: 'APP', + }, + }); + executeEvent('open-apps-mode', {}); + }, + [t] + ); + + const openSubWireTab = useCallback(() => { + executeEvent('addTab', { + data: { + name: t('group:subscription.tab_subwire'), + navigateIfAlreadyOpen: true, + service: 'APP', + }, + }); + executeEvent('open-apps-mode', {}); + }, [t]); + + const [anchorEl, setAnchorEl] = useState(null); + const mySubscriptions = useAtomValue(mySubscriptionsAtom); + const managedSubscriptions = useAtomValue(managedSubscriptionsAtom); + const subscriptionsLoading = useAtomValue(subscriptionsLoadingAtom); + const managedLoading = useAtomValue(managedSubscriptionsLoadingAtom); + const isLoading = subscriptionsLoading || managedLoading; + + const paymentNeededSubscriptions = useMemo( + () => + (mySubscriptions ?? []).filter( + (subscription: any) => subscription?.status === 'payment-needed' + ), + [mySubscriptions] + ); + const activeSubscriptions = useMemo( + () => + (mySubscriptions ?? []).filter( + (subscription: any) => subscription?.status === 'active' + ), + [mySubscriptions] + ); + const managedActionSubscriptions = useMemo( + () => + (managedSubscriptions ?? []).filter( + (entry: any) => (entry?.actions?.totalActions ?? 0) > 0 + ), + [managedSubscriptions] + ); + const managedQuietSubscriptions = useMemo( + () => + (managedSubscriptions ?? []).filter( + (entry: any) => (entry?.actions?.totalActions ?? 0) === 0 + ), + [managedSubscriptions] + ); + + const totalActions = + paymentNeededSubscriptions.length + + managedActionSubscriptions.reduce( + (sum: number, entry: any) => sum + (entry?.actions?.totalActions ?? 0), + 0 + ); + const hasContent = + paymentNeededSubscriptions.length > 0 || + activeSubscriptions.length > 0 || + managedActionSubscriptions.length > 0 || + managedQuietSubscriptions.length > 0; + + const subscriptionsTitle = t('group:subscription.subscriptions', { + postProcess: 'capitalizeFirstChar', + }); + const tooltipLabel = + totalActions > 0 + ? t('group:subscription.tooltip_actions_needed', { + count: totalActions, + title: subscriptionsTitle, + }) + : subscriptionsTitle; + + const panelBorderColor = isDarkMode + ? alpha('#A9BCD8', 0.18) + : theme.palette.divider; + const itemBorderColor = isDarkMode + ? alpha('#A9BCD8', 0.13) + : alpha(theme.palette.divider, 0.92); + const itemBackground = isDarkMode + ? alpha('#FFFFFF', 0.032) + : theme.palette.action.hover; + const itemHoverBackground = isDarkMode + ? alpha('#FFFFFF', 0.055) + : alpha(theme.palette.primary.main, 0.07); + const itemHoverBorderColor = isDarkMode + ? alpha('#A9BCD8', 0.22) + : alpha(theme.palette.divider, 1); + + const panelLinkColor = isDarkMode + ? theme.palette.primary.light + : theme.palette.primary.main; + const panelLinkHoverColor = isDarkMode + ? theme.palette.primary.main + : theme.palette.primary.dark; + + const headerIconColor = isDarkMode + ? theme.palette.primary.light + : theme.palette.primary.main; + + const sectionTitleSx = { + color: theme.palette.text.primary, + fontSize: '0.78rem', + fontWeight: 700, + letterSpacing: '0.01em', + } as const; + + const renderSubscriptionRow = (subscription: any, tone: 'active' | 'due') => { + const statusColor = + tone === 'due' ? theme.palette.warning.main : theme.palette.success.main; + const dueText = formatTimeUntil(subscription?.nextPaymentDue); + + return ( + { + setAnchorEl(null); + openSubscriptionTab(subscription?.link); + }} + sx={{ + alignItems: 'center', + backgroundColor: itemBackground, + border: `1px solid ${itemBorderColor}`, + borderLeft: `3px solid ${alpha(statusColor, 0.86)}`, + borderRadius: '12px', + display: 'flex', + gap: 1.25, + p: 1.35, + textAlign: 'left', + width: '100%', + '&:hover': { + backgroundColor: itemHoverBackground, + borderColor: itemHoverBorderColor, + }, + }} + > + + + {subscription?.title || + t('group:subscription.row_fallback_title', { + postProcess: 'capitalizeFirstChar', + })} + + + {subscription?.ownerName + ? t('group:subscription.by_creator', { + name: subscription.ownerName, + }) + : t('group:subscription.creator_fallback')} + + + {t('group:subscription.price_line', { + price: subscription?.priceQort, + interval: subscription?.billingInterval, + })} + {tone === 'active' && dueText + ? t('group:subscription.expires_suffix', { time: dueText }) + : ''} + + + + + {tone === 'due' + ? t('group:subscription.status_due') + : t('group:subscription.status_active')} + + + ); + }; + + const renderManagedRow = (entry: any, hasAction: boolean) => { + const accentColor = hasAction + ? theme.palette.warning.main + : theme.palette.info.main; + const actionCount = entry?.actions?.totalActions ?? 0; + const pendingJoinRequests = entry?.actions?.pendingJoinRequests ?? 0; + const needsReEncryption = !!entry?.actions?.needsReEncryption; + + const badgeBoxSx = { + alignItems: 'center', + color: accentColor, + display: 'flex', + flexShrink: 0, + fontSize: '0.68rem', + fontWeight: 800, + gap: 0.55, + letterSpacing: '0.04em', + textTransform: 'uppercase', + } as const; + + return ( + { + setAnchorEl(null); + openSubscriptionTab(entry?.url); + }} + sx={{ + alignItems: 'center', + backgroundColor: itemBackground, + border: `1px solid ${itemBorderColor}`, + borderLeft: `3px solid ${alpha(accentColor, 0.86)}`, + borderRadius: '12px', + display: 'flex', + gap: 1.25, + p: 1.35, + textAlign: 'left', + width: '100%', + '&:hover': { + backgroundColor: itemHoverBackground, + borderColor: itemHoverBorderColor, + }, + }} + > + + + {entry?.group?.groupName || + t('group:subscription.managed_fallback', { + postProcess: 'capitalizeFirstChar', + })} + + {entry?.group?.description ? ( + + {entry.group.description} + + ) : null} + + {t('group:subscription.members', { + count: entry?.group?.memberCount ?? 0, + })} + {pendingJoinRequests > 0 + ? t('group:subscription.join_requests_suffix', { + count: pendingJoinRequests, + }) + : ''} + {needsReEncryption + ? t('group:subscription.re_encryption_suffix') + : ''} + + + + + {hasAction + ? t('group:subscription.actions_badge', { count: actionCount }) + : t('group:subscription.status_owner')} + + + ); + }; + + return ( + <> + { + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }} + sx={{ + position: 'relative', + ...(compact && { + alignItems: 'center', + borderRadius: 1, + display: 'flex', + height: 32, + justifyContent: 'center', + width: 32, + }), + ...(buttonSx || {}), + }} + > + + {tooltipLabel} + + } + slotProps={{ + arrow: { sx: { color: theme.palette.background.paper } }, + tooltip: { + sx: { + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + }, + }, + }} + > + 0 + ? theme.palette.warning.main + : theme.palette.text.secondary, + fontSize: compact ? 17 : 19, + ...(iconSx || {}), + }} + /> + + {totalActions > 0 ? ( + + {totalActions > 99 ? '99+' : totalActions} + + ) : null} + + + setAnchorEl(null)} + open={!!anchorEl} + slotProps={{ + paper: { + sx: isDarkMode + ? { + background: '#111820', + backgroundImage: 'none', + border: `1px solid ${panelBorderColor}`, + borderRadius: '16px', + boxShadow: `0 22px 46px ${alpha('#000', 0.44)}`, + mt: 1, + overflow: 'hidden', + } + : { + background: theme.palette.background.paper, + backgroundImage: 'none', + border: `1px solid ${theme.palette.divider}`, + borderRadius: '16px', + boxShadow: `0 16px 40px ${alpha('#1E3248', 0.1)}`, + mt: 1, + overflow: 'hidden', + }, + }, + }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + > + + + + + {subscriptionsTitle} + + + + {isLoading && !hasContent ? ( + + + + {t('group:subscription.loading', { + postProcess: 'capitalizeFirstChar', + })} + + + ) : hasContent ? ( + <> + {(paymentNeededSubscriptions.length > 0 || + managedActionSubscriptions.length > 0) && ( + + + {t('group:subscription.section_needs_action', { + postProcess: 'capitalizeFirstChar', + })} + + {paymentNeededSubscriptions.map((subscription: any) => + renderSubscriptionRow(subscription, 'due') + )} + {managedActionSubscriptions.map((entry: any) => + renderManagedRow(entry, true) + )} + + )} + + {(activeSubscriptions.length > 0 || + managedQuietSubscriptions.length > 0) && ( + + + {t('group:subscription.section_active', { + postProcess: 'capitalizeFirstChar', + })} + + {activeSubscriptions.map((subscription: any) => + renderSubscriptionRow(subscription, 'active') + )} + {managedQuietSubscriptions.map((entry: any) => + renderManagedRow(entry, false) + )} + + )} + + + { + setAnchorEl(null); + openSubscriptionTab(); + }} + sx={{ + alignItems: 'center', + alignSelf: 'flex-start', + color: panelLinkColor, + display: 'inline-flex', + fontSize: '0.78rem', + fontWeight: 700, + gap: 0.45, + px: 0.2, + py: 0.35, + '&:hover': { color: panelLinkHoverColor }, + }} + > + {t('group:subscription.open_subscriptions', { + postProcess: 'capitalizeFirstChar', + })} + + + + ) : ( + + + {t('group:subscription.empty_title')} + + + {t('group:subscription.empty_body')} + + { + setAnchorEl(null); + openSubWireTab(); + }} + sx={{ + alignItems: 'center', + color: panelLinkColor, + display: 'inline-flex', + fontSize: '0.78rem', + fontWeight: 700, + gap: 0.25, + mt: 1.35, + '&:hover': { color: panelLinkHoverColor }, + }} + > + {t('group:subscription.open_subwire', { + postProcess: 'capitalizeFirstChar', + })} + + + + )} + + + + ); +} diff --git a/src/components/Embeds/embed-utils.ts b/src/components/Embeds/embed-utils.ts index 46811197..b9ca54ee 100644 --- a/src/components/Embeds/embed-utils.ts +++ b/src/components/Embeds/embed-utils.ts @@ -1,9 +1,9 @@ +import { decodeHTML } from 'entities'; + import { QORTAL_PROTOCOL } from '../../constants/constants'; -function decodeHTMLEntities(str: string) { - const txt = document.createElement('textarea'); - txt.innerHTML = str; - return txt.value; +function decodeHTMLEntities(str: string): string { + return decodeHTML(str); } export const parseQortalLink = (link: string) => { diff --git a/src/components/GeneralNotifications.tsx b/src/components/GeneralNotifications.tsx index a1251187..9aa13c1f 100644 --- a/src/components/GeneralNotifications.tsx +++ b/src/components/GeneralNotifications.tsx @@ -1,191 +1,737 @@ -import { useState } from 'react'; +import AppsIcon from '@mui/icons-material/Apps'; +import CloseIcon from '@mui/icons-material/Close'; +import MailOutlineIcon from '@mui/icons-material/MailOutline'; +import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; +import NotificationsRoundedIcon from '@mui/icons-material/NotificationsRounded'; +import SettingsIcon from '@mui/icons-material/Settings'; import { + Avatar, Box, ButtonBase, Card, + Dialog, + DialogContent, + DialogTitle, + IconButton, + List, MenuItem, Popover, + Switch, Tooltip, Typography, + alpha, useTheme, } from '@mui/material'; -import NotificationsIcon from '@mui/icons-material/Notifications'; -import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; -import { formatDate } from '../utils/time'; -import { useHandlePaymentNotification } from '../hooks/useHandlePaymentNotification'; -import { executeEvent } from '../utils/events'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { getBaseApiReact } from '../App'; +import { + customWebsocketSubscriptionsAtom, + isNotificationSeenInAppFromKeyTimes, + lastPaymentSeenTimestampAtom, + notificationSeenInAppKeyTimesAtom, + notificationSeenInAppKeysAtom, + paymentNotificationsAtom, +} from '../atoms/global'; +import LogoSelected from '../assets/svgs/LogoSelected.svg'; +import { + getAppsWithNotificationPermission, + getNotificationOsPushDisabledMap, + getNotificationPermissionKey, + setNotificationOsPushDisabled, + setPermission, +} from '../qortal/qortal-requests'; +import { extractComponents } from './Chat/MessageDisplay'; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from '../utils/events'; +import { formatDate } from '../utils/time'; -export const GeneralNotifications = ({ address, tooltipPlacement = 'left' }) => { - const [anchorEl, setAnchorEl] = useState(null); +const RESOURCE_EVENT = 'RESOURCE_PUBLISHED'; - const { - latestTx, - getNameOrAddressOfSenderMiddle, - hasNewPayment, - setLastEnteredTimestampPayment, - nameAddressOfSender, - } = useHandlePaymentNotification(address); +function toTimestampMs(value) { + if (value == null || typeof value !== 'number') return null; + return value < 1e12 ? value * 1000 : value; +} - const handlePopupClick = (event) => { - event.stopPropagation(); // Prevent parent onClick from firing - setAnchorEl(event.currentTarget); - }; +function getNotificationTimestamp(notification) { + return toTimestampMs( + notification?.data?.created ?? + notification?.data?.timestamp ?? + notification?.timestamp + ); +} - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); +function getNotificationMessage(messageObj, currentLang, fallback) { + if (!messageObj || typeof messageObj !== 'object') return fallback; + const lang = (currentLang || 'en').split('-')[0]; + return ( + messageObj[lang]?.trim() || + messageObj.en?.trim() || + Object.values(messageObj).find((value: any) => value?.trim()) || + fallback + ); +} +export const GeneralNotifications = ({ + tooltipPlacement = 'left', + compact = false, + buttonSx = undefined, + iconSx = undefined, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsApps, setSettingsApps] = useState([]); + const [settingsLoading, setSettingsLoading] = useState(false); + const [osPushDisabledMap, setOsPushDisabledMap] = useState< + Record + >({}); + const notifications = useAtomValue(paymentNotificationsAtom); + const customSubscriptions = useAtomValue(customWebsocketSubscriptionsAtom); + const setCustomSubscriptions = useSetAtom(customWebsocketSubscriptionsAtom); + const lastSeenTimestamp = useAtomValue(lastPaymentSeenTimestampAtom); + const setLastSeenTimestamp = useSetAtom(lastPaymentSeenTimestampAtom); + const seenInAppKeyTimes = useAtomValue(notificationSeenInAppKeyTimesAtom); + const setSeenKeys = useSetAtom(notificationSeenInAppKeysAtom); const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + const { t, i18n } = useTranslation(['core']); + + useEffect(() => { + const handler = (event) => { + const detail = event.detail; + if (detail?.address && Array.isArray(detail?.keys)) { + setSeenKeys({ address: detail.address, keys: detail.keys }); + } + }; + subscribeToEvent('notification-seen-in-app-updated', handler); + return () => + unsubscribeFromEvent('notification-seen-in-app-updated', handler); + }, [setSeenKeys]); + + const resourceNotifications = useMemo( + () => + (notifications ?? []).filter((item) => item?.event === RESOURCE_EVENT), + [notifications] + ); + const unseenCount = useMemo(() => { + return resourceNotifications.filter((notification) => { + const timestamp = getNotificationTimestamp(notification); + if (timestamp == null) return false; + if (isNotificationSeenInAppFromKeyTimes(notification, seenInAppKeyTimes)) + return false; + return !lastSeenTimestamp || timestamp > lastSeenTimestamp; + }).length; + }, [resourceNotifications, seenInAppKeyTimes, lastSeenTimestamp]); + + const hasNewNotifications = unseenCount > 0; + const NotificationIcon = hasNewNotifications + ? NotificationsActiveRoundedIcon + : NotificationsRoundedIcon; + + const openSettings = () => { + setSettingsOpen(true); + setSettingsLoading(true); + Promise.all([ + getAppsWithNotificationPermission(), + getNotificationOsPushDisabledMap(), + ]) + .then(([apps, disabledMap]) => { + setSettingsApps(apps); + setOsPushDisabledMap(disabledMap || {}); + }) + .finally(() => setSettingsLoading(false)); + }; return ( <> { - handlePopupClick(e); + aria-label="Notifications" + onClick={(event) => { + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }} + sx={{ + position: 'relative', + ...(buttonSx || {}), }} - style={{}} > - {t('core:payment_notification')} + {t('message.generic.notifications', { + defaultValue: 'Notifications', + })} } - placement={tooltipPlacement} - arrow - sx={{ fontSize: '24' }} slotProps={{ + arrow: { sx: { color: theme.palette.background.paper } }, tooltip: { sx: { - color: theme.palette.text.primary, backgroundColor: theme.palette.background.paper, - }, - }, - arrow: { - sx: { color: theme.palette.text.primary, }, }, }} > - + {hasNewNotifications && ( + + {unseenCount > 99 ? '99+' : unseenCount} + + )} { - if (hasNewPayment) { - setLastEnteredTimestampPayment(Date.now()); - } + if (hasNewNotifications) setLastSeenTimestamp(Date.now()); setAnchorEl(null); - }} // Close popover on click outside + }} + open={!!anchorEl} + slotProps={{ + paper: { + sx: isDarkMode + ? { + background: '#111820', + backgroundImage: 'none', + border: `1px solid ${alpha('#A9BCD8', 0.18)}`, + borderRadius: '16px', + boxShadow: `0 22px 46px ${alpha('#000', 0.44)}`, + mt: 1, + overflow: 'hidden', + } + : { + background: theme.palette.background.paper, + backgroundImage: 'none', + border: `1px solid ${theme.palette.divider}`, + borderRadius: '16px', + boxShadow: `0 16px 40px ${alpha('#1E3248', 0.1)}`, + mt: 1, + overflow: 'hidden', + }, + }, + }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} > - {!hasNewPayment && ( - - {t('core:message.generic.no_notifications')} - + { + event.stopPropagation(); + openSettings(); + }} + onMouseDown={(event) => event.stopPropagation()} + size="small" + sx={{ + color: theme.palette.text.secondary, + pointerEvents: 'auto', + position: 'absolute', + right: 4, + top: 4, + zIndex: 2, + }} + > + + + + {!resourceNotifications.length && ( + <> + + + {t('message.generic.no_app_notifications', { + defaultValue: 'No app notifications yet', + })} + + + {t('message.generic.app_notifications_hint', { + defaultValue: 'Q-App notifications will appear here', + })} + + )} - {hasNewPayment && ( - { - setAnchorEl(null); - executeEvent('openWalletsApp', {}); - }} - > - { + const isQMail = + notification?.notificationId === 'q-mail-notification' || + notification?.appName === 'Q-Mail'; + const timestamp = getNotificationTimestamp(notification); + const unseen = + timestamp != null && + (!lastSeenTimestamp || timestamp > lastSeenTimestamp) && + !isNotificationSeenInAppFromKeyTimes( + notification, + seenInAppKeyTimes + ); + + return ( + { + if (hasNewNotifications) setLastSeenTimestamp(Date.now()); + setAnchorEl(null); + const link = notification?.link; + if (!link) return; + const data = extractComponents(link); + if (!data) return; + executeEvent('addTab', { + data: { ...data, navigateIfAlreadyOpen: true }, + }); + executeEvent('open-apps-mode', {}); + }} sx={{ - backgroundColor: '#1F2023', - display: 'flex', - flexDirection: 'column', - gap: '5px', - padding: '10px', - width: '100%', + borderRadius: '12px', + display: 'block', + p: 0, + whiteSpace: 'normal', + '&:hover': { + bgcolor: isDarkMode + ? alpha('#FFFFFF', 0.045) + : alpha(theme.palette.primary.main, 0.06), + }, }} > - - - {formatDate(latestTx?.timestamp)} - + > + {isQMail ? ( + + ) : ( + app-icon + )} + + + + + {notification?.appName || 'Q-App'} + + {timestamp && ( + + {formatDate(timestamp)} + + )} + + + {getNotificationMessage( + notification?.message, + i18n.language, + t('message.generic.new_notification', { + defaultValue: 'New notification', + }) + )} + + + + + ); + })} + + + setSettingsOpen(false)} + open={settingsOpen} + PaperProps={{ + sx: isDarkMode + ? { + background: '#121821', + backgroundImage: 'none', + border: `1px solid ${alpha('#A9BCD8', 0.18)}`, + borderRadius: '18px', + boxShadow: `0 26px 56px ${alpha('#000', 0.46)}`, + overflow: 'hidden', + } + : { + background: theme.palette.background.paper, + backgroundImage: 'none', + border: `1px solid ${theme.palette.divider}`, + borderRadius: '18px', + boxShadow: `0 20px 48px ${alpha('#1E3248', 0.12)}`, + overflow: 'hidden', + }, + }} + > + + {t('message.generic.notification_settings', { + defaultValue: 'Notification settings', + })} + setSettingsOpen(false)} + size="small" + sx={{ + color: alpha(theme.palette.text.secondary, 0.92), + '&:hover': { + backgroundColor: isDarkMode + ? alpha('#FFFFFF', 0.05) + : alpha(theme.palette.action.active, 0.06), + color: theme.palette.text.primary, + }, + }} + > + + + + + + {t('message.generic.notification_settings_desc', { + defaultValue: + 'Choose which apps can send desktop alerts while keeping in-Hub activity visible.', + })} + + {settingsLoading ? ( + + {t('message.generic.loading', { defaultValue: 'Loading...' })} + + ) : settingsApps.length === 0 ? ( + + {t('message.generic.no_notification_apps', { + defaultValue: 'No apps have notification permission yet.', + })} + + ) : ( + + {settingsApps.map((appName) => ( - {latestTx?.amount} + + + + + + + {appName} + + + {t('message.generic.disable_os_push_desc', { + defaultValue: + 'Mute desktop alerts for this app while keeping in-Hub activity visible.', + })} + + + + + + {t('message.generic.disable_os_push', { + defaultValue: 'Disable OS push', + })} + + { + await setNotificationOsPushDisabled(appName, checked); + setOsPushDisabledMap((prev) => ({ + ...prev, + [appName]: checked, + })); + }} + size="small" + /> + { + const notificationIds = (customSubscriptions ?? []) + .filter( + (sub) => + sub?.event === RESOURCE_EVENT && + sub?.appName === appName + ) + .map((sub) => sub?.notificationId) + .filter(Boolean); + await setPermission( + getNotificationPermissionKey(appName), + false + ); + setCustomSubscriptions((prev) => + (prev ?? []).filter( + (sub) => + !( + sub?.event === RESOURCE_EVENT && + sub?.appName === appName + ) + ) + ); + if (notificationIds.length) { + executeEvent( + 'custom-ws-unsubscribe', + notificationIds + ); + } + executeEvent( + 'notifications-websocket-reconnect', + undefined + ); + setSettingsApps((prev) => + prev.filter((name) => name !== appName) + ); + }} + sx={{ + borderRadius: '10px', + color: theme.palette.error.light, + fontSize: '0.8rem', + fontWeight: 600, + px: 1.1, + py: 0.6, + '&:hover': { + backgroundColor: alpha( + theme.palette.error.main, + 0.08 + ), + }, + }} + > + {t('message.generic.revoke_permission', { + defaultValue: 'Revoke', + })} + + - - - {nameAddressOfSender.current[latestTx?.creatorAddress] || - getNameOrAddressOfSenderMiddle(latestTx?.creatorAddress)} - - - + ))} + )} - - + + ); }; diff --git a/src/components/Group/AddGroup.tsx b/src/components/Group/AddGroup.tsx index 2aa59747..ccee85a6 100644 --- a/src/components/Group/AddGroup.tsx +++ b/src/components/Group/AddGroup.tsx @@ -37,7 +37,7 @@ import { useSetAtom } from 'jotai'; import { txListAtom } from '../../atoms/global'; import { TransitionUp } from '../../common/Transitions.tsx'; -export const AddGroup = ({ address, open, setOpen }) => { +export const AddGroup = ({ address, open, setOpen, initialTab = 0 }) => { const { show } = useContext(QORTAL_APP_CONTEXT); const setTxList = useSetAtom(txListAtom); @@ -48,7 +48,7 @@ export const AddGroup = ({ address, open, setOpen }) => { const [approvalThreshold, setApprovalThreshold] = useState('40'); const [minBlock, setMinBlock] = useState('5'); const [maxBlock, setMaxBlock] = useState('21600'); - const [value, setValue] = useState(0); + const [value, setValue] = useState(initialTab); const [openSnack, setOpenSnack] = useState(false); const [infoSnack, setInfoSnack] = useState(null); @@ -187,14 +187,29 @@ export const AddGroup = ({ address, open, setOpen }) => { setValue(2); }; + const openFindGroupRequestFunc = () => { + setValue(1); + }; + + useEffect(() => { + if (open) { + setValue(initialTab); + } + }, [initialTab, open]); + useEffect(() => { subscribeToEvent('openGroupInvitesRequest', openGroupInvitesRequestFunc); + subscribeToEvent('openFindGroupRequest', openFindGroupRequestFunc); return () => { unsubscribeFromEvent( 'openGroupInvitesRequest', openGroupInvitesRequestFunc ); + unsubscribeFromEvent( + 'openFindGroupRequest', + openFindGroupRequestFunc + ); }; }, []); diff --git a/src/components/Group/AddGroupList.tsx b/src/components/Group/AddGroupList.tsx index 85a11026..572ffaec 100644 --- a/src/components/Group/AddGroupList.tsx +++ b/src/components/Group/AddGroupList.tsx @@ -622,11 +622,11 @@ export const AddGroupList = ({ setInfoSnack, setOpenSnack }) => { > diff --git a/src/components/Group/BlockedUsersModal.tsx b/src/components/Group/BlockedUsersModal.tsx index cf94bd25..32faa309 100644 --- a/src/components/Group/BlockedUsersModal.tsx +++ b/src/components/Group/BlockedUsersModal.tsx @@ -1,4 +1,5 @@ import { + alpha, Box, Button, Dialog, @@ -6,15 +7,19 @@ import { DialogContent, DialogContentText, DialogTitle, + Divider, IconButton, TextField, Typography, useTheme, } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getBaseApiReact } from '../../App'; -import { Spacer } from '../../common/Spacer'; -import CloseIcon from '@mui/icons-material/Close'; + +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import InfoIcon from '@mui/icons-material/Info'; +import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; import { executeEvent, @@ -26,13 +31,46 @@ import { getNameInfo, requestQueueMemberNames } from './Group'; import { useModal } from '../../hooks/useModal'; import { useBlockedAddresses } from '../../hooks/useBlockUsers'; import { + blockedAddressesAtom, + blockedNamesAtom, infoSnackGlobalAtom, isOpenBlockedModalAtom, openSnackGlobalAtom, } from '../../atoms/global'; -import InfoIcon from '@mui/icons-material/Info'; -import { useAtom, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; +import { + dialogActionsSx, + dialogContentSx, + dialogContentTextSx, + dialogModalBackdropSx, + dialogTitleSx, + getDialogPaperSx, + getDialogPrimaryButtonSx, + getDialogSecondaryButtonSx, +} from '../App/dialogSurface'; + +const blockedListRowSx = (theme: ReturnType) => ({ + alignItems: 'flex-start', + backgroundColor: alpha('#FFFFFF', 0.035), + border: '1px solid rgba(169,188,216,0.11)', + borderRadius: '12px', + display: 'flex', + gap: 1.5, + justifyContent: 'space-between', + px: 1.5, + py: 1.2, +}); + +const unblockButtonSx = (theme: ReturnType) => ({ + ...getDialogSecondaryButtonSx(theme), + flexShrink: 0, + fontSize: '0.8rem', + minHeight: 36, + minWidth: 92, + mt: '-2px', + px: 1.4, +}); export const BlockedUsersModal = () => { const theme = useTheme(); @@ -48,33 +86,30 @@ export const BlockedUsersModal = () => { ); const [hasChanged, setHasChanged] = useState(false); const [value, setValue] = useState(''); - const [addressesWithNames, setAddressesWithNames] = useState({}); + const [addressesWithNames, setAddressesWithNames] = useState< + Record + >({}); const { isShow, onCancel, onOk, show, message } = useModal(); - const { - getAllBlockedUsers, - removeBlockFromList, - addToBlockList, - } = useBlockedAddresses(true); + const { removeBlockFromList, addToBlockList, refreshBlockedUsers } = + useBlockedAddresses(true); + const blockedAddresses = useAtomValue(blockedAddressesAtom); + const blockedNames = useAtomValue(blockedNamesAtom); const setOpenSnackGlobal = useSetAtom(openSnackGlobalAtom); const setInfoSnackCustom = useSetAtom(infoSnackGlobalAtom); - const [blockedUsers, setBlockedUsers] = useState({ - addresses: {}, - names: {}, - }); + const addressKeys = Object.keys(blockedAddresses || {}); + const nameKeys = Object.keys(blockedNames || {}); - const fetchBlockedUsers = () => { - setBlockedUsers(getAllBlockedUsers()); + const handleCloseMain = () => { + if (hasChanged) { + executeEvent('updateChatMessagesWithBlocks', true); + } + setIsOpenBlockedModal(false); }; - useEffect(() => { - if (!isOpenBlockedModal) return; - fetchBlockedUsers(); - }, [isOpenBlockedModal]); - const getNames = async () => { - const addresses = Object.keys(blockedUsers?.addresses); - const addressNames = {}; + const addresses = Object.keys(blockedAddresses || {}); + const addressNames: Record = {}; const getMemNames = addresses.map(async (address) => { const name = await requestQueueMemberNames.enqueue(() => { @@ -122,7 +157,6 @@ export const BlockedUsersModal = () => { } if (!userName) { await addToBlockList(userAddress, null); - fetchBlockedUsers(); setHasChanged(true); executeEvent('updateChatMessagesWithBlocks', true); setValue(''); @@ -139,7 +173,6 @@ export const BlockedUsersModal = () => { } else if (responseModal === 'name') { await addToBlockList(null, userName); } - fetchBlockedUsers(); setHasChanged(true); setValue(''); if (user) { @@ -165,240 +198,412 @@ export const BlockedUsersModal = () => { } }; - const blockUserFromOutsideModalFunc = (e) => { - const user = e.detail?.user; - setIsOpenBlockedModal(true); - blockUser(null, user); - }; + const blockUserRef = useRef(blockUser); + blockUserRef.current = blockUser; useEffect(() => { - subscribeToEvent('blockUserFromOutside', blockUserFromOutsideModalFunc); - + const handler = (e: Event) => { + const user = (e as CustomEvent<{ user?: string }>).detail?.user; + setIsOpenBlockedModal(true); + void blockUserRef.current(null, user); + }; + subscribeToEvent('blockUserFromOutside', handler); return () => { - unsubscribeFromEvent( - 'blockUserFromOutside', - blockUserFromOutsideModalFunc - ); + unsubscribeFromEvent('blockUserFromOutside', handler); }; - }, []); + }, [setIsOpenBlockedModal]); - return ( - - - {t('auth:blocked_users', { postProcess: 'capitalizeAll' })} - + useEffect(() => { + if (!isOpenBlockedModal) return; + + setAddressesWithNames({}); + refreshBlockedUsers().catch((error) => { + console.error('Unable to refresh blocked users.', error); + }); + }, [isOpenBlockedModal, refreshBlockedUsers]); + + const paperSx = { + ...getDialogPaperSx(theme, { maxWidth: 544 }), + maxHeight: 'min(620px, calc(100vh - 48px))', + width: 'calc(100% - 40px)', + }; + + const textFieldSx = { + '& .MuiOutlinedInput-root': { + backgroundColor: alpha('#FFFFFF', 0.04), + borderRadius: '11px', + '& fieldset': { + borderColor: 'rgba(169,188,216,0.16)', + }, + '&:hover fieldset': { + borderColor: 'rgba(169,188,216,0.24)', + }, + '&.Mui-focused fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.55), + }, + }, + }; - + - + + {t('auth:blocked_users', { postProcess: 'capitalizeAll' })} + + { - setValue(e.target.value); - }} - /> - + + - {Object.entries(blockedUsers?.addresses).length > 0 && ( - <> - - - - {t('auth:message.generic.blocked_addresses', { - postProcess: 'capitalizeFirstChar', - })} - - - + + + {t('auth:message.generic.block_list_intro', { + postProcess: 'capitalizeFirstChar', + })} + - + - - - )} - - - {Object.entries(blockedUsers?.addresses || {})?.map( - ([key, value]) => { - return ( - - {addressesWithNames[key] || key} - - - ); - } + {(addressKeys.length > 0 || nameKeys.length > 0) && ( + )} - - - {Object.entries(blockedUsers?.names).length > 0 && ( - <> - - - - {t('auth:message.generic.blocked_names', { - postProcess: 'capitalizeFirstChar', - })} - - - - - )} - - {Object.entries(blockedUsers?.names || {})?.map(([key, value]) => { - return ( - - {key} + 6 ? 280 : 'none', + overflowY: addressKeys.length + nameKeys.length > 6 ? 'auto' : 'visible', + pr: addressKeys.length + nameKeys.length > 6 ? 0.5 : 0, + }} + > + {addressKeys.length > 0 && ( + + + + {t('auth:message.generic.blocked_addresses', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('auth:message.generic.blocked_addresses_hint', { + postProcess: 'capitalizeFirstChar', + })} + + + + {addressKeys.map((key) => ( + + + + {addressesWithNames[key] || key} + + {addressesWithNames[key] && ( + + {key} + + )} + + + + ))} + - ); - })} - - + )} - - - + {addressKeys.length > 0 && nameKeys.length > 0 && ( + + )} + + {nameKeys.length > 0 && ( + + + + {t('auth:message.generic.blocked_names', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('auth:message.generic.blocked_names_hint', { + postProcess: 'capitalizeFirstChar', + })} + + + + {nameKeys.map((key) => ( + + + {key} + + + + ))} + + + )} + + + {addressKeys.length === 0 && nameKeys.length === 0 && ( + + + {t('auth:message.generic.no_blocked_users', { + postProcess: 'capitalizeFirstChar', + })} + + + )} + + + + + + {t('auth:message.generic.decide_block', { @@ -411,17 +616,22 @@ export const BlockedUsersModal = () => { })} onClick={onCancel} sx={{ - bgcolor: theme.palette.background.default, - color: theme.palette.text.primary, + color: theme.palette.text.secondary, position: 'absolute', - right: 8, - top: 8, + right: 10, + top: 14, + '&:hover': { + color: theme.palette.text.primary, + }, }} > - + - - + + {t('auth:message.generic.blocking', { name: message?.userName || message?.userAddress, postProcess: 'capitalizeFirstChar', @@ -430,18 +640,26 @@ export const BlockedUsersModal = () => { - + {t('auth:message.generic.choose_block', { postProcess: 'capitalizeFirstChar', })} @@ -449,8 +667,16 @@ export const BlockedUsersModal = () => { - + - + ); }; diff --git a/src/components/Group/Forum/Mail-styles.ts b/src/components/Group/Forum/Mail-styles.ts index df6d9941..c8dec888 100644 --- a/src/components/Group/Forum/Mail-styles.ts +++ b/src/components/Group/Forum/Mail-styles.ts @@ -623,6 +623,8 @@ export const ThreadSingleLastMessageSpanP = styled('span')(({ theme }) => ({ })); export const GroupContainer = styled(Box)` + height: 100%; + min-height: 0; overflow: auto; position: relative; width: 100%; diff --git a/src/components/Group/Forum/ShowMessageWithoutModal.tsx b/src/components/Group/Forum/ShowMessageWithoutModal.tsx index ecf60e9c..4240c3c8 100644 --- a/src/components/Group/Forum/ShowMessageWithoutModal.tsx +++ b/src/components/Group/Forum/ShowMessageWithoutModal.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { Avatar, Box, IconButton } from '@mui/material'; +import { Avatar, Box, IconButton, useTheme } from '@mui/material'; import DOMPurify from 'dompurify'; +import '../../Chat/chat.css'; import FormatQuoteIcon from '@mui/icons-material/FormatQuote'; import MoreSVG from '../../../assets/svgs/More.svg'; import { @@ -19,6 +20,7 @@ import { getBaseApiReact } from '../../../App'; import { WrapperUserAction } from '../../WrapperUserAction'; export const ShowMessage = ({ message, openNewPostWithQuote, myName }: any) => { + const theme = useTheme(); const [expandAttachments, setExpandAttachments] = useState(false); let cleanHTML = ''; @@ -212,7 +214,23 @@ export const ShowMessage = ({ message, openNewPostWithQuote, myName }: any) => { )} {message?.htmlContent && ( -
+ +
+ )} { + setTimeout(() => { + const container = threadContainerRef.current; + if (!container) return; + + container.scrollTo({ + top: position === 'bottom' ? container.scrollHeight : 0, + behavior: position === 'bottom' ? 'smooth' : 'auto', + }); + }, position === 'bottom' ? 300 : 100); + }, []); + const getSavedData = useCallback(async (groupId) => { const res = await getDataPublishesFunc(groupId, 'thmsg'); dataPublishes.current = res || {}; @@ -268,14 +278,10 @@ export const Thread = ({ } setMessages(fullArrayMsg); if (before === null && after === null && isReverse) { - setTimeout(() => { - containerRef.current.scrollIntoView({ behavior: 'smooth' }); - }, 300); + scrollThreadContainer('bottom'); } if (after || (before === null && after === null && !isReverse)) { - setTimeout(() => { - threadBeginningRef.current.scrollIntoView(); - }, 100); + scrollThreadContainer('top'); } if (fullArrayMsg.length === 0) { @@ -578,6 +584,8 @@ export const Thread = ({ sx={{ display: 'flex', flexDirection: 'column', + height: '100%', + minHeight: 0, overflow: 'hidden', position: 'relative', width: '100%', @@ -655,11 +663,13 @@ export const Thread = ({ -
@@ -1095,8 +1105,6 @@ export const Thread = ({ - -
diff --git a/src/components/Group/Group.styles.ts b/src/components/Group/Group.styles.ts index 5749dff7..fb45c3db 100644 --- a/src/components/Group/Group.styles.ts +++ b/src/components/Group/Group.styles.ts @@ -13,6 +13,7 @@ export const RootBox = styled(Box)({ display: 'flex', flexDirection: 'row', height: '100%', + position: 'relative', width: '100%', }); @@ -42,20 +43,30 @@ export const InnerChatBox = styled(Box)({ display: 'flex', flexGrow: 1, height: '100%', + minHeight: 0, position: 'relative', }); -export const AdminRowBox = styled(Box)({ - display: 'flex', - gap: '20px', - padding: '15px', +export const AdminRowBox = styled(Box)(({ theme }) => ({ alignItems: 'center', -}); + borderRadius: theme.shape.borderRadius, + display: 'flex', + gap: theme.spacing(2), + justifyContent: 'space-between', + padding: theme.spacing(1.5, 2), + transition: 'background-color 0.2s ease', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, +})); export const ChatContentBox = styled(Box)({ display: 'flex', - flexGrow: 1, - height: `calc(100vh - ${70 + appChromeOffset}px)`, + flexDirection: 'column', + flex: 1, + minHeight: 0, + minWidth: 0, + overflow: 'hidden', position: 'relative', }); @@ -122,7 +133,10 @@ export const SelectedGroupWrapper = styled('div', { shouldForwardProp: (prop) => prop !== 'isVisible', })(({ isVisible }) => ({ width: '100%', - display: 'block', + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 0, opacity: !isVisible ? 0 : 1, position: isVisible ? 'absolute' : 'fixed', left: !isVisible ? '-100000px' : '0px', @@ -140,3 +154,19 @@ export const GroupRightSidebar = styled(AuthenticatedContainerInnerRight, { padding: '5px', display: hide ? 'none' : 'flex', })); + + + +export const NotPartAdminListBox = styled(Box)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius * 2, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + maxHeight: 'min(420px, calc(100vh - 340px))', + maxWidth: 420, + minHeight: 0, + overflowX: 'hidden', + overflowY: 'auto', + width: '100%', +})); diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 2b7e7bc5..0cc2c937 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Paper, Typography, useTheme } from '@mui/material'; import { lazy, Profiler, @@ -13,7 +13,8 @@ import { ChatGroup } from '../Chat/ChatGroup'; import { CreateCommonSecret } from '../Chat/CreateCommonSecret'; import { base64ToUint8Array } from '../../qdn/encryption/group-encryption'; import { uint8ArrayToObject } from '../../encryption/encryption'; -import { Spacer } from '../../common/Spacer'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; + import { clearAllQueues, getBaseApiReact, @@ -47,7 +48,6 @@ import { useMessageQueue } from '../../messaging/MessageQueueContext'; import { HomeDesktop } from './HomeDesktop'; import { DesktopHeader } from '../Desktop/DesktopHeader'; import { AppsDesktop } from '../Apps/AppsDesktop'; -import { AppsDevMode } from '../Apps/AppsDevMode'; import { DesktopSideBar } from '../Desktop/DesktopLeftSideBar'; import { AdminSpace } from '../Chat/AdminSpace'; import { @@ -79,20 +79,21 @@ import { TIME_DAYS_1_IN_MILLISECONDS, } from '../../constants/constants'; import { useWebsocketStatus } from './useWebsocketStatus'; -import { useQMailFetch } from '../../hooks/useQMailFetch'; +import { WebSocketNotifications } from './WebsocketNotifications'; import { DirectsSidebar } from './DirectsSidebar'; import { GlobalChatWidget } from './GlobalChatWidget'; +import { openQChatTab, QCHAT_INTERNAL_TAB_ID } from '../../utils/openQChatTab'; import { AdminRowBox, CenterBox, ChatContentBox, EncryptionKeyMessageDiv, FloatingButtonContainerBox, - GroupRightSidebar, InnerChatBox, MainContentBox, NewChatOverlay, NoSelectionTypography, + NotPartAdminListBox, NotPartGroupDiv, RootBox, SelectedDirectOverlay, @@ -186,9 +187,9 @@ function MemberGroupsEffects({ export const Group = ({ myAddress, - setIsOpenDrawerProfile, setDesktopViewMode, desktopViewMode, + onOpenSettings, }: GroupProps) => { const [desktopSideView, setDesktopSideView] = useState('groups'); const [chatWidgetClosed, setChatWidgetClosed] = useAtom(chatWidgetClosedAtom); @@ -212,6 +213,7 @@ export const Group = ({ const [groupOwner, setGroupOwner] = useState(null); const [triedToFetchSecretKey, setTriedToFetchSecretKey] = useState(false); const [openAddGroup, setOpenAddGroup] = useState(false); + const [openAddGroupTab, setOpenAddGroupTab] = useState<0 | 1 | 2>(0); const [openManageMembers, setOpenManageMembers] = useState(false); const setMemberGroups = useSetAtom(memberGroupsAtom); const [timestampEnterData, setTimestampEnterData] = useAtom( @@ -231,6 +233,8 @@ export const Group = ({ const [groupAnnouncements, setGroupAnnouncements] = useAtom( groupAnnouncementsAtom ); + const theme = useTheme(); + const [defaultThread, setDefaultThread] = useState(null); const [, setIsOpenDrawer] = useState(false); const [isOpenBlockedModal, setIsOpenBlockedUserModal] = useAtom( @@ -241,6 +245,7 @@ export const Group = ({ const setMutedGroups = useSetAtom(mutedGroupsAtom); const [mobileViewMode, setMobileViewMode] = useState('home'); const [, setMobileViewModeKeepOpen] = useState(''); + const [isQChatTabActive, setIsQChatTabActive] = useState(false); const timestampEnterDataRef = useRef({}); const selectedGroupRef = useRef(null); const selectedDirectRef = useRef(null); @@ -256,6 +261,12 @@ export const Group = ({ const setIsEnabledDevMode = useSetAtom(enabledDevModeAtom); const setIsDisabledEditorEnter = useSetAtom(isDisabledEditorEnterAtom); + useEffect(() => { + if (!openAddGroup) { + setOpenAddGroupTab(0); + } + }, [openAddGroup]); + useEffect(() => { const isDevModeFromStorage = localStorage.getItem('isEnabledDevMode'); if (isDevModeFromStorage) { @@ -306,7 +317,6 @@ export const Group = ({ const [groupsProperties, setGroupsProperties] = useAtom(groupsPropertiesAtom); const setGroupsOwnerNames = useSetAtom(groupsOwnerNamesAtom); const userInfo = useAtomValue(userInfoAtom); - useQMailFetch(userInfo?.name, userInfo?.address); const setUserInfoForLevels = useSetAtom(addressInfoControllerAtom); const setMyGroupsWhereIAmAdmin = useSetAtom(myGroupsWhereIAmAdminAtom); @@ -357,26 +367,43 @@ export const Group = ({ // Track view modes to prevent marking messages as read when not viewing chat const desktopViewModeRef = useRef(desktopViewMode); const mobileViewModeRef = useRef(mobileViewMode); + const qChatTabActiveRef = useRef(false); + const lastNonQappDesktopViewModeRef = useRef( + desktopViewMode !== 'apps' && desktopViewMode !== 'dev' + ? desktopViewMode + : 'home' + ); useEffect(() => { desktopViewModeRef.current = desktopViewMode; + if (desktopViewMode !== 'apps' && desktopViewMode !== 'dev') { + lastNonQappDesktopViewModeRef.current = desktopViewMode; + } }, [desktopViewMode]); useEffect(() => { mobileViewModeRef.current = mobileViewMode; }, [mobileViewMode]); + useEffect(() => { + qChatTabActiveRef.current = isQChatTabActive; + }, [isQChatTabActive]); + // Track previous view mode to detect when user returns to chat const prevDesktopViewModeRef = useRef(desktopViewMode); const prevMobileViewModeRef = useRef(mobileViewMode); + const prevQChatTabActiveRef = useRef(isQChatTabActive); // Mark messages as read when user returns to chat view useEffect(() => { const wasInChatMode = prevDesktopViewModeRef.current === 'chat' || + prevQChatTabActiveRef.current || prevMobileViewModeRef.current === 'chat'; const isNowInChatMode = - desktopViewMode === 'chat' || mobileViewMode === 'chat'; + desktopViewMode === 'chat' || + isQChatTabActive || + mobileViewMode === 'chat'; // Only update timestamp when user RETURNS to chat (wasn't in chat, now is in chat) if (!wasInChatMode && isNowInChatMode) { @@ -426,7 +453,8 @@ export const Group = ({ // Update previous view mode refs prevDesktopViewModeRef.current = desktopViewMode; prevMobileViewModeRef.current = mobileViewMode; - }, [desktopViewMode, mobileViewMode]); + prevQChatTabActiveRef.current = isQChatTabActive; + }, [desktopViewMode, isQChatTabActive, mobileViewMode]); const getUserSettings = useCallback(async () => { try { @@ -678,7 +706,10 @@ export const Group = ({ setTriedToFetchSecretKey(true); } } catch (error) { - if (error === 'Unable to decrypt data') { + if ( + error === 'Unable to decrypt data' || + error === 'Unable to decrypt' + ) { setTriedToFetchSecretKey(true); settimeoutForRefetchSecretKey.current = setTimeout(() => { getSecretKey(); @@ -927,6 +958,7 @@ export const Group = ({ selectedGroupRef.current && groupSectionRef.current === 'chat' && (desktopViewModeRef.current === 'chat' || + qChatTabActiveRef.current || mobileViewModeRef.current === 'chat') ) { window @@ -946,6 +978,7 @@ export const Group = ({ if ( selectedDirectRef.current && (desktopViewModeRef.current === 'chat' || + qChatTabActiveRef.current || mobileViewModeRef.current === 'chat') ) { window @@ -975,6 +1008,7 @@ export const Group = ({ selectedGroupRef.current && groupSectionRef.current === 'announcement' && (desktopViewModeRef.current === 'chat' || + qChatTabActiveRef.current || mobileViewModeRef.current === 'group') ) { window @@ -1130,12 +1164,13 @@ export const Group = ({ (direct) => direct?.address === directAddress ); if (findDirect?.address === selectedDirect?.address) { + openQChatTab(); isLoadingOpenSectionFromNotification.current = false; return; } if (findDirect) { setDesktopSideView('directs'); - setDesktopViewMode('home'); + openQChatTab(); setSelectedDirect(null); setNewChat(false); @@ -1173,7 +1208,7 @@ export const Group = ({ ); if (findDirect) { - setDesktopViewMode('chat'); + openQChatTab(); setDesktopSideView('directs'); setSelectedDirect(null); @@ -1196,7 +1231,7 @@ export const Group = ({ getTimestampEnterChat(); }, 200); } else { - setDesktopViewMode('chat'); + openQChatTab(); setDesktopSideView('directs'); setNewChat(true); setTimeout(() => { @@ -1268,6 +1303,32 @@ export const Group = ({ [getGroupAnnouncements, getTimestampEnterChat] ); + const handleMarkAllMemberGroupsRead = useCallback(() => { + const ids = (memberGroupsRef.current || []) + .map((g) => g?.groupId) + .filter((id) => id != null && id !== ''); + if (!ids.length) return; + + window + .sendMessage('markAllMemberGroupsRead', { groupIds: ids }) + .then((response) => { + if (response?.error) { + console.error('Failed to mark all groups read:', response.error); + } + }) + .catch((error) => { + console.error( + 'Failed to mark all groups read:', + error.message || 'An error occurred' + ); + }); + + setTimeout(() => { + getGroupAnnouncements(); + getTimestampEnterChat(); + }, 200); + }, [getGroupAnnouncements, getTimestampEnterChat]); + useEffect(() => { subscribeToEvent('markAsRead', handleMarkAsRead); @@ -1276,6 +1337,17 @@ export const Group = ({ }; }, [handleMarkAsRead]); + useEffect(() => { + subscribeToEvent('markAllMemberGroupsRead', handleMarkAllMemberGroupsRead); + + return () => { + unsubscribeFromEvent( + 'markAllMemberGroupsRead', + handleMarkAllMemberGroupsRead + ); + }; + }, [handleMarkAllMemberGroupsRead]); + const resetAllStatesAndRefs = useCallback(() => { // Reset all useState values to their initial states setSecretKey(null); @@ -1312,11 +1384,13 @@ export const Group = ({ setGroupAnnouncements({}); setDefaultThread(null); setMobileViewMode('home'); + setIsQChatTabActive(false); // Reset all useRef values to their initial states hasInitializedWebsocket.current = false; selectedGroupRef.current = null; selectedDirectRef.current = null; groupSectionRef.current = null; + qChatTabActiveRef.current = false; isLoadingOpenSectionFromNotification.current = false; settimeoutForRefetchSecretKey.current = null; initiatedGetMembers.current = false; @@ -1348,6 +1422,49 @@ export const Group = ({ }; }, [openAppsMode]); + const openHomeMode = useCallback(() => { + setDesktopViewMode('home'); + }, []); + + useEffect(() => { + subscribeToEvent('open-home-mode', openHomeMode); + + return () => { + unsubscribeFromEvent('open-home-mode', openHomeMode); + }; + }, [openHomeMode]); + + const returnFromAppsMode = useCallback(() => { + setDesktopViewMode(lastNonQappDesktopViewModeRef.current || 'home'); + }, [setDesktopViewMode]); + + useEffect(() => { + subscribeToEvent('return-from-apps-mode', returnFromAppsMode); + + return () => { + unsubscribeFromEvent('return-from-apps-mode', returnFromAppsMode); + }; + }, [returnFromAppsMode]); + + const openGroupDiscovery = useCallback(() => { + setChatMode('groups'); + setDesktopSideView('groups'); + setSelectedGroup(null); + setSelectedDirect(null); + setNewChat(false); + openQChatTab(); + setOpenAddGroupTab(1); + setOpenAddGroup(true); + }, []); + + useEffect(() => { + subscribeToEvent('open-group-discovery', openGroupDiscovery); + + return () => { + unsubscribeFromEvent('open-group-discovery', openGroupDiscovery); + }; + }, [openGroupDiscovery]); + const openDevMode = useCallback(() => { setDesktopViewMode('dev'); }, []); @@ -1371,7 +1488,8 @@ export const Group = ({ if (findGroup?.groupId === selectedGroup?.groupId) { isLoadingOpenSectionFromNotification.current = false; setChatMode('groups'); - setDesktopViewMode('chat'); + setGroupSection('chat'); + openQChatTab(); return; } if (findGroup) { @@ -1395,7 +1513,7 @@ export const Group = ({ setTriedToFetchSecretKey(false); setFirstSecretKeyInCreation(false); setGroupSection('chat'); - setDesktopViewMode('chat'); + openQChatTab(); window .sendMessage('addTimestampEnterChat', { @@ -1438,7 +1556,11 @@ export const Group = ({ const findGroup = memberGroupsRef.current?.find( (group: any) => +group?.groupId === +groupId ); - if (findGroup?.groupId === selectedGroup?.groupId) return; + if (findGroup?.groupId === selectedGroup?.groupId) { + setGroupSection('announcement'); + openQChatTab(); + return; + } if (findGroup) { setChatMode('groups'); setSelectedGroup(null); @@ -1457,7 +1579,7 @@ export const Group = ({ setTriedToFetchSecretKey(false); setFirstSecretKeyInCreation(false); setGroupSection('announcement'); - setDesktopViewMode('chat'); + openQChatTab(); window .sendMessage('addGroupNotificationTimestamp', { timestamp: Date.now(), @@ -1505,6 +1627,7 @@ export const Group = ({ if (findGroup?.groupId === selectedGroup?.groupId) { setGroupSection('forum'); setDefaultThread(data); + openQChatTab(); return; } @@ -1527,7 +1650,7 @@ export const Group = ({ setFirstSecretKeyInCreation(false); setGroupSection('forum'); setDefaultThread(data); - setDesktopViewMode('chat'); + openQChatTab(); setTimeout(() => { setSelectedGroup(findGroup); setMobileViewMode('group'); @@ -1668,6 +1791,15 @@ export const Group = ({ [t] ); + const notPartOfKeys = useMemo(() => { + return ( + isPrivate && + !admins.includes(myAddress) && + !secretKey && + triedToFetchSecretKey + ); + }, [isPrivate, admins, myAddress, secretKey, triedToFetchSecretKey]); + const closeChatDirect = useCallback(() => { setSelectedDirect(null); setNewChat(false); @@ -1712,45 +1844,28 @@ export const Group = ({ }, 200); }, []); - return ( - <> - - - - - - - + const renderQChatTabContent = ({ + hide = false, + isSelected, + }: { + hide?: boolean; + isSelected: boolean; + }) => { + const isVisible = isSelected && !hide; - {desktopViewMode === 'chat' && desktopSideView !== 'directs' && ( + return ( + + {desktopSideView !== 'directs' ? ( - )} - - {desktopViewMode === 'chat' && desktopSideView === 'directs' && ( + ) : ( )} - - {openAddGroup && ( - - + {newChat && ( + + - + )} - {newChat && ( - <> - - - - - )} - {desktopViewMode === 'chat' && !selectedGroup && ( + {isVisible && !selectedGroup && !selectedDirect && !newChat && ( {t('group:message.generic.no_selection', { @@ -1829,9 +1940,7 @@ export const Group = ({ )} - + )} - {isPrivate && - !admins.includes(myAddress) && - !secretKey && - triedToFetchSecretKey ? ( + {notPartOfKeys ? ( <> {secretKeyPublishDate || (!secretKeyPublishDate && !firstSecretKeyInCreation) ? ( - - {t('group:message.generic.not_part_group', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - + + + + {t('group:message.generic.not_part_group', { + postProcess: 'capitalizeFirstChar', + })} + + {t('group:message.generic.only_encrypted', { postProcess: 'capitalizeFirstChar', })} - - - - - - - {t('group:message.generic.notify_admins', { + + + + {t('group:message.error.notify_admins', { postProcess: 'capitalizeFirstChar', })} - - - - {adminsWithNames.map((admin) => { - return ( + + {adminsWithNames.map((admin) => ( - {admin?.name} + + {admin?.name} + {t('core:action.notify', { postProcess: 'capitalizeFirstChar', })} - ); - })} + ))} + ) : null} @@ -1981,11 +2128,10 @@ export const Group = ({ {groupSection === 'adminSpace' && ( )} @@ -2033,76 +2179,127 @@ export const Group = ({ )} - {isOpenBlockedModal && ( + + + + + + {selectedDirect && !newChat && ( + + + + + + )} + + + ); + }; + + return ( + <> + + + + + + + + + + + {openAddGroup && ( - + )} - {selectedDirect && !newChat && ( - <> - - - - - - - )} + {desktopViewMode === 'chat' && + renderQChatTabContent({ isSelected: true })} - - { + setIsQChatTabActive( + isVisible && tab?.internal === QCHAT_INTERNAL_TAB_ID + ); + }} + renderInternalTab={({ hide, isSelected, tab }) => + tab?.internal === QCHAT_INTERNAL_TAB_ID + ? renderQChatTabContent({ hide, isSelected }) + : null + } + show={desktopViewMode === 'apps' || desktopViewMode === 'dev'} /> - - void; + graphicVariant?: GroupActivityEmptyStateGraphicVariant; +}; + +export const GroupActivityEmptyState = ({ + compact = false, + isVisible = true, + title, + secondaryLines, + tertiaryText, + ctaLabel, + onCtaClick, + graphicVariant = 'requests', +}: GroupActivityEmptyStateProps) => { + const theme = useTheme(); + const rootRef = useRef(null); + const graphicFrameRef = useRef(null); + const resizeFrameRef = useRef(null); + const extraLiftPxRef = useRef(0); + const [extraLiftPx, setExtraLiftPx] = useState(0); + + const baseLiftPx = compact ? 44 : 40; + const totalLiftPx = baseLiftPx + extraLiftPx; + + useEffect(() => { + extraLiftPxRef.current = extraLiftPx; + }, [extraLiftPx]); + + useLayoutEffect(() => { + if (typeof window === 'undefined' || !isVisible) return undefined; + + const scheduleMeasurement = (callback: () => void) => { + if (resizeFrameRef.current !== null) { + window.cancelAnimationFrame(resizeFrameRef.current); + } + resizeFrameRef.current = window.requestAnimationFrame(() => { + resizeFrameRef.current = null; + callback(); + }); + }; + + const updateLift = () => { + const ghostBarNode = document.querySelector( + '[data-group-activity-ghost-bar="true"]' + ) as HTMLElement | null; + const graphicFrameNode = graphicFrameRef.current; + + if (!ghostBarNode || !graphicFrameNode) return; + if ( + ghostBarNode.getClientRects().length === 0 || + graphicFrameNode.getClientRects().length === 0 + ) { + return; + } + + const ghostBarBottom = ghostBarNode.getBoundingClientRect().bottom; + const graphicTop = graphicFrameNode.getBoundingClientRect().top; + const actualGapPx = graphicTop - ghostBarBottom; + const baselineGapPx = actualGapPx + extraLiftPxRef.current; + const targetGapPx = baselineGapPx * 0.4; + const nextExtraLiftPx = Math.max(0, baselineGapPx - targetGapPx); + + if (Math.abs(nextExtraLiftPx - extraLiftPxRef.current) > 0.5) { + extraLiftPxRef.current = nextExtraLiftPx; + setExtraLiftPx(nextExtraLiftPx); + } + }; + + const handleMeasurement = () => { + scheduleMeasurement(updateLift); + }; + + updateLift(); + window.addEventListener('resize', handleMeasurement); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(handleMeasurement); + + const ghostBarNode = document.querySelector( + '[data-group-activity-ghost-bar="true"]' + ); + if (ghostBarNode) { + resizeObserver.observe(ghostBarNode); + } + if (rootRef.current) { + resizeObserver.observe(rootRef.current); + } + } + + return () => { + window.removeEventListener('resize', handleMeasurement); + if (resizeFrameRef.current !== null) { + window.cancelAnimationFrame(resizeFrameRef.current); + } + resizeObserver?.disconnect(); + }; + }, [ + baseLiftPx, + compact, + graphicVariant, + isVisible, + secondaryLines, + tertiaryText, + title, + ]); + + return ( + + + + + + + {title} + + + {secondaryLines.map((line) => ( + + {line} + + ))} + + {tertiaryText ? ( + + {tertiaryText} + + ) : null} + + + + + + ); +}; diff --git a/src/components/Group/GroupActivityEmptyStateGraphic.tsx b/src/components/Group/GroupActivityEmptyStateGraphic.tsx new file mode 100644 index 00000000..84fc7cc1 --- /dev/null +++ b/src/components/Group/GroupActivityEmptyStateGraphic.tsx @@ -0,0 +1,462 @@ +import { useId, type CSSProperties } from 'react'; +import { keyframes } from '@emotion/react'; +import { Box, type SxProps, type Theme } from '@mui/material'; + +const GROUP_EMPTY_RATIO = 92 / 320; +const GROUP_EMPTY_EASING = 'cubic-bezier(0.45, 0.05, 0.55, 0.95)'; + +const requesterNodeFloat = keyframes` + 0%, 100% { + transform: translate3d(0px, 0px, 0px); + opacity: 0.94; + } + 38% { + transform: translate3d(-1px, -4px, 0px); + opacity: 1; + } + 74% { + transform: translate3d(1px, -1px, 0px); + opacity: 0.97; + } +`; + +const groupNodeFloat = keyframes` + 0%, 100% { + transform: translate3d(0px, 0px, 0px); + opacity: 0.92; + } + 44% { + transform: translate3d(1px, -3px, 0px); + opacity: 0.98; + } + 78% { + transform: translate3d(-1px, -5px, 0px); + opacity: 0.95; + } +`; + +const bridgeStreamDrift = keyframes` + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -10.8; + } +`; + +const markerFloat = keyframes` + 0%, 100% { + transform: translate3d(0px, 0px, 0px) rotate(0deg); + opacity: 0.2; + } + 50% { + transform: translate3d(1px, -5px, 0px) rotate(1.8deg); + opacity: 0.3; + } +`; + +const particleFade = keyframes` + 0%, 100% { + opacity: 0.016; + transform: translate3d(0px, 0px, 0px); + } + 50% { + opacity: 0.052; + transform: translate3d(0px, -1.2px, 0px); + } +`; + +const ambientGlowBreathe = keyframes` + 0%, 100% { + opacity: 0.68; + transform: scale(1); + } + 50% { + opacity: 0.92; + transform: scale(1.015); + } +`; + +const EMPTY_STATE_PARTICLES = [ + { cx: 112, cy: 47, r: 1.15, delay: '0s', duration: '6.9s' }, + { cx: 148, cy: 40, r: 1.35, delay: '1.2s', duration: '7.4s' }, + { cx: 192, cy: 95, r: 1.05, delay: '2.1s', duration: '7.1s' }, + { cx: 226, cy: 47, r: 1.25, delay: '3s', duration: '7.7s' }, +] as const; + +const REQUESTER_QORTAL_LOGO_PATH = + 'M0 -9.2L7.8 -4.6V4.7L0 9.3L-7.8 4.7V-4.6L0 -9.2ZM0 -4.7L-4.2 -2.3V2.5L0 5L4.2 2.5V-2.3L0 -4.7Z'; +const REQUESTER_QORTAL_LOGO_TAIL_PATH = 'M4.5 2.5L7.8 4.5V10.8L4.5 8.9V2.5Z'; + +type GroupActivityEmptyStateGraphicProps = { + size?: number; + sx?: SxProps; + variant?: GroupActivityEmptyStateGraphicVariant; +}; + +export type GroupActivityEmptyStateGraphicVariant = 'requests' | 'invites'; + +type QortalRequestBubbleSvgProps = { + centerX: number; + centerY: number; + className?: string; + logoScale?: number; +}; + +const QortalRequestBubbleSvg = ({ + centerX, + centerY, + className, + logoScale = 1, +}: QortalRequestBubbleSvgProps) => { + const idBase = useId().replace(/:/g, ''); + const requesterFillId = `${idBase}-requester-fill`; + const requesterRingId = `${idBase}-requester-ring`; + const haloFilterId = `${idBase}-halo-blur`; + const requesterCoreSoftFilterId = `${idBase}-requester-core-soft`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const QortalRequestBubbleIcon = ({ + size = 22, + logoScale = 1, + sx, +}: { + size?: number; + logoScale?: number; + sx?: SxProps; +}) => ( + +); + +export const GroupActivityEmptyStateGraphic = ({ + size = 292, + sx, + variant = 'requests', +}: GroupActivityEmptyStateGraphicProps) => { + const height = Math.round(size * GROUP_EMPTY_RATIO); + const isInviteVariant = variant === 'invites'; + const idBase = useId().replace(/:/g, ''); + const fieldGradientId = `${idBase}-field-gradient`; + const groupFillId = `${idBase}-group-fill`; + const groupRingId = `${idBase}-group-ring`; + const bridgeFadeGradientId = `${idBase}-bridge-fade-gradient`; + const bridgeFadeMaskId = `${idBase}-bridge-fade-mask`; + const ambientFilterId = `${idBase}-ambient-blur`; + const haloFilterId = `${idBase}-halo-blur`; + const bridgeStartX = isInviteVariant ? 210 : 110; + const bridgeEndX = isInviteVariant ? 118 : 202; + const bridgeMinX = Math.min(bridgeStartX, bridgeEndX); + const bridgeMaxX = Math.max(bridgeStartX, bridgeEndX); + const bridgePath = `M${bridgeStartX} 80H${bridgeEndX}`; + + return ( + - )} - - {/* ── USER TAB ── */} - {activeTab === 'user' && ( - <> - setShowMostActiveGroups(true)} /> - - {SHOW_MOST_ACTIVE_GROUPS && showMostActiveGroups && } - - {/* ── GROUP ACTIVITY SECTION ── */} - {!isLoadingGroups && hasDoneNameAndBalanceAndIsLoaded && ( - - {/* Section title and refresh */} - - - {t('tutorial:home.group_activity', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - {/* Tab bar */} - - {( - [ - { - key: 'requests' as ActivityTab, - label: t('group:join_requests', { postProcess: 'capitalizeFirstChar' }), - count: requestsCount, - countLoading: requestsCountLoading, - }, - { - key: 'invites' as ActivityTab, - label: t('group:group.invites', { postProcess: 'capitalizeFirstChar' }), - count: invitesCount, - countLoading: invitesCountLoading, - }, - { - key: 'promotions' as ActivityTab, - label: t('group:group.promotions', { postProcess: 'capitalizeFirstChar' }), - count: promotionsCount, - countLoading: false, - }, - ] - ).map(({ key, label, count, countLoading }) => ( - - ))} - - - {/* Tab content: mount all so each can report its count; hide inactive */} - - - - - - - - - - - )} - - - )} - - {/* ── DEVELOPER TAB ── */} - {activeTab === 'developer' && ( - - )} - - - - - )} - - - ); -}; diff --git a/src/components/Group/HomeDesktop/BlockHeightValue.tsx b/src/components/Group/HomeDesktop/BlockHeightValue.tsx new file mode 100644 index 00000000..b48375a7 --- /dev/null +++ b/src/components/Group/HomeDesktop/BlockHeightValue.tsx @@ -0,0 +1,104 @@ +import { Box } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { GROUP_ACTIVITY_BLUE } from '../groupActivityColorSystem'; + +const BLOCK_HEIGHT_TAIL_DIGITS = 4; + +function getBlockHeightParts(value?: string | null) { + const rawValue = `${value || ''}`.trim(); + const digits = rawValue.replace(/\D/g, ''); + + if (digits.length <= BLOCK_HEIGHT_TAIL_DIGITS) { + return { + canHighlightTail: false, + fullValue: rawValue, + prefix: '', + tail: rawValue, + }; + } + + return { + canHighlightTail: true, + fullValue: digits, + prefix: digits.slice(0, -BLOCK_HEIGHT_TAIL_DIGITS), + tail: digits.slice(-BLOCK_HEIGHT_TAIL_DIGITS), + }; +} + +export function BlockHeightValue({ theme, value }) { + const parts = getBlockHeightParts(value); + + return ( + + {parts.canHighlightTail ? ( + <> + + {parts.prefix} + + + {parts.tail} + + + ) : ( + parts.tail + )} + + ); +} diff --git a/src/components/Group/HomeDesktop/DashboardUtilityPanel.tsx b/src/components/Group/HomeDesktop/DashboardUtilityPanel.tsx new file mode 100644 index 00000000..8e64d1ba --- /dev/null +++ b/src/components/Group/HomeDesktop/DashboardUtilityPanel.tsx @@ -0,0 +1,60 @@ +import { Box, Typography } from '@mui/material'; +import { + dashboardPanelSx, + handleDashboardPanelPointerLeave, + handleDashboardPanelPointerMove, + useDashboardPanelMouseLight, +} from '../dashboardPanelEffects'; + +export const DashboardUtilityPanel = ({ + title, + children, + theme, + sx = undefined, + titleSx = undefined, + panelBoxRef = undefined, +}) => { + const panelRef = useDashboardPanelMouseLight(); + const assignPanelNode = (node) => { + panelRef.current = node; + + if (typeof panelBoxRef === 'function') { + panelBoxRef(node); + return; + } + + if (panelBoxRef) { + panelBoxRef.current = node; + } + }; + + return ( + + + {title} + + {children} + + ); +}; diff --git a/src/components/Group/HomeDesktop/HomeDesktop.tsx b/src/components/Group/HomeDesktop/HomeDesktop.tsx new file mode 100644 index 00000000..214ece2b --- /dev/null +++ b/src/components/Group/HomeDesktop/HomeDesktop.tsx @@ -0,0 +1,925 @@ +import { Box, Typography, useMediaQuery, useTheme } from '@mui/material'; +import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; +import ForumRoundedIcon from '@mui/icons-material/ForumRounded'; +import { alpha, darken } from '@mui/material/styles'; +import { + Activity, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import ErrorBoundary from '../../../common/ErrorBoundary'; +import { Spacer } from '../../../common/Spacer'; +import { HomeProfileCard } from '../HomeProfileCard'; +import { GETTING_STARTED_LS_KEY } from '../gettingStartedStorage'; +import { HomeQortinoWorkspaceCard } from '../HomeQortinoWorkspaceCard'; +import { HomeQuickToolsPad } from '../HomeQuickToolsPad'; +import { HomeFeaturedApps } from '../HomeFeaturedApps'; +import { useTranslation } from 'react-i18next'; +import { + LazyMotion, + domAnimation, + motion, + useReducedMotion, +} from 'framer-motion'; +import { getBaseApiReact } from '../../../App'; +import { executeEvent } from '../../../utils/events'; +import { openQChatTab } from '../../../utils/openQChatTab'; +import { + dashboardPanelSx, + useDashboardPanelMouseLight, +} from '../dashboardPanelEffects'; +import { DashboardWidgetFrame } from '../../Widgets/DashboardWidgetFrame'; +import { GroupsWidget } from '../../Widgets/GroupsWidget'; +import { QuitterFeedWidget } from '../../Widgets/QuitterFeedWidget'; +import { InfoPreviewPanel } from './InfoPreviewPanel'; +import { HomeDesktopWalletActivity } from './HomeDesktopWalletActivity'; +import { + HOME_CUSTOMIZABLE_CARD_LAYOUT_STORAGE_KEY, + HOME_CUSTOMIZABLE_CARD_MAX_HEIGHTS, + HOME_CUSTOMIZABLE_CARD_MIN_HEIGHTS, + HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT, + HOME_CUSTOMIZABLE_CARD_RESIZE_STEP_PX, + HOME_DASHBOARD_VERTICAL_GAP_PX, + HOME_DASHBOARD_WIDGET_DISPLAY_MODE, + HOME_DASHBOARD_WIDGET_HEIGHT_PX, + HOME_EMBEDDED_QAPP_PANEL_HEIGHT_PX, + HOME_GROUP_ACTIVITY_CARD_DEFAULT_HEIGHT_PX, + HOME_INFO_COLLAPSED_VISIBLE_HEIGHT_PX, + HOME_LEFT_CENTER_GRID_TEMPLATE_COLUMNS, + HOME_LEFT_CENTER_LOWER_ROW_GRID_TEMPLATE_COLUMNS, + HOME_QUITTER_WIDGET_INITIAL_BATCH_SIZES, + HOME_QUITTER_WIDGET_LOAD_MORE_BATCH_SIZES, + HOME_QUITTER_WIDGET_SEARCH_LIMITS, + HOME_RIGHT_RAIL_TOP_ALIGNMENT_OFFSET_PX, + HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX, + HOME_SHARED_SIDE_RAIL_WIDTH_XL, + HOME_WIDE_DASHBOARD_MIN_WIDTH_PX, +} from './homeDesktopConstants'; +import type { + HomeCustomizableCardId, + HomeCustomizableCardsLayout, + HomeLayoutDebugKey, + HomeLayoutDebugMetric, +} from './types'; +import { + clampHomeCustomizableCardHeight, + measureHomeLayoutDebugMetric, + parseHomeCustomizableCardsLayout, +} from './utils'; + +export const HomeDesktop = ({ + myAddress, + setGroupSection, + setSelectedGroup, + setDesktopViewMode, + desktopViewMode, + onOpenSettings, +}) => { + const groupActivityPanelRef = useDashboardPanelMouseLight(); + const groupActivityCardHeightRef = useRef(null); + const quitterCardHeightRef = useRef(null); + const homeLayoutDebugRootRef = useRef(null); + const accountOverviewDebugRef = useRef(null); + const infoDebugRef = useRef(null); + const profileCardDebugRef = useRef(null); + const toolsDebugRef = useRef(null); + const featuredAppsDebugRef = useRef(null); + const walletActivityDebugRef = useRef(null); + const rightRailRef = useRef(null); + const layoutStabilizeFrameRef = useRef(null); + const [isOnboardingComplete, setIsOnboardingComplete] = useState(false); + const [walletActivityTargetHeightPx, setWalletActivityTargetHeightPx] = + useState(null); + const [qortinoCardTargetHeightPx, setQortinoCardTargetHeightPx] = useState< + number | null + >(null); + const [customizableCardsLayout, setCustomizableCardsLayout] = + useState(() => + parseHomeCustomizableCardsLayout( + localStorage.getItem(HOME_CUSTOMIZABLE_CARD_LAYOUT_STORAGE_KEY) + ) + ); + const [groupWidgetRefreshToken, setGroupWidgetRefreshToken] = useState(0); + const [isGroupWidgetRefreshing, setIsGroupWidgetRefreshing] = useState(false); + const [quitterWidgetRefreshToken, setQuitterWidgetRefreshToken] = useState(0); + const [isQuitterWidgetRefreshing, setIsQuitterWidgetRefreshing] = + useState(false); + const reduce = useReducedMotion(); + const { t } = useTranslation(['core', 'group', 'tutorial', 'auth']); + const td = useCallback( + (key: string, defaultValue: string) => + t(`group:dashboard.${key}`, { defaultValue }), + [t] + ); + const theme = useTheme(); + const isSplitDashboardLayout = useMediaQuery(theme.breakpoints.up('md')); + const isWideDashboardLayout = useMediaQuery( + theme.breakpoints.up(HOME_WIDE_DASHBOARD_MIN_WIDTH_PX) + ); + const resolvedWideLeftLowerRowPanelHeightPx = isWideDashboardLayout + ? HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX + : null; + const resolvedQortinoCardHeightPx = isSplitDashboardLayout + ? (qortinoCardTargetHeightPx ?? HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX) + : null; + const resolvedWalletActivityHeightPx = isWideDashboardLayout + ? (walletActivityTargetHeightPx ?? + HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX) + : null; + + const infoPanelMaxExpandedHeightPx = + isWideDashboardLayout && resolvedWalletActivityHeightPx != null + ? HOME_INFO_COLLAPSED_VISIBLE_HEIGHT_PX + + HOME_DASHBOARD_VERTICAL_GAP_PX + + resolvedWalletActivityHeightPx + + 2 + : null; + const handleOpenReceiveQort = useCallback( + (target: HTMLElement | null) => { + if (!target) return; + const rect = target.getBoundingClientRect(); + const rightRailRect = rightRailRef.current?.getBoundingClientRect(); + executeEvent('openReceiveQortInternal', { + address: myAddress ?? '', + anchorRect: { + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }, + targetRect: rightRailRect + ? { + height: rightRailRect.height, + left: rightRailRect.left, + top: rightRailRect.top, + width: rightRailRect.width, + } + : null, + }); + }, + [myAddress] + ); + const assignGroupActivityPanelNode = useCallback( + (node: HTMLDivElement | null) => { + groupActivityPanelRef.current = node; + groupActivityCardHeightRef.current = node; + }, + [groupActivityPanelRef] + ); + + useEffect(() => { + localStorage.setItem( + HOME_CUSTOMIZABLE_CARD_LAYOUT_STORAGE_KEY, + JSON.stringify(customizableCardsLayout) + ); + }, [customizableCardsLayout]); + + useEffect(() => { + setCustomizableCardsLayout((currentLayout) => { + let changed = false; + const nextHeights: Partial> = { + ...currentLayout.heights, + }; + + HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT.forEach((cardId) => { + const currentHeight = currentLayout.heights[cardId]; + if ( + typeof currentHeight !== 'number' || + !Number.isFinite(currentHeight) + ) { + return; + } + const clampedHeight = clampHomeCustomizableCardHeight( + cardId, + currentHeight + ); + if (clampedHeight !== currentHeight) { + nextHeights[cardId] = clampedHeight; + changed = true; + } + }); + + if (!changed) return currentLayout; + return { + ...currentLayout, + heights: nextHeights, + }; + }); + }, []); + + useLayoutEffect(() => { + const rootNode = homeLayoutDebugRootRef.current; + + if (!rootNode || desktopViewMode !== 'home') { + setWalletActivityTargetHeightPx(null); + return; + } + + const measureDebugLayout = () => { + const rootRect = rootNode.getBoundingClientRect(); + const nextMetrics: Partial< + Record + > = {}; + + if (accountOverviewDebugRef.current) { + nextMetrics.accountOverview = measureHomeLayoutDebugMetric( + accountOverviewDebugRef.current, + rootRect + ); + } + + if (infoDebugRef.current) { + nextMetrics.info = measureHomeLayoutDebugMetric( + infoDebugRef.current, + rootRect + ); + } + + if (profileCardDebugRef.current) { + nextMetrics.profileCard = measureHomeLayoutDebugMetric( + profileCardDebugRef.current, + rootRect + ); + } + + if (toolsDebugRef.current) { + nextMetrics.tools = measureHomeLayoutDebugMetric( + toolsDebugRef.current, + rootRect + ); + } + + if (featuredAppsDebugRef.current) { + nextMetrics.featuredApps = measureHomeLayoutDebugMetric( + featuredAppsDebugRef.current, + rootRect + ); + } + + if (walletActivityDebugRef.current) { + nextMetrics.walletActivity = measureHomeLayoutDebugMetric( + walletActivityDebugRef.current, + rootRect + ); + } + + if (isWideDashboardLayout) { + const profileMetric = nextMetrics.profileCard; + const leftRowMetric = + nextMetrics.featuredApps ?? nextMetrics.tools ?? undefined; + const featuredMetric = nextMetrics.featuredApps; + const toolsMetric = nextMetrics.tools; + const walletMetric = nextMetrics.walletActivity; + + if (profileMetric && featuredMetric && toolsMetric) { + const nextQortinoTargetHeight = Math.max( + HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX, + Math.round( + profileMetric.height + featuredMetric.height - toolsMetric.height + ) + ); + + setQortinoCardTargetHeightPx((currentHeight) => + currentHeight !== null && + Math.abs(currentHeight - nextQortinoTargetHeight) < 0.25 + ? currentHeight + : nextQortinoTargetHeight + ); + } else { + setQortinoCardTargetHeightPx(null); + } + + if (leftRowMetric && walletMetric) { + const nextTargetHeight = Math.max( + 0, + leftRowMetric.bottom - walletMetric.top + ); + + setWalletActivityTargetHeightPx((currentHeight) => + currentHeight !== null && + Math.abs(currentHeight - nextTargetHeight) < 0.25 + ? currentHeight + : nextTargetHeight + ); + } else { + setWalletActivityTargetHeightPx(null); + } + } else { + setQortinoCardTargetHeightPx(null); + setWalletActivityTargetHeightPx(null); + } + + return { + accountOverviewTop: nextMetrics.accountOverview?.top ?? 0, + featuredBottom: nextMetrics.featuredApps?.bottom ?? 0, + featuredTop: nextMetrics.featuredApps?.top ?? 0, + infoBottom: nextMetrics.info?.bottom ?? 0, + toolsBottom: nextMetrics.tools?.bottom ?? 0, + walletBottom: nextMetrics.walletActivity?.bottom ?? 0, + walletTop: nextMetrics.walletActivity?.top ?? 0, + }; + }; + + const cancelLayoutStabilizePass = () => { + if (layoutStabilizeFrameRef.current !== null) { + window.cancelAnimationFrame(layoutStabilizeFrameRef.current); + layoutStabilizeFrameRef.current = null; + } + }; + + const startLayoutStabilizePass = () => { + cancelLayoutStabilizePass(); + + const startTime = performance.now(); + let lastSnapshot = ''; + let stableFrameCount = 0; + + const step = () => { + const snapshot = measureDebugLayout(); + const snapshotKey = JSON.stringify(snapshot); + + if (snapshotKey === lastSnapshot) { + stableFrameCount += 1; + } else { + lastSnapshot = snapshotKey; + stableFrameCount = 0; + } + + const elapsed = performance.now() - startTime; + if (stableFrameCount >= 3 || elapsed > 900) { + layoutStabilizeFrameRef.current = null; + return; + } + + layoutStabilizeFrameRef.current = window.requestAnimationFrame(step); + }; + + layoutStabilizeFrameRef.current = window.requestAnimationFrame(step); + }; + + measureDebugLayout(); + startLayoutStabilizePass(); + + const fonts = ( + document as Document & { + fonts?: { ready?: Promise }; + } + ).fonts; + + if (fonts?.ready) { + fonts.ready.then(() => { + startLayoutStabilizePass(); + }); + } + + const observedNodes = [ + rootNode, + accountOverviewDebugRef.current, + infoDebugRef.current, + profileCardDebugRef.current, + toolsDebugRef.current, + featuredAppsDebugRef.current, + walletActivityDebugRef.current, + ].filter(Boolean) as HTMLElement[]; + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureDebugLayout); + + return () => { + window.removeEventListener('resize', measureDebugLayout); + }; + } + + const resizeObserver = new ResizeObserver(() => { + startLayoutStabilizePass(); + }); + + observedNodes.forEach((node) => { + resizeObserver.observe(node); + }); + window.addEventListener('resize', startLayoutStabilizePass); + + return () => { + cancelLayoutStabilizePass(); + resizeObserver.disconnect(); + window.removeEventListener('resize', startLayoutStabilizePass); + }; + }, [desktopViewMode, isOnboardingComplete, isWideDashboardLayout]); + + useEffect(() => { + if (!myAddress) { + setIsOnboardingComplete(false); + return; + } + + const isComplete = + localStorage.getItem(`${GETTING_STARTED_LS_KEY}_${myAddress}`) === + 'completed'; + setIsOnboardingComplete(isComplete); + }, [myAddress]); + + const handleGettingStartedComplete = useCallback(() => { + setIsOnboardingComplete(true); + }, []); + + const handleRefreshGroupActivity = useCallback(() => { + setGroupWidgetRefreshToken((value) => value + 1); + }, []); + + const handleRefreshQuitterWidget = useCallback(() => { + setQuitterWidgetRefreshToken((value) => value + 1); + }, []); + + const handleSwapDashboardWidgets = useCallback(() => { + setCustomizableCardsLayout((currentLayout) => ({ + ...currentLayout, + order: [...currentLayout.order].reverse(), + })); + }, []); + + const handleOpenAppsPanel = useCallback(() => { + executeEvent('newTabWindow', {}); + setDesktopViewMode('apps'); + }, [setDesktopViewMode]); + const handleOpenEmbeddedQuitter = useCallback(() => { + executeEvent('addTab', { data: { service: 'APP', name: 'Quitter' } }); + executeEvent('open-apps-mode', {}); + }, []); + const handleOpenQChatPanel = useCallback(() => { + setSelectedGroup(null); + setGroupSection('chat'); + openQChatTab(); + }, [setGroupSection, setSelectedGroup]); + const handleOpenGroupsWidget = useCallback(() => { + handleOpenQChatPanel(); + }, [handleOpenQChatPanel]); + + const qortinoWorkspaceShellFallback = ( + + + {td('qortino_runtime_title', 'QORTINO card shell hit a runtime snag.')} + + + {td( + 'qortino_runtime_body', + 'The rest of the dashboard is still safe. Refresh the Hub and if this keeps happening report it to the team.' + )} + + + ); + const groupActivityCardOrder = Math.max( + 0, + customizableCardsLayout.order.indexOf('groupActivity') + ); + const quitterCardOrder = Math.max( + 0, + customizableCardsLayout.order.indexOf('quitter') + ); + const quitterWidgetInitialBatchSize = + HOME_QUITTER_WIDGET_INITIAL_BATCH_SIZES[HOME_DASHBOARD_WIDGET_DISPLAY_MODE]; + const quitterWidgetLoadMoreBatchSize = + HOME_QUITTER_WIDGET_LOAD_MORE_BATCH_SIZES[ + HOME_DASHBOARD_WIDGET_DISPLAY_MODE + ]; + const quitterWidgetSearchLimit = + HOME_QUITTER_WIDGET_SEARCH_LIMITS[HOME_DASHBOARD_WIDGET_DISPLAY_MODE]; + + return ( + + + + + + + + + + Qortal Hub + + {isWideDashboardLayout ? ( + + + *': { height: '100%' }, + }} + > + + + + + + + + + + *': { width: '100%' }, + }} + > + + + *': { + height: '100%', + position: 'relative', + width: '100%', + zIndex: 1, + }, + }} + > + + + + + ) : ( + <> + + *': { height: '100%' }, + }} + > + + { + setIsOnboardingComplete(true); + }} + onOpenAppsPanel={handleOpenAppsPanel} + /> + + + *': { width: '100%' }, + }} + > + + + + + + + + *': { + height: '100%', + position: 'relative', + width: '100%', + zIndex: 1, + }, + }} + > + + + + + )} + + + + *': { height: '100%' }, + }} + > + + + *': { height: '100%' }, + }} + > + + + + + + + } + actionLabel={td('open_in_q_chat', 'Open in Q-Chat')} + height={HOME_DASHBOARD_WIDGET_HEIGHT_PX} + onAction={handleOpenGroupsWidget} + onRefresh={handleRefreshGroupActivity} + onSwap={handleSwapDashboardWidgets} + order={groupActivityCardOrder} + panelRef={assignGroupActivityPanelNode} + refreshing={isGroupWidgetRefreshing} + title={t('tutorial:home.group_activity', { + postProcess: 'capitalizeFirstChar', + })} + widgetId="groups" + > + + + + + } + actionLabel={td('open_in_q_apps', 'Open in Q-Apps')} + height={HOME_DASHBOARD_WIDGET_HEIGHT_PX} + onAction={handleOpenEmbeddedQuitter} + onRefresh={handleRefreshQuitterWidget} + onSwap={handleSwapDashboardWidgets} + order={quitterCardOrder} + panelRef={quitterCardHeightRef} + refreshing={isQuitterWidgetRefreshing} + title={td('quitter_feed', 'Quitter Feed')} + widgetId="quitter" + > + + + + + + + + + ); +}; diff --git a/src/components/Group/HomeDesktop/HomeDesktopWalletActivity.tsx b/src/components/Group/HomeDesktop/HomeDesktopWalletActivity.tsx new file mode 100644 index 00000000..6f44f03c --- /dev/null +++ b/src/components/Group/HomeDesktop/HomeDesktopWalletActivity.tsx @@ -0,0 +1,590 @@ +import { Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; +import SouthWestRoundedIcon from '@mui/icons-material/SouthWestRounded'; +import ShoppingBagRoundedIcon from '@mui/icons-material/ShoppingBagRounded'; +import { alpha } from '@mui/material/styles'; +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { useAtomValue } from 'jotai'; +import { balanceAtom, userInfoAtom } from '../../../atoms/global'; +import { getBaseApiReact } from '../../../App'; +import { executeEvent } from '../../../utils/events'; +import { useTranslation } from 'react-i18next'; +import { DashboardUtilityPanel } from './DashboardUtilityPanel'; +import { WalletActionButton } from './WalletActionButton'; +import { WALLET_ACTIVITY_RECENT_PAYMENT_FETCH_LIMIT } from './homeDesktopConstants'; +import type { + WalletActivityEntry, + WalletActivityTransaction, +} from './types'; +import { + formatWalletActivityAmount, + formatWalletActivityRelativeTime, + getWalletActivityCreatorAddress, + getWalletActivityRecipientAddress, + isWalletActivityTimestampRecent, +} from './utils'; + +const HOME_RIGHT_RAIL_DATA_ATTR = '[data-home-right-rail]'; + +function getRightRailRectFromElement(element: HTMLElement | null) { + if (!element) return null; + const rail = element.closest(HOME_RIGHT_RAIL_DATA_ATTR); + if (!rail) return null; + return rail.getBoundingClientRect(); +} + +export const HomeDesktopWalletActivity = () => { + const theme = useTheme(); + const userInfo = useAtomValue(userInfoAtom); + const balance = useAtomValue(balanceAtom); + const userAddress = userInfo?.address; + const { t } = useTranslation(['core', 'group', 'tutorial', 'auth']); + const td = useCallback( + (key: string, defaultValue: string) => + t(`group:dashboard.${key}`, { defaultValue }), + [t] + ); + + const tDashboard = useCallback( + (key: string, options?: { count?: number }) => + t(`group:dashboard.${key}`, options), + [t] + ); + + const walletActivityNameCacheRef = useRef>({}); + const lastWalletActivityBalanceRef = useRef(null); + const [recentWalletActivity, setRecentWalletActivity] = + useState(null); + const [isWalletActivityLoading, setIsWalletActivityLoading] = useState(false); + const [walletActivityRelativeTimeNow, setWalletActivityRelativeTimeNow] = + useState(() => Date.now()); + + const walletActivitySecondaryTextColor = alpha( + theme.palette.text.primary, + 0.6 + ); + + const handleOpenReceiveQort = useCallback((target: HTMLElement | null) => { + if (!target) return; + const rect = target.getBoundingClientRect(); + const rightRailRect = getRightRailRectFromElement(target); + executeEvent('openReceiveQortInternal', { + address: userAddress ?? '', + anchorRect: { + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }, + targetRect: rightRailRect + ? { + height: rightRailRect.height, + left: rightRailRect.left, + top: rightRailRect.top, + width: rightRailRect.width, + } + : null, + }); + }, [userAddress]); + + const handleOpenWalletActivityCounterparty = useCallback( + (address: string) => { + if (!address) return; + executeEvent('openUserLookupDrawer', { + addressOrName: address, + }); + }, + [] + ); + + const resolveWalletActivityAddressLabel = useCallback( + async (address: string) => { + if (!address) { + return td('wallet_activity_unknown_address', 'Unknown address'); + } + + const cachedSenderName = walletActivityNameCacheRef.current[address]; + if (cachedSenderName !== undefined) { + return cachedSenderName || address; + } + + try { + const response = await fetch( + `${getBaseApiReact()}/names/primary/${address}` + ); + const responseData = await response.json(); + const senderName = + response.ok && responseData?.name ? responseData.name : ''; + walletActivityNameCacheRef.current[address] = senderName || ''; + return senderName || address; + } catch (error) { + console.error( + 'Failed to resolve wallet activity participant name:', + error + ); + walletActivityNameCacheRef.current[address] = ''; + return address; + } + }, + [td] + ); + + const fetchWalletActivityTransactionBySignature = useCallback( + async (signature?: string) => { + if (!signature) return null; + + try { + const response = await fetch( + `${getBaseApiReact()}/transactions/signature/${encodeURIComponent(signature)}` + ); + + if (!response.ok) return null; + + const responseData = await response.json(); + return responseData && typeof responseData === 'object' + ? (responseData as WalletActivityTransaction) + : null; + } catch (error) { + console.error( + 'Failed to load wallet activity transaction by signature:', + error + ); + return null; + } + }, + [] + ); + + const buildWalletActivityEntry = useCallback( + async (transaction: WalletActivityTransaction | null | undefined) => { + if (!transaction || !userAddress) return null; + + let resolvedTransaction = transaction; + let creatorAddress = getWalletActivityCreatorAddress(resolvedTransaction); + let recipientAddress = + getWalletActivityRecipientAddress(resolvedTransaction); + + if ( + (!creatorAddress || !recipientAddress) && + resolvedTransaction.signature + ) { + const fullTransaction = await fetchWalletActivityTransactionBySignature( + resolvedTransaction.signature + ); + + if (fullTransaction) { + resolvedTransaction = { + ...resolvedTransaction, + ...fullTransaction, + timestamp: + resolvedTransaction.timestamp ?? fullTransaction.timestamp, + }; + creatorAddress = getWalletActivityCreatorAddress(resolvedTransaction); + recipientAddress = + getWalletActivityRecipientAddress(resolvedTransaction); + } + } + + const timestamp = Number(resolvedTransaction.timestamp); + const amount = Number(resolvedTransaction.amount); + const isOutgoing = creatorAddress === userAddress; + const isIncoming = recipientAddress === userAddress; + + if ( + !Number.isFinite(timestamp) || + !Number.isFinite(amount) || + (!isIncoming && !isOutgoing) || + !isWalletActivityTimestampRecent(timestamp) + ) { + return null; + } + + const counterpartyAddress = isOutgoing + ? recipientAddress + : creatorAddress; + + if (!counterpartyAddress) return null; + + const counterpartyLabel = + await resolveWalletActivityAddressLabel(counterpartyAddress); + + const direction = isOutgoing ? 'outgoing' : 'incoming'; + return { + amount, + counterpartyAddress, + counterpartyLabel, + direction, + timestamp, + } satisfies WalletActivityEntry; + }, + [ + fetchWalletActivityTransactionBySignature, + resolveWalletActivityAddressLabel, + userAddress, + ] + ); + + const loadRecentWalletActivity = useCallback(async () => { + if (!userAddress) { + setRecentWalletActivity(null); + setIsWalletActivityLoading(false); + return; + } + + setIsWalletActivityLoading(true); + + try { + const response = await fetch( + `${getBaseApiReact()}/transactions/search?txType=PAYMENT&address=${userAddress}&confirmationStatus=CONFIRMED&limit=${WALLET_ACTIVITY_RECENT_PAYMENT_FETCH_LIMIT}&reverse=true` + ); + + if (!response.ok) { + throw new Error('Failed to fetch wallet activity payments'); + } + + const responseData = await response.json(); + const latestRelevantPayment = Array.isArray(responseData) + ? responseData.find( + (transaction: WalletActivityTransaction) => + (getWalletActivityCreatorAddress(transaction) === userAddress || + getWalletActivityRecipientAddress(transaction) === + userAddress) && + Number.isFinite(Number(transaction?.timestamp)) && + isWalletActivityTimestampRecent(Number(transaction.timestamp)) + ) + : null; + + const recentEntry = await buildWalletActivityEntry(latestRelevantPayment); + setRecentWalletActivity((currentEntry) => { + if (!recentEntry) { + return currentEntry && + isWalletActivityTimestampRecent(currentEntry.timestamp) + ? currentEntry + : null; + } + + if ( + currentEntry && + isWalletActivityTimestampRecent(currentEntry.timestamp) && + currentEntry.timestamp > recentEntry.timestamp + ) { + return currentEntry; + } + + return recentEntry; + }); + } catch (error) { + console.error('Failed to load recent wallet activity:', error); + setRecentWalletActivity((currentEntry) => + currentEntry && isWalletActivityTimestampRecent(currentEntry.timestamp) + ? currentEntry + : null + ); + } finally { + setIsWalletActivityLoading(false); + } + }, [buildWalletActivityEntry, userAddress]); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setWalletActivityRelativeTimeNow(Date.now()); + }, 60000); + + return () => { + window.clearInterval(intervalId); + }; + }, []); + + useEffect(() => { + setRecentWalletActivity(null); + lastWalletActivityBalanceRef.current = null; + }, [userAddress]); + + useEffect(() => { + if (!userAddress || balance == null) { + return; + } + + const nextBalanceKey = String(balance); + if (lastWalletActivityBalanceRef.current == null) { + lastWalletActivityBalanceRef.current = nextBalanceKey; + return; + } + + if (lastWalletActivityBalanceRef.current === nextBalanceKey) { + return; + } + + lastWalletActivityBalanceRef.current = nextBalanceKey; + const refreshTimer = window.setTimeout(() => { + loadRecentWalletActivity(); + }, 650); + + return () => { + window.clearTimeout(refreshTimer); + }; + }, [balance, loadRecentWalletActivity, userAddress]); + + useEffect(() => { + loadRecentWalletActivity(); + }, [loadRecentWalletActivity]); + + return ( + + + + } + label={td('send', 'Send')} + onClick={(event) => { + const el = event.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + const rightRailRect = getRightRailRectFromElement(el); + executeEvent('openPaymentInternal', { + anchorRect: { + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }, + targetRect: rightRailRect + ? { + height: rightRailRect.height, + left: rightRailRect.left, + top: rightRailRect.top, + width: rightRailRect.width, + } + : null, + }); + }} + theme={theme} + /> + } + label={td('receive', 'Receive')} + onClick={(event) => { + handleOpenReceiveQort(event.currentTarget as HTMLElement); + }} + theme={theme} + /> + } + label={td('buy', 'Buy')} + onClick={() => { + executeEvent('addTab', { + data: { service: 'APP', name: 'q-trade' }, + }); + executeEvent('open-apps-mode', {}); + }} + theme={theme} + /> + + + + {td('recent_transaction', 'Recent Transaction')} + + {isWalletActivityLoading ? ( + + {td( + 'loading_wallet_activity', + 'Loading recent wallet activity...' + )} + + ) : recentWalletActivity ? ( + [recentWalletActivity].map((activityEntry, index) => ( + + + + {formatWalletActivityAmount( + activityEntry.amount, + activityEntry.direction + )} + + + + {activityEntry.direction === 'outgoing' + ? td('sent_to', 'sent to ') + : td('received_from', 'received from ')} + + { + event.stopPropagation(); + handleOpenWalletActivityCounterparty( + activityEntry.counterpartyAddress + ); + }} + sx={{ + borderRadius: '6px', + color: theme.palette.text.primary, + display: 'inline-flex', + font: 'inherit', + fontWeight: 600, + lineHeight: 'inherit', + maxWidth: '100%', + minWidth: 0, + p: 0, + textAlign: 'left', + verticalAlign: 'baseline', + '&:hover': { + color: theme.palette.primary.light, + textDecoration: 'underline', + textUnderlineOffset: '2px', + }, + }} + > + {activityEntry.counterpartyLabel} + + + + + {formatWalletActivityRelativeTime( + activityEntry.timestamp, + walletActivityRelativeTimeNow, + tDashboard + )} + + + )) + ) : ( + + {td('no_wallet_activity', 'No new wallet activity.')} + + )} + + + {td( + 'wallet_activity_window', + 'Latest transaction within the past 7 days' + )} + + + + + ); +}; diff --git a/src/components/Group/HomeDesktop/InfoPreviewPanel.tsx b/src/components/Group/HomeDesktop/InfoPreviewPanel.tsx new file mode 100644 index 00000000..4978873a --- /dev/null +++ b/src/components/Group/HomeDesktop/InfoPreviewPanel.tsx @@ -0,0 +1,914 @@ +import { + Box, + ButtonBase, + CircularProgress, + Menu, + MenuItem, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import { alpha } from '@mui/material/styles'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { InfoPreviewPrimaryRow } from './infoPreviewPanelTypes'; +export type { + InfoPreviewPanelRows, + InfoPreviewStatusTone, +} from './infoPreviewPanelTypes'; +import { useDashboardInfoPreviewRows } from './useDashboardInfoPreviewRows'; +import { useDashboardNodeMenu } from './useDashboardNodeMenu'; +import { + nodeMenuItemSx, + normalizeDashboardNodeUrl, +} from './utils'; +import { GROUP_ACTIVITY_BLUE } from '../groupActivityColorSystem'; +import { + dashboardPanelSx, + handleDashboardPanelPointerLeave, + handleDashboardPanelPointerMove, + useDashboardPanelMouseLight, +} from '../dashboardPanelEffects'; +import { ProgressiveBlur } from '../../ui/progressive-blur'; +import { + HOME_WIDE_DASHBOARD_MIN_WIDTH_PX, + INFO_PANEL_EXPAND_CLOSE_DELAY_MS, + INFO_PANEL_EXPAND_OPEN_DELAY_MS, + INFO_PANEL_EXPANDED_EXTRA_BREATHING_PX, + SYSTEM_BADGE_SX, +} from './homeDesktopConstants'; + +const sepSx = (theme) => ({ + borderBottom: `1px solid ${theme.palette.border.subtle}`, +}); + +const infoSepSx = (theme, _index, _total) => sepSx(theme); + +export const InfoPreviewPanel = ({ + maxExpandedHeightPx = null, +}: { + maxExpandedHeightPx?: number | null; +}) => { + const theme = useTheme(); + const { + dashboardNodeOptions, + handleCloseNodeMenu, + handleOpenNodeMenu, + handleSelectDashboardNode, + isSwitchingNodeUrl, + nodeMenuAnchorEl, + nodeSwitchError, + selectedNodeUrl, + td, + } = useDashboardNodeMenu(); + const { i18n } = useTranslation(['core', 'group', 'tutorial', 'auth']); + const resetKey = ( + i18n.resolvedLanguage || + i18n.language || + 'en' + ).split('-')[0]; + const rows = useDashboardInfoPreviewRows({ + nodeMenuAnchorEl, + onOpenNodeMenu: handleOpenNodeMenu, + }); + const forceExpanded = Boolean(nodeMenuAnchorEl); + const enableOverlay = useMediaQuery( + theme.breakpoints.up(HOME_WIDE_DASHBOARD_MIN_WIDTH_PX) + ); + const panelRef = useDashboardPanelMouseLight(); + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const openTimerRef = useRef(null); + const closeTimerRef = useRef(null); + const [collapsedHeight, setCollapsedHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); + const [isExpanded, setIsExpanded] = useState(false); + const footerSectionCount = rows.footerSections.length; + const footerItemCount = rows.footerSections.reduce( + (total, section) => total + section.items.length, + 0 + ); + + const clearHoverTimers = () => { + if (openTimerRef.current !== null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + + useEffect(() => { + return () => clearHoverTimers(); + }, []); + + useEffect(() => { + clearHoverTimers(); + setIsExpanded(false); + setCollapsedHeight(0); + setContentHeight(0); + }, [resetKey]); + + useEffect(() => { + if (!enableOverlay) { + setIsExpanded(false); + return; + } + + const wrapperNode = wrapperRef.current; + const contentNode = contentRef.current; + if (!wrapperNode || !contentNode) return; + + const updateMeasurements = () => { + setCollapsedHeight(wrapperNode.getBoundingClientRect().height); + setContentHeight(contentNode.scrollHeight); + }; + + updateMeasurements(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateMeasurements); + return () => { + window.removeEventListener('resize', updateMeasurements); + }; + } + + const resizeObserver = new ResizeObserver(updateMeasurements); + resizeObserver.observe(wrapperNode); + resizeObserver.observe(contentNode); + window.addEventListener('resize', updateMeasurements); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateMeasurements); + }; + }, [ + enableOverlay, + footerItemCount, + footerSectionCount, + resetKey, + rows.metricItems.length, + rows.primaryItems.length, + ]); + + const hasOverflow = + enableOverlay && collapsedHeight > 0 && contentHeight > collapsedHeight + 4; + const isEffectivelyExpanded = isExpanded || (forceExpanded && hasOverflow); + const resolvedCollapsedHeight = + collapsedHeight > 0 ? collapsedHeight : undefined; + const rawExpandedHeight = resolvedCollapsedHeight + ? Math.max( + resolvedCollapsedHeight, + contentHeight + INFO_PANEL_EXPANDED_EXTRA_BREATHING_PX + ) + : contentHeight + INFO_PANEL_EXPANDED_EXTRA_BREATHING_PX; + const expandedHeight = + maxExpandedHeightPx != null + ? Math.max( + resolvedCollapsedHeight ?? 0, + Math.min(rawExpandedHeight, maxExpandedHeightPx) + ) + : rawExpandedHeight; + + const handleMouseEnter = () => { + if (!hasOverflow) return; + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + if (isExpanded || openTimerRef.current !== null) return; + openTimerRef.current = window.setTimeout(() => { + openTimerRef.current = null; + setIsExpanded(true); + }, INFO_PANEL_EXPAND_OPEN_DELAY_MS); + }; + + const handleMouseLeave = (event: MouseEvent) => { + handleDashboardPanelPointerLeave(event); + if (forceExpanded) return; + if (!hasOverflow) return; + if (openTimerRef.current !== null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (!isExpanded || closeTimerRef.current !== null) return; + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + setIsExpanded(false); + }, INFO_PANEL_EXPAND_CLOSE_DELAY_MS); + }; + + const showCollapsedFade = hasOverflow && !isEffectivelyExpanded; + const statusAccentColor = + rows.status.tone === 'issue' + ? theme.palette.mode === 'dark' + ? alpha(theme.palette.error.light, 0.9) + : alpha(theme.palette.error.main, 0.88) + : rows.status.tone === 'syncing' + ? theme.palette.mode === 'dark' + ? alpha(theme.palette.warning.light, 0.9) + : alpha(theme.palette.warning.main, 0.88) + : theme.palette.mode === 'dark' + ? alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.96) + : alpha(GROUP_ACTIVITY_BLUE.gradientBottom, 0.92); + const statusGlowColor = + rows.status.tone === 'issue' + ? alpha(theme.palette.error.light, 0.16) + : rows.status.tone === 'syncing' + ? alpha(theme.palette.warning.light, 0.18) + : alpha(GROUP_ACTIVITY_BLUE.primary, 0.18); + + const renderPrimaryValue = (row: InfoPreviewPrimaryRow) => { + if (row.valueNode) return row.valueNode; + + if (row.variant === 'pill') { + const pillTone = + row.pillTone === 'negative' + ? { + background: + theme.palette.mode === 'dark' + ? 'rgba(104, 70, 74, 0.32)' + : 'rgba(168, 90, 90, 0.12)', + border: alpha( + theme.palette.error.light, + theme.palette.mode === 'dark' ? 0.16 : 0.22 + ), + color: + theme.palette.mode === 'dark' + ? alpha(theme.palette.error.light, 0.88) + : alpha(theme.palette.error.dark, 0.88), + } + : row.pillTone === 'warning' + ? { + background: + theme.palette.mode === 'dark' + ? 'rgba(123, 102, 62, 0.3)' + : 'rgba(173, 140, 74, 0.14)', + border: alpha( + theme.palette.warning.light, + theme.palette.mode === 'dark' ? 0.18 : 0.24 + ), + color: + theme.palette.mode === 'dark' + ? alpha(theme.palette.warning.light, 0.9) + : alpha(theme.palette.warning.dark, 0.88), + } + : { + background: + theme.palette.mode === 'dark' + ? 'rgba(88, 122, 178, 0.3)' + : 'rgba(117, 161, 227, 0.15)', + border: alpha( + GROUP_ACTIVITY_BLUE.gradientTop, + theme.palette.mode === 'dark' ? 0.18 : 0.24 + ), + color: + theme.palette.mode === 'dark' + ? alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.92) + : alpha(GROUP_ACTIVITY_BLUE.pressed, 0.9), + }; + return ( + + {row.value} + + ); + } + + return ( + + {row.value} + + ); + }; + + return ( + + + + + *': { + flexShrink: 0, + }, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + position: 'relative', + width: '100%', + }} + > + + + + {td('info_panel_status', 'status')} + + + + + + + + {rows.primaryItems.map((row, index) => ( + + + {row.label} + + + {renderPrimaryValue(row)} + + + ))} + + + {rows.metricItems.map((metric) => ( + + + {metric.label} + + + {metric.value} + + + ))} + + + + {rows.footerSections.map((section, sectionIndex) => { + const isNodeSection = section.variant === 'node'; + const sectionHeaderLabel = isNodeSection + ? td('info_panel_node_comment', '// node_info') + : section.title; + + return ( + + + {sectionHeaderLabel} + + + {section.items.map((row, index) => ( + + {row.labelAction ? ( + + + + {row.label} + + + + + ) : ( + + {row.label} + + )} + {row.valueNode || ( + + {row.value} + + )} + + ))} + + ); + })} + + + + {showCollapsedFade && ( + + )} + + + + + {dashboardNodeOptions.filter((option) => option.type === 'custom') + .length === 0 && ( + + {td('no_custom_nodes_saved', 'No custom nodes saved')} + + )} + {dashboardNodeOptions.map((option) => { + const isCurrent = + normalizeDashboardNodeUrl(option.node.url) === selectedNodeUrl; + const isSwitching = + isSwitchingNodeUrl === normalizeDashboardNodeUrl(option.node.url); + return ( + handleSelectDashboardNode(option)} + sx={{ + ...nodeMenuItemSx(theme, isCurrent), + ...(option.type === 'public' + ? { + borderTop: `1px solid ${alpha( + theme.palette.text.primary, + 0.08 + )}`, + mt: 0.55, + } + : {}), + }} + > + + + {option.label} + + + {option.secondary} + + + {isSwitching ? ( + + ) : isCurrent ? ( + + ) : null} + + ); + })} + {nodeSwitchError && ( + + {nodeSwitchError} + + )} + + + ); +}; diff --git a/src/components/Group/HomeDesktop/WalletActionButton.tsx b/src/components/Group/HomeDesktop/WalletActionButton.tsx new file mode 100644 index 00000000..3d461137 --- /dev/null +++ b/src/components/Group/HomeDesktop/WalletActionButton.tsx @@ -0,0 +1,82 @@ +import { Box, ButtonBase, Typography } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { + APP_BLUE_SURFACE_TEXT, + getBlueTier1ButtonSx, +} from '../groupActivityColorSystem'; + +export const WalletActionButton = ({ icon, label, onClick, theme }) => { + const blueStrongHover = getBlueTier1ButtonSx()['&:hover']; + const isDarkMode = theme.palette.mode === 'dark'; + + return ( + + + {icon} + + + {label} + + + ); +}; diff --git a/src/components/Group/HomeDesktop/homeDesktopConstants.ts b/src/components/Group/HomeDesktop/homeDesktopConstants.ts new file mode 100644 index 00000000..f8a77f48 --- /dev/null +++ b/src/components/Group/HomeDesktop/homeDesktopConstants.ts @@ -0,0 +1,102 @@ +import type { WidgetDisplayMode } from '../../Widgets/DashboardWidgetFrame'; +import type { HomeCustomizableCardId } from './types'; + +/** Wide hub layout activates at this min viewport width (below theme xl / 1536). */ +export const HOME_WIDE_DASHBOARD_MIN_WIDTH_PX = 1250; + +export const INFO_PANEL_EXPAND_OPEN_DELAY_MS = 35; +export const INFO_PANEL_EXPAND_CLOSE_DELAY_MS = 60; +export const INFO_PANEL_EXPANDED_EXTRA_BREATHING_PX = 52; + +export const SYSTEM_BADGE_SX = { + borderRadius: '4px', + fontSize: '0.7rem', + fontWeight: 700, + height: '26px', + letterSpacing: '0.05em', + lineHeight: 1, + px: '10px', + textTransform: 'uppercase', + whiteSpace: 'nowrap', +} as const; + +export const GROUP_ACTIVITY_COMPACT_VIEWPORT_HEIGHT_PX = 680; + +// Home dashboard desktop layout invariants: +// - Info top aligns visually with Account Overview top. +// - Account Overview -> Featured Q-Apps gap = 20px. +// - Info -> Wallet Activity gap = 20px. +// - Info collapsed height stays fixed to preserve spacing and overlay behavior. +export const HOME_DASHBOARD_VERTICAL_GAP_PX = 20; +export const HOME_SHARED_SIDE_RAIL_WIDTH_MD = 'minmax(285px, 330px)'; +export const HOME_SHARED_SIDE_RAIL_WIDTH_XL = 'minmax(310px, 360px)'; +export const HOME_LEFT_CENTER_GRID_TEMPLATE_COLUMNS = { + xs: '1fr', + md: `${HOME_SHARED_SIDE_RAIL_WIDTH_MD} minmax(0, 1fr)`, + xl: `${HOME_SHARED_SIDE_RAIL_WIDTH_XL} minmax(0, 1fr)`, +} as const; +export const HOME_LEFT_CENTER_LOWER_ROW_GRID_TEMPLATE_COLUMNS = { + xs: '1fr', + lg: `${HOME_SHARED_SIDE_RAIL_WIDTH_MD} minmax(0, 1fr)`, +} as const; +// Right rail is offset to visually align Info with Account Overview. +// The left column includes the "Qortal Hub" eyebrow label above Account Overview, +// while the right column starts directly with the rail cards, so this offset +// compensates for that extra left-side content. The alignment is visual, not structural. +export const HOME_RIGHT_RAIL_TOP_ALIGNMENT_OFFSET_PX = 29; +export const HOME_INFO_COLLAPSED_VISIBLE_HEIGHT_PX = 322; +export const HOME_SHARED_LEFT_LOWER_ROW_PANEL_HEIGHT_PX = 426; +export const HOME_EMBEDDED_QAPP_PANEL_HEIGHT_PX = 720; +export const HOME_GROUP_ACTIVITY_CARD_CHROME_HEIGHT_PX = 100; +export const HOME_GROUP_ACTIVITY_CARD_DEFAULT_HEIGHT_PX = + GROUP_ACTIVITY_COMPACT_VIEWPORT_HEIGHT_PX + + HOME_GROUP_ACTIVITY_CARD_CHROME_HEIGHT_PX; +export const HOME_CUSTOMIZABLE_CARD_LAYOUT_STORAGE_KEY = + 'home-dashboard-customizable-cards-layout-v1'; +export const HOME_CUSTOMIZABLE_CARD_RESIZE_STEP_PX = 60; +export const HOME_DASHBOARD_WIDGET_HEIGHT_PX = 612; +export const HOME_DASHBOARD_WIDGET_DISPLAY_MODE: WidgetDisplayMode = 'expanded'; +export const HOME_CUSTOMIZABLE_CARD_MIN_HEIGHTS: Record< + HomeCustomizableCardId, + number +> = { + groupActivity: HOME_DASHBOARD_WIDGET_HEIGHT_PX, + quitter: HOME_DASHBOARD_WIDGET_HEIGHT_PX, +}; +export const HOME_CUSTOMIZABLE_CARD_MAX_HEIGHTS: Record< + HomeCustomizableCardId, + number +> = { + groupActivity: HOME_DASHBOARD_WIDGET_HEIGHT_PX, + quitter: HOME_DASHBOARD_WIDGET_HEIGHT_PX, +}; +export const HOME_QUITTER_WIDGET_INITIAL_BATCH_SIZES: Record< + WidgetDisplayMode, + number +> = { + compact: 6, + expanded: 8, +}; +export const HOME_QUITTER_WIDGET_LOAD_MORE_BATCH_SIZES: Record< + WidgetDisplayMode, + number +> = { + compact: 4, + expanded: 4, +}; +export const HOME_QUITTER_WIDGET_SEARCH_LIMITS: Record = + { + compact: 6, + expanded: 8, + }; +export const WALLET_ACTIVITY_RECENT_PAYMENT_LOOKBACK_MS = + 7 * 24 * 60 * 60 * 1000; +export const WALLET_ACTIVITY_RECENT_PAYMENT_FETCH_LIMIT = 50; +export const INFO_VALUE_COLUMN_MIN_WIDTH_PX = 136; + +export const DASHBOARD_MINTER_DEFAULT_VIEW_STORAGE_KEY = + 'dashboardMinterDefaultView'; +export const HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT: HomeCustomizableCardId[] = [ + 'groupActivity', + 'quitter', +]; diff --git a/src/components/Group/HomeDesktop/index.ts b/src/components/Group/HomeDesktop/index.ts new file mode 100644 index 00000000..8aaba5a5 --- /dev/null +++ b/src/components/Group/HomeDesktop/index.ts @@ -0,0 +1 @@ +export { HomeDesktop } from './HomeDesktop'; diff --git a/src/components/Group/HomeDesktop/infoPreviewPanelTypes.ts b/src/components/Group/HomeDesktop/infoPreviewPanelTypes.ts new file mode 100644 index 00000000..0965ea60 --- /dev/null +++ b/src/components/Group/HomeDesktop/infoPreviewPanelTypes.ts @@ -0,0 +1,49 @@ +import type { MouseEvent, ReactNode } from 'react'; + +export type InfoPreviewStatusTone = 'operational' | 'syncing' | 'issue'; + +export type InfoPreviewPrimaryRow = { + label: string; + emphasize?: boolean; + value?: string; + valueNode?: ReactNode; + variant?: 'pill'; + pillTone?: 'negative' | 'warning' | 'positive'; +}; + +export type InfoPreviewMetricItem = { + label: string; + value: string; + accent?: string; +}; + +export type InfoPreviewFooterRow = { + label: string; + value?: string; + valueNode?: ReactNode; + labelAction?: { + ariaLabel: string; + isOpen: boolean; + onClick: (event: MouseEvent) => void; + tooltip: string; + }; +}; + +export type InfoPreviewFooterSection = { + title: string; + /** Stable section semantics (title is localized and must not be used for branching). */ + variant?: 'node'; + offsetTopPx?: number; + items: InfoPreviewFooterRow[]; +}; + +export type InfoPreviewPanelRows = { + status: { + tone: InfoPreviewStatusTone; + isOperational?: boolean; + label?: string; + }; + primaryItems: InfoPreviewPrimaryRow[]; + metricItems: InfoPreviewMetricItem[]; + footerSections: InfoPreviewFooterSection[]; +}; diff --git a/src/components/Group/HomeDesktop/types.ts b/src/components/Group/HomeDesktop/types.ts new file mode 100644 index 00000000..341665ba --- /dev/null +++ b/src/components/Group/HomeDesktop/types.ts @@ -0,0 +1,54 @@ +import type { ApiKey } from '../../../types/auth'; + +export type HomeCustomizableCardId = 'groupActivity' | 'quitter'; +export type MinterProgressSnapshot = { + currentBlocks: number; + currentLevel: number; + progressRatio: number; + requiredBlocks: number; +}; +export type MinterInfoView = 'dots' | 'progress'; +export type WalletActivityTransaction = { + amount?: number | string; + creator?: string; + creatorAddress?: string; + recipientAddress?: string; + recipient?: string; + sender?: string; + senderAddress?: string; + signature?: string; + timestamp?: number | string; +}; +export type WalletActivityDirection = 'incoming' | 'outgoing'; +export type WalletActivityEntry = { + amount: number; + counterpartyAddress: string; + counterpartyLabel: string; + direction: WalletActivityDirection; + timestamp: number; +}; +export type DashboardNodeOption = { + key: string; + label: string; + node: ApiKey; + secondary: string; + type: 'custom' | 'local' | 'public'; +}; +export type HomeCustomizableCardsLayout = { + heights: Partial>; + order: HomeCustomizableCardId[]; +}; +export type HomeLayoutDebugMetric = { + bottom: number; + height: number; + left: number; + top: number; + width: number; +}; +export type HomeLayoutDebugKey = + | 'accountOverview' + | 'featuredApps' + | 'info' + | 'profileCard' + | 'tools' + | 'walletActivity'; diff --git a/src/components/Group/HomeDesktop/useDashboardInfoPreviewRows.tsx b/src/components/Group/HomeDesktop/useDashboardInfoPreviewRows.tsx new file mode 100644 index 00000000..98197a61 --- /dev/null +++ b/src/components/Group/HomeDesktop/useDashboardInfoPreviewRows.tsx @@ -0,0 +1,782 @@ +import { Box, ButtonBase, Tooltip, Typography, useTheme } from '@mui/material'; +import LockOpenRoundedIcon from '@mui/icons-material/LockOpenRounded'; +import { alpha, type SxProps, type Theme } from '@mui/material/styles'; +import { + useCallback, + useEffect, + useMemo, + useState, + type MouseEvent, +} from 'react'; +import { useAtomValue } from 'jotai'; +import { + AnimatePresence, + motion, +} from 'framer-motion'; +import { useTranslation } from 'react-i18next'; +import { + balanceAtom, + nodeInfosAtom, + selectedNodeInfoAtom, + userInfoAtom, +} from '../../../atoms/global'; +import { getBaseApiReact } from '../../../App'; +import { manifestData } from '../../NotAuthenticated'; +import { executeEvent } from '../../../utils/events'; +import { accountTargetBlocks } from '../../Minting/MintingStats'; +import { + GROUP_ACTIVITY_BLUE, + getBlueTier3DotSx, +} from '../groupActivityColorSystem'; +import { useHandleUserInfo } from '../../../hooks/useHandleUserInfo'; +import { + isLocalNodeUrl, +} from '../../../constants/constants'; +import { nodeDisplay } from '../../../utils/helpers'; +import { BlockHeightValue } from './BlockHeightValue'; +import type { + InfoPreviewPanelRows, + InfoPreviewStatusTone, +} from './infoPreviewPanelTypes'; +import { + DASHBOARD_MINTER_DEFAULT_VIEW_STORAGE_KEY, + INFO_VALUE_COLUMN_MIN_WIDTH_PX, +} from './homeDesktopConstants'; +import type { MinterInfoView, MinterProgressSnapshot } from './types'; +import { parseMinterInfoView } from './utils'; + +type UseDashboardInfoPreviewRowsParams = { + nodeMenuAnchorEl: HTMLElement | null; + onOpenNodeMenu: (event: MouseEvent) => void; +}; + +export function useDashboardInfoPreviewRows({ + nodeMenuAnchorEl, + onOpenNodeMenu, +}: UseDashboardInfoPreviewRowsParams): InfoPreviewPanelRows { + const theme = useTheme(); + const balance = useAtomValue(balanceAtom); + const nodeInfos = useAtomValue(nodeInfosAtom); + const selectedNode = useAtomValue(selectedNodeInfoAtom); + const userInfo = useAtomValue(userInfoAtom); + const userAddress = userInfo?.address; + const { t } = useTranslation(['core', 'group', 'tutorial', 'auth']); + const td = useCallback( + ( + key: string, + defaultValue: string, + options?: Record + ) => + String( + t(`group:dashboard.${key}`, { + defaultValue, + ...options, + }) + ), + [t] + ); + + const [coreVersionLabel, setCoreVersionLabel] = useState('—'); + const [minterLevel, setMinterLevel] = useState(null); + const [minterProgress, setMinterProgress] = + useState(null); + const [minterDefaultView, setMinterDefaultView] = useState( + () => + parseMinterInfoView( + localStorage.getItem(DASHBOARD_MINTER_DEFAULT_VIEW_STORAGE_KEY) + ) + ); + const [isMinterFieldHovered, setIsMinterFieldHovered] = useState(false); + + const getIndividualUserInfo = useHandleUserInfo(); + + const filledBlueDotSx = getBlueTier3DotSx(theme, true); + const emptyBlueDotSx = getBlueTier3DotSx(theme, false); + + useEffect(() => { + let active = true; + if (!userAddress) { + setMinterLevel(null); + return; + } + getIndividualUserInfo(userAddress) + .then((level) => { + if (active) setMinterLevel(typeof level === 'number' ? level : null); + }) + .catch(() => { + if (active) setMinterLevel(null); + }); + return () => { + active = false; + }; + }, [getIndividualUserInfo, userAddress]); + + useEffect(() => { + let active = true; + + const loadMinterProgress = async () => { + if (!userAddress) { + if (active) setMinterProgress(null); + return; + } + + try { + const response = await fetch( + `${getBaseApiReact()}/addresses/${userAddress}` + ); + if (!response.ok) { + throw new Error('network error'); + } + + const data = await response.json(); + if (!active) return; + + const currentLevel = + typeof data?.level === 'number' && Number.isFinite(data.level) + ? data.level + : null; + const mintedBlocks = + typeof data?.blocksMinted === 'number' && + Number.isFinite(data.blocksMinted) + ? data.blocksMinted + : 0; + const mintedAdjustment = + typeof data?.blocksMintedAdjustment === 'number' && + Number.isFinite(data.blocksMintedAdjustment) + ? data.blocksMintedAdjustment + : 0; + const currentBlocks = Math.max(0, mintedBlocks + mintedAdjustment); + const requiredBlocks = + currentLevel != null + ? currentLevel >= 10 + ? currentBlocks + : accountTargetBlocks(currentLevel) + : undefined; + + if (currentLevel == null || requiredBlocks == null) { + setMinterProgress(null); + return; + } + + setMinterProgress({ + currentBlocks, + currentLevel, + progressRatio: + requiredBlocks > 0 + ? Math.max(0, Math.min(1, currentBlocks / requiredBlocks)) + : 0, + requiredBlocks, + }); + } catch { + if (active) { + setMinterProgress(null); + } + } + }; + + loadMinterProgress(); + const interval = window.setInterval(loadMinterProgress, 30000); + + return () => { + active = false; + window.clearInterval(interval); + }; + }, [userAddress]); + + useEffect(() => { + let active = true; + + const loadCoreInfo = async () => { + try { + const response = await fetch(`${getBaseApiReact()}/admin/info`, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + const data = await response.json(); + if (!active) return; + setCoreVersionLabel( + data?.buildVersion ? String(data.buildVersion).substring(0, 20) : '—' + ); + } catch { + if (active) { + setCoreVersionLabel('—'); + } + } + }; + + loadCoreInfo(); + const interval = window.setInterval(loadCoreInfo, 30000); + + return () => { + active = false; + window.clearInterval(interval); + }; + }, []); + + const handleSetMinterDefaultView = useCallback((nextView: MinterInfoView) => { + setMinterDefaultView(nextView); + localStorage.setItem(DASHBOARD_MINTER_DEFAULT_VIEW_STORAGE_KEY, nextView); + }, []); + + const balanceLabel = + balance != null ? `${Number(balance).toFixed(2)} QORT` : '—'; + + const hasLiveNodeConnection = nodeInfos?.height != null; + const liveSyncPercent = + hasLiveNodeConnection && + nodeInfos?.isSynchronizing && + nodeInfos?.syncPercent !== 100 + ? Math.round(nodeInfos?.syncPercent || 0) + : 100; + const nodeStatusValue = hasLiveNodeConnection + ? td('sync_percent', '{{percent}}% Synced', { + percent: liveSyncPercent, + }) + : td('node_unavailable', 'Node unavailable'); + const peersLabel = `${nodeInfos?.numberOfConnections || 0}`; + const blockHeightLabel = `${nodeInfos?.height || '—'}`; + const hubVersionLabel = manifestData.version || '—'; + const qdnPeersLabel = `${nodeInfos?.numberOfDataConnections || 0}`; + + const nodeBase = getBaseApiReact(); + const nodeHostLabel = (() => { + try { + return new URL(nodeBase).host; + } catch { + return nodeDisplay(nodeBase); + } + })(); + const customNodeDashboardLabel = + selectedNode?.name?.trim() || selectedNode?.url?.trim() || ''; + const nodeTypeLabel = isLocalNodeUrl(nodeBase) + ? td('local_node', 'Local node') + : nodeBase.includes('ext-node.qortal.link') + ? td('public_node', 'Public node') + : customNodeDashboardLabel || + td('custom_node', 'Custom node'); + const isSystemOperational = + hasLiveNodeConnection && + !(nodeInfos?.isSynchronizing && nodeInfos?.syncPercent !== 100); + const resolvedInfoStatusLabel = isSystemOperational + ? td('fully_operational', 'Fully operational') + : td('not_operational', 'Not operational'); + const resolvedIsSystemOperational = isSystemOperational; + const resolvedInfoStatusTone: InfoPreviewStatusTone = + nodeInfos?.isSynchronizing && nodeInfos?.syncPercent !== 100 + ? 'syncing' + : resolvedIsSystemOperational + ? 'operational' + : 'issue'; + + const resolvedCoreVersionLabel = coreVersionLabel; + const isMinterOn = Boolean(minterLevel && minterLevel > 0); + const minterDotsFilled = isMinterOn + ? Math.max(1, Math.min(9, minterLevel ?? 5)) + : 0; + const formattedMinterCurrentBlocks = + minterProgress?.currentBlocks != null + ? minterProgress.currentBlocks.toLocaleString() + : null; + const formattedMinterRequiredBlocks = + minterProgress?.requiredBlocks != null + ? minterProgress.requiredBlocks.toLocaleString() + : null; + const hasMinterProgressSummary = + minterProgress != null && + formattedMinterCurrentBlocks != null && + formattedMinterRequiredBlocks != null; + const resolvedMinterDefaultView: MinterInfoView = + hasMinterProgressSummary && minterDefaultView === 'progress' + ? 'progress' + : 'dots'; + const minterHoverView: MinterInfoView = + resolvedMinterDefaultView === 'dots' ? 'progress' : 'dots'; + const isShowingMinterHoverView = + hasMinterProgressSummary && isMinterFieldHovered; + const activeMinterInfoView: MinterInfoView = isShowingMinterHoverView + ? minterHoverView + : resolvedMinterDefaultView; + const minterPinActionLabel = + activeMinterInfoView === 'progress' + ? td( + 'minter_pin_save_progress_default', + 'Save level bar as default view' + ) + : td('minter_pin_save_dots_default', 'Save minting dots as default view'); + + const minterValue = useMemo( + () => ( + + ), + [ + activeMinterInfoView, + emptyBlueDotSx, + filledBlueDotSx, + formattedMinterCurrentBlocks, + formattedMinterRequiredBlocks, + handleSetMinterDefaultView, + hasMinterProgressSummary, + isMinterOn, + isShowingMinterHoverView, + minterDotsFilled, + minterPinActionLabel, + minterProgress, + td, + theme, + ] + ); + + const coreVersionMetricLabel = + resolvedCoreVersionLabel && resolvedCoreVersionLabel !== '—' + ? resolvedCoreVersionLabel.replace(/^qortal-/i, '').split('-')[0] || + resolvedCoreVersionLabel + : '—'; + + return { + status: { + isOperational: resolvedIsSystemOperational, + label: resolvedInfoStatusLabel, + tone: resolvedInfoStatusTone, + }, + primaryItems: [ + { + emphasize: true, + label: td('qort_balance', 'QORT Balance'), + value: balanceLabel, + }, + { + label: nodeTypeLabel, + pillTone: + nodeStatusValue === td('node_unavailable', 'Node unavailable') + ? 'negative' + : nodeStatusValue === + td('sync_percent', '{{percent}}% Synced', { percent: 100 }) + ? 'positive' + : 'warning', + value: nodeStatusValue, + variant: 'pill', + }, + { + label: td('minter_level', 'Minter Level'), + valueNode: minterValue, + }, + ], + metricItems: [ + { + accent: 'blue', + label: td('peers', 'Peers'), + value: peersLabel, + }, + { + accent: 'blue', + label: td('qdn', 'QDN'), + value: qdnPeersLabel, + }, + { + accent: 'green', + label: td('core', 'Core'), + value: coreVersionMetricLabel, + }, + { + accent: 'violet', + label: td('hub', 'Hub'), + value: hubVersionLabel, + }, + ], + footerSections: [ + { + variant: 'node', + title: td('node', 'Node'), + offsetTopPx: 10, + items: [ + { + label: td('using_node', 'Using Node'), + labelAction: { + ariaLabel: td('change_node', 'Change node'), + isOpen: Boolean(nodeMenuAnchorEl), + onClick: onOpenNodeMenu, + tooltip: td('change_node', 'Change node'), + }, + value: nodeHostLabel, + }, + { + label: td('node_type', 'Node Type'), + value: nodeTypeLabel, + }, + { + label: td('node_height', 'Node Height'), + value: blockHeightLabel, + valueNode: ( + + ), + }, + ], + }, + ], + }; +} + +function MinterInfoValue({ + activeMinterInfoView, + emptyBlueDotSx, + filledBlueDotSx, + formattedMinterCurrentBlocks, + formattedMinterRequiredBlocks, + handleSetMinterDefaultView, + hasMinterProgressSummary, + isMinterOn, + isShowingMinterHoverView, + minterDotsFilled, + minterPinActionLabel, + minterProgress, + onHoverMinterChange, + td, + theme, +}: { + activeMinterInfoView: MinterInfoView; + emptyBlueDotSx: SxProps; + filledBlueDotSx: SxProps; + formattedMinterCurrentBlocks: string | null; + formattedMinterRequiredBlocks: string | null; + handleSetMinterDefaultView: (view: MinterInfoView) => void; + hasMinterProgressSummary: boolean; + isMinterOn: boolean; + isShowingMinterHoverView: boolean; + minterDotsFilled: number; + minterPinActionLabel: string; + minterProgress: MinterProgressSnapshot | null; + onHoverMinterChange: (hovered: boolean) => void; + td: ( + key: string, + defaultValue: string, + options?: Record + ) => string; + theme: Theme; +}) { + return ( + + + {isMinterOn ? ( + + onHoverMinterChange(true) + : undefined + } + onMouseLeave={ + hasMinterProgressSummary + ? () => onHoverMinterChange(false) + : undefined + } + onFocusCapture={ + hasMinterProgressSummary + ? () => onHoverMinterChange(true) + : undefined + } + onBlurCapture={ + hasMinterProgressSummary + ? (event) => { + const nextFocusedElement = event.relatedTarget; + + if ( + !(nextFocusedElement instanceof Node) || + !event.currentTarget.contains(nextFocusedElement) + ) { + onHoverMinterChange(false); + } + } + : undefined + } + sx={{ + alignItems: 'center', + display: 'inline-flex', + height: '22px', + justifyContent: 'flex-end', + maxWidth: '100%', + minWidth: '180px', + width: '180px', + }} + > + + + {isShowingMinterHoverView ? ( + + + handleSetMinterDefaultView(activeMinterInfoView) + } + sx={{ + alignItems: 'center', + borderRadius: '999px', + color: alpha( + GROUP_ACTIVITY_BLUE.primary, + theme.palette.mode === 'dark' ? 0.92 : 0.82 + ), + display: 'inline-flex', + flexShrink: 0, + height: '18px', + justifyContent: 'center', + transition: + 'background-color 140ms ease, color 140ms ease, transform 120ms ease', + width: '18px', + '&:hover': { + backgroundColor: alpha( + GROUP_ACTIVITY_BLUE.primary, + theme.palette.mode === 'dark' ? 0.18 : 0.12 + ), + color: GROUP_ACTIVITY_BLUE.primary, + transform: 'translateY(-1px)', + }, + '&:active': { + transform: 'translateY(0)', + }, + }} + > + + + + ) : null} + + {activeMinterInfoView === 'progress' && + hasMinterProgressSummary ? ( + + + + + + {formattedMinterCurrentBlocks} /{' '} + {formattedMinterRequiredBlocks} + + + ) : ( + + {Array.from({ length: 9 }).map((_, index) => ( + + ))} + + )} + + + + + ) : ( + + { + executeEvent('addTab', { + data: { service: 'APP', name: 'q-mintership', path: '' }, + }); + executeEvent('open-apps-mode', {}); + }} + sx={{ + alignItems: 'center', + backgroundColor: 'transparent', + display: 'inline-flex', + justifyContent: 'center', + minWidth: 0, + ml: 'auto', + px: 0, + py: 0, + transition: + 'color 140ms ease, text-shadow 140ms ease, transform 120ms ease', + whiteSpace: 'nowrap', + '&:hover': { + '& .minter-apply-text': { + color: + theme.palette.mode === 'dark' + ? alpha(GROUP_ACTIVITY_BLUE.gradientTop, 1) + : alpha(GROUP_ACTIVITY_BLUE.hover, 0.98), + textShadow: `0 0 10px ${alpha( + GROUP_ACTIVITY_BLUE.primary, + theme.palette.mode === 'dark' ? 0.18 : 0.12 + )}`, + }, + transform: 'translateY(-1px)', + }, + '&:active': { + transform: 'translateY(0)', + }, + }} + > + + + [ + + + {td('apply', 'Apply')} + + + ] + + + + + )} + + + ); +} diff --git a/src/components/Group/HomeDesktop/useDashboardNodeMenu.ts b/src/components/Group/HomeDesktop/useDashboardNodeMenu.ts new file mode 100644 index 00000000..21b3c636 --- /dev/null +++ b/src/components/Group/HomeDesktop/useDashboardNodeMenu.ts @@ -0,0 +1,227 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type MouseEvent, +} from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { nodeInfosAtom, selectedNodeInfoAtom } from '../../../atoms/global'; +import { getBaseApiReact } from '../../../App'; +import { useAuth } from '../../../hooks/useAuth'; +import { useBlockedAddresses } from '../../../hooks/useBlockUsers'; +import type { ApiKey } from '../../../types/auth'; +import { + getDefaultLocalNodeUrl, + HTTPS_EXT_NODE_QORTAL_LINK, + isLocalNodeUrl, +} from '../../../constants/constants'; +import type { DashboardNodeOption } from './types'; +import { + getDashboardNodeHost, + normalizeDashboardCustomNodes, + normalizeDashboardNodeUrl, +} from './utils'; +import { ensureElectronCertIfLocalPrivateHttps } from '../../../utils/helpers'; + +export function useDashboardNodeMenu() { + const selectedNode = useAtomValue(selectedNodeInfoAtom); + const setNodeInfos = useSetAtom(nodeInfosAtom); + const { getBalanceFunc, handleSaveNodeInfo } = useAuth(); + const { refreshBlockedUsers } = useBlockedAddresses(true); + const { t } = useTranslation(['core', 'group', 'tutorial', 'auth']); + const td = useCallback( + ( + key: string, + defaultValue: string, + options?: Record + ) => + String( + t(`group:dashboard.${key}`, { + defaultValue, + ...options, + }) + ), + [t] + ); + + const [dashboardCustomNodes, setDashboardCustomNodes] = useState( + [] + ); + const [nodeMenuAnchorEl, setNodeMenuAnchorEl] = useState( + null + ); + const [isSwitchingNodeUrl, setIsSwitchingNodeUrl] = useState(''); + const [nodeSwitchError, setNodeSwitchError] = useState(''); + + const selectedNodeUrl = normalizeDashboardNodeUrl( + selectedNode?.url || getBaseApiReact() + ); + const publicNodeUrl = normalizeDashboardNodeUrl(HTTPS_EXT_NODE_QORTAL_LINK); + + const loadDashboardCustomNodes = useCallback(async () => { + try { + const nodes = normalizeDashboardCustomNodes( + await window.sendMessage('getCustomNodesFromStorage') + ); + setDashboardCustomNodes(nodes); + } catch (error) { + console.error(error); + setDashboardCustomNodes([]); + } + }, []); + + const handleOpenNodeMenu = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + setNodeSwitchError(''); + setNodeMenuAnchorEl(event.currentTarget); + loadDashboardCustomNodes(); + }, + [loadDashboardCustomNodes] + ); + + const handleCloseNodeMenu = useCallback(() => { + if (isSwitchingNodeUrl) return; + setNodeMenuAnchorEl(null); + }, [isSwitchingNodeUrl]); + + const dashboardNodeOptions = useMemo(() => { + const nodes = dashboardCustomNodes.filter((node) => { + const nodeUrl = normalizeDashboardNodeUrl(node.url); + return nodeUrl && nodeUrl !== publicNodeUrl && !isLocalNodeUrl(nodeUrl); + }); + const localNodeUrl = normalizeDashboardNodeUrl(getDefaultLocalNodeUrl()); + const localNodeOption: DashboardNodeOption | null = isLocalNodeUrl( + selectedNodeUrl + ) + ? null + : { + key: 'local', + label: td('node_menu_local', 'Local Node'), + node: { url: localNodeUrl, apikey: '' }, + secondary: getDashboardNodeHost(localNodeUrl), + type: 'local', + }; + + if ( + selectedNodeUrl && + selectedNodeUrl !== publicNodeUrl && + !isLocalNodeUrl(selectedNodeUrl) && + !nodes.some( + (node) => normalizeDashboardNodeUrl(node.url) === selectedNodeUrl + ) + ) { + nodes.unshift({ + url: selectedNodeUrl, + apikey: selectedNode?.apikey || '', + name: selectedNode?.name || '', + } as ApiKey); + } + + return [ + ...nodes.map((node) => { + const nodeUrl = normalizeDashboardNodeUrl(node.url); + const host = getDashboardNodeHost(nodeUrl); + return { + key: `custom:${nodeUrl}`, + label: node.name || host, + node: { ...node, url: nodeUrl }, + secondary: host, + type: 'custom' as const, + }; + }), + ...(localNodeOption ? [localNodeOption] : []), + { + key: 'public', + label: td('node_menu_public', 'Public Node'), + node: { url: HTTPS_EXT_NODE_QORTAL_LINK, apikey: '' }, + secondary: getDashboardNodeHost(HTTPS_EXT_NODE_QORTAL_LINK), + type: 'public' as const, + }, + ]; + }, [ + dashboardCustomNodes, + publicNodeUrl, + selectedNode?.apikey, + selectedNode?.name, + selectedNodeUrl, + td, + ]); + + const handleSelectDashboardNode = useCallback( + async (option: DashboardNodeOption) => { + const nextUrl = normalizeDashboardNodeUrl(option.node.url); + if (!nextUrl || isSwitchingNodeUrl) return; + + if (nextUrl === selectedNodeUrl) { + setNodeMenuAnchorEl(null); + return; + } + + try { + setNodeSwitchError(''); + setIsSwitchingNodeUrl(nextUrl); + let nodeToSave = option.node; + + if (option.type === 'local') { + const apiKey = window?.coreSetup?.getApiKey + ? await window.coreSetup.getApiKey() + : ''; + nodeToSave = { ...option.node, apikey: apiKey || '' }; + } + + const certResult = await ensureElectronCertIfLocalPrivateHttps( + nextUrl, + nodeToSave.apikey ?? '' + ); + if (!certResult.success) { + throw new Error( + certResult.error || 'Unable to prepare local HTTPS certificate' + ); + } + + await handleSaveNodeInfo(nodeToSave); + setNodeInfos({}); + await getBalanceFunc(); + refreshBlockedUsers().catch((error) => { + console.error('Unable to refresh blocked users after node switch.', error); + }); + setNodeMenuAnchorEl(null); + } catch (error) { + console.error(error); + setNodeSwitchError( + td('node_switch_error', 'Could not switch nodes right now.') + ); + } finally { + setIsSwitchingNodeUrl(''); + } + }, + [ + getBalanceFunc, + handleSaveNodeInfo, + isSwitchingNodeUrl, + refreshBlockedUsers, + selectedNodeUrl, + setNodeInfos, + td, + ] + ); + + useEffect(() => { + loadDashboardCustomNodes(); + }, [loadDashboardCustomNodes]); + + return { + dashboardNodeOptions, + handleCloseNodeMenu, + handleOpenNodeMenu, + handleSelectDashboardNode, + isSwitchingNodeUrl, + nodeMenuAnchorEl, + nodeSwitchError, + selectedNodeUrl, + td, + }; +} diff --git a/src/components/Group/HomeDesktop/utils.ts b/src/components/Group/HomeDesktop/utils.ts new file mode 100644 index 00000000..4c1f2983 --- /dev/null +++ b/src/components/Group/HomeDesktop/utils.ts @@ -0,0 +1,211 @@ +import { alpha, type Theme } from '@mui/material/styles'; +import type { ApiKey } from '../../../types/auth'; +import { nodeDisplay } from '../../../utils/helpers'; +import { GROUP_ACTIVITY_BLUE } from '../groupActivityColorSystem'; +import { + HOME_CUSTOMIZABLE_CARD_MAX_HEIGHTS, + HOME_CUSTOMIZABLE_CARD_MIN_HEIGHTS, + HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT, + WALLET_ACTIVITY_RECENT_PAYMENT_LOOKBACK_MS, +} from './homeDesktopConstants'; +import type { + HomeCustomizableCardId, + HomeCustomizableCardsLayout, + HomeLayoutDebugMetric, + MinterInfoView, + WalletActivityDirection, + WalletActivityTransaction, +} from './types'; + +export function normalizeDashboardNodeUrl(url?: string | null) { + return (url || '').trim().replace(/\/+$/, ''); +} + +export function getDashboardNodeHost(url: string) { + try { + return new URL(url).host; + } catch { + return nodeDisplay(url); + } +} + +export function normalizeDashboardCustomNodes(nodes: unknown): ApiKey[] { + if (!Array.isArray(nodes)) return []; + + return nodes + .map((node) => ({ + url: + typeof node?.url === 'string' + ? normalizeDashboardNodeUrl(node.url) + : '', + apikey: typeof node?.apikey === 'string' ? node.apikey : '', + name: typeof node?.name === 'string' ? node.name.trim() : '', + })) + .filter((node) => Boolean(node.url)); +} + +export const isWalletActivityTimestampRecent = (timestamp: number) => + Date.now() - timestamp <= WALLET_ACTIVITY_RECENT_PAYMENT_LOOKBACK_MS; + +export function formatWalletActivityRelativeTime( + timestamp: number, + now: number, + tDashboard: ( + key: string, + options?: { count?: number } + ) => string +) { + const elapsedMs = Math.max(0, now - timestamp); + const elapsedMinutes = Math.floor(elapsedMs / 60000); + + if (elapsedMinutes < 1) { + return tDashboard('wallet_activity_relative_just_now'); + } + + if (elapsedMinutes < 60) { + return tDashboard('wallet_activity_relative_minutes_ago', { + count: elapsedMinutes, + }); + } + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) { + return tDashboard('wallet_activity_relative_hours_ago', { + count: elapsedHours, + }); + } + + const elapsedDays = Math.floor(elapsedHours / 24); + return tDashboard('wallet_activity_relative_days_ago', { + count: elapsedDays, + }); +} + +export function formatWalletActivityAmount( + amount: number, + direction: WalletActivityDirection +) { + return `${direction === 'outgoing' ? '-' : '+'}${Math.abs(amount).toFixed(2)} QORT`; +} + +export function getWalletActivityCreatorAddress( + transaction: WalletActivityTransaction +) { + return ( + transaction.creatorAddress || + transaction.senderAddress || + transaction.sender || + transaction.creator || + '' + ).trim(); +} + +export function getWalletActivityRecipientAddress( + transaction: WalletActivityTransaction +) { + return (transaction.recipient || transaction.recipientAddress || '').trim(); +} + +export function parseMinterInfoView(value: string | null): MinterInfoView { + return value === 'progress' ? 'progress' : 'dots'; +} + +export function clampHomeCustomizableCardHeight( + cardId: HomeCustomizableCardId, + value: number +) { + return Math.max( + HOME_CUSTOMIZABLE_CARD_MIN_HEIGHTS[cardId], + Math.min(HOME_CUSTOMIZABLE_CARD_MAX_HEIGHTS[cardId], Math.round(value)) + ); +} + +export function parseHomeCustomizableCardsLayout( + rawValue: string | null +): HomeCustomizableCardsLayout { + if (!rawValue) { + return { + heights: {}, + order: HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT, + }; + } + + try { + const parsed = JSON.parse(rawValue); + const parsedOrder = Array.isArray(parsed?.order) + ? parsed.order.filter( + (value): value is HomeCustomizableCardId => + value === 'groupActivity' || value === 'quitter' + ) + : []; + const order = + parsedOrder.length === HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT.length && + HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT.every((value) => + parsedOrder.includes(value) + ) + ? parsedOrder + : HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT; + + const nextHeights: Partial> = {}; + const parsedHeights = parsed?.heights ?? {}; + + HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT.forEach((cardId) => { + const height = parsedHeights?.[cardId]; + if (typeof height === 'number' && Number.isFinite(height) && height > 0) { + nextHeights[cardId] = clampHomeCustomizableCardHeight(cardId, height); + } + }); + + return { + heights: nextHeights, + order, + }; + } catch { + return { + heights: {}, + order: HOME_CUSTOMIZABLE_CARD_ORDER_DEFAULT, + }; + } +} + +export function measureHomeLayoutDebugMetric( + node: HTMLElement, + rootRect: DOMRect +): HomeLayoutDebugMetric { + const rect = node.getBoundingClientRect(); + + return { + bottom: rect.bottom - rootRect.top, + height: rect.height, + left: rect.left - rootRect.left, + top: rect.top - rootRect.top, + width: rect.width, + }; +} + +export function nodeMenuItemSx(theme: Theme, selected: boolean) { + return { + alignItems: 'center', + borderRadius: '8px', + color: selected + ? theme.palette.mode === 'dark' + ? alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.96) + : alpha(GROUP_ACTIVITY_BLUE.pressed, 0.94) + : alpha(theme.palette.text.primary, 0.9), + display: 'flex', + gap: '12px', + minHeight: 52, + px: 1.15, + py: 0.9, + '&.Mui-disabled': { + color: alpha(theme.palette.text.secondary, 0.52), + opacity: 1, + }, + '&:hover': { + backgroundColor: alpha( + GROUP_ACTIVITY_BLUE.primary, + theme.palette.mode === 'dark' ? 0.12 : 0.08 + ), + }, + }; +} diff --git a/src/components/Group/HomeDeveloperTab.tsx b/src/components/Group/HomeDeveloperTab.tsx deleted file mode 100644 index 97143814..00000000 --- a/src/components/Group/HomeDeveloperTab.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Box, ButtonBase, Typography, useTheme } from '@mui/material'; -import BuildIcon from '@mui/icons-material/Build'; -import GroupsIcon from '@mui/icons-material/Groups'; -import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'; -import { useTranslation } from 'react-i18next'; -import { executeEvent } from '../../utils/events'; - -// TODO: replace with real group IDs once confirmed -const CORE_SUPPORT_GROUP = { id: 120, name: 'Qortal-CORE-Support' }; -const DEVNET_TESTING_GROUP = { id: 269, name: 'Q-App-DevNet-Testing' }; - -interface HomeDeveloperTabProps { - getTimestampEnterChat: () => void; - setDesktopViewMode: (mode: string) => void; - setGroupSection: (section: string) => void; - setMobileViewMode: (mode: string) => void; - setSelectedGroup: (group: any) => void; -} - -export const HomeDeveloperTab = ({ - getTimestampEnterChat, - setDesktopViewMode, - setGroupSection, - setMobileViewMode, - setSelectedGroup, -}: HomeDeveloperTabProps) => { - const { t } = useTranslation(['tutorial']); - const theme = useTheme(); - - const openApp = (appName: string) => { - executeEvent('addTab', { data: { service: 'APP', name: appName } }); - executeEvent('open-apps-mode', {}); - }; - - const openGroup = (group: { id: number; name: string }) => { - setSelectedGroup({ groupId: String(group.id), groupName: group.name }); - setMobileViewMode('group'); - getTimestampEnterChat(); - setGroupSection('default'); - setDesktopViewMode('chat'); - }; - - const cards = [ - { - key: 'qtube_tutorial', - icon: , - title: t('tutorial:home.qtube_tutorial'), - description: t('tutorial:home.qtube_tutorial_desc'), - onAction: () => openApp('q-tube'), - }, - { - key: 'core_support', - icon: , - title: t('tutorial:home.core_support'), - description: t('tutorial:home.core_support_desc'), - onAction: () => openGroup(CORE_SUPPORT_GROUP), - }, - { - key: 'devnet_testing', - icon: , - title: t('tutorial:home.devnet_testing'), - description: t('tutorial:home.devnet_testing_desc'), - onAction: () => openGroup(DEVNET_TESTING_GROUP), - }, - ]; - - return ( - - - {t('tutorial:home.developer_resources')} - - - {cards.map((card) => ( - - {/* Icon */} - {card.icon} - - {/* Title + description */} - - - {card.title} - - - {card.description} - - - - ))} - - ); -}; diff --git a/src/components/Group/HomeFeaturedApps.tsx b/src/components/Group/HomeFeaturedApps.tsx index fd14e01c..e4281801 100644 --- a/src/components/Group/HomeFeaturedApps.tsx +++ b/src/components/Group/HomeFeaturedApps.tsx @@ -1,60 +1,726 @@ -import { useEffect, useRef, useState } from 'react'; -import { Avatar, Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import { + CSSProperties, + useEffect, + useEffectEvent, + useRef, + useState, +} from 'react'; +import { alpha } from '@mui/material/styles'; +import { + Avatar, + Box, + Button, + ButtonBase, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import VisibilityOffRoundedIcon from '@mui/icons-material/VisibilityOffRounded'; +import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; import { useTranslation } from 'react-i18next'; import { executeEvent } from '../../utils/events'; import { getBaseApiReactForAvatar } from '../../utils/globalApi'; -import { officialAppsConfig } from '../Apps/config/officialApps'; +import BorderGlow from '../common/BorderGlow'; +import { getBlueTier1ButtonSx } from '../../styles/blueMaterial'; +import { + dashboardPanelSx, + handleDashboardPanelPointerLeave, + handleDashboardPanelPointerMove, + useDashboardPanelMouseLight, +} from './dashboardPanelEffects'; +import { + GROUP_ACTIVITY_BLUE, + getBlueAmbientLineBackground, +} from './groupActivityColorSystem'; const RETRY_DELAY_MS = 5000; +export const FEATURED_PREVIEW_EXPAND_DELAY_MS = 200; +export const FEATURED_INITIAL_PREVIEW_DURATION_MS = 3500; +export const FEATURED_INTRO_TOTAL_DURATION_MS = + FEATURED_PREVIEW_EXPAND_DELAY_MS + FEATURED_INITIAL_PREVIEW_DURATION_MS; +const FEATURED_STRIP_HOVER_DURATION_MS = 190; +const FEATURED_INITIAL_PREVIEW_SESSION_KEY = + 'dashboard-featured-subwire-preview-seen'; +const FEATURED_CALM_MODE_STORAGE_KEY = 'dashboard-featured-apps-calm-mode'; +const FEATURED_TEASER_FADE_DURATION_MS = 300; +const SUBWIRE_APP_NAME = 'SubWire'; +const Q_TUBE_APP_NAME = 'Q-Tube'; +const SUBWIRE_PREVIEW_VIDEO_SRC = '/subwire-preview.webm'; +const Q_TUBE_PREVIEW_VIDEO_SRC = '/q-tube-preview.webm'; +const FEATURED_TILE_VIDEO_SRC = { + [Q_TUBE_APP_NAME]: Q_TUBE_PREVIEW_VIDEO_SRC, + [SUBWIRE_APP_NAME]: SUBWIRE_PREVIEW_VIDEO_SRC, +} as const; +const FEATURED_STRIP_HOVER_TRANSITION = `${FEATURED_STRIP_HOVER_DURATION_MS}ms ease`; +const FEATURED_PREVIEW_CONFIG = { + [Q_TUBE_APP_NAME]: { + accent: '#78AFFF', + align: 'left', + eyebrow: 'Always on', + subtitle: + 'Network-hosted video drops, weird clips, and creator rabbit holes.', + title: Q_TUBE_APP_NAME, + videoSrc: Q_TUBE_PREVIEW_VIDEO_SRC, + videoPosition: 'center center', + }, + [SUBWIRE_APP_NAME]: { + accent: '#78AFFF', + align: 'right', + eyebrow: 'Creator-owned', + subtitle: + 'Tell your stories without limits. Own your work and earn directly from your subscribers.', + title: SUBWIRE_APP_NAME, + videoSrc: SUBWIRE_PREVIEW_VIDEO_SRC, + videoPosition: 'center center', + }, +} as const; +type PreviewAppName = keyof typeof FEATURED_PREVIEW_CONFIG; +const FEATURED_APP_GRID = [ + Q_TUBE_APP_NAME, + 'Quitter', + 'Q-Mail', + 'Q-Blog', + 'Q-Trade', + SUBWIRE_APP_NAME, +] as const; +const FEATURED_GRID_GAP_PX = 12; +const FEATURED_GRID_COL_MAX_PX = 124; +/** Cap width when space allows; below this the grid scales down proportionally (avoids 3-col + span-2 orphan gaps). */ +const FEATURED_GRID_MAX_WIDTH_PX = + 4 * FEATURED_GRID_COL_MAX_PX + 3 * FEATURED_GRID_GAP_PX; const openApp = (appName: string) => { executeEvent('addTab', { data: { service: 'APP', name: appName } }); executeEvent('open-apps-mode', {}); }; -export const HomeFeaturedApps = () => { - const { t } = useTranslation(['tutorial']); +const openAppsLibrary = () => { + executeEvent('openAppsLibrarySearch', { + data: { + query: '', + }, + }); + executeEvent('open-apps-mode', {}); +}; + +export const HomeFeaturedApps = ({ panelBoxRef = undefined }) => { const theme = useTheme(); + const { t } = useTranslation(['group']); + const td = (key: string, defaultValue: string) => + t(`group:dashboard.${key}`, { defaultValue }); + const panelRef = useDashboardPanelMouseLight(); + const assignPanelNode = (node) => { + panelRef.current = node; + + if (typeof panelBoxRef === 'function') { + panelBoxRef(node); + return; + } + + if (panelBoxRef) { + panelBoxRef.current = node; + } + }; + const previewExpandTimerRef = useRef | null>( + null + ); + const previewCollapseTimerRef = useRef | null>( + null + ); + const initialPreviewStartTimerRef = useRef | null>(null); + const initialPreviewEndTimerRef = useRef | null>(null); + const [expandedPreviewApp, setExpandedPreviewApp] = + useState(null); + const [autoPreviewActive, setAutoPreviewActive] = useState(false); + const [isCalmMode, setIsCalmMode] = useState(() => { + try { + return ( + window.localStorage.getItem(FEATURED_CALM_MODE_STORAGE_KEY) === '1' + ); + } catch { + return false; + } + }); + const featuredFooterStripBackground = + theme.palette.mode === 'dark' + ? `radial-gradient(140% 252% at 50% 54%, ${alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.225)} 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.132 + )} 18%, ${alpha(GROUP_ACTIVITY_BLUE.hover, 0.078)} 38%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.032 + )} 62%, transparent 88%), linear-gradient(90deg, transparent 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.014 + )} 18%, ${alpha(GROUP_ACTIVITY_BLUE.gradientMid, 0.072)} 50%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.014 + )} 82%, transparent 100%), linear-gradient(180deg, ${alpha( + theme.palette.common.white, + 0.024 + )} 0%, ${alpha(theme.palette.common.white, 0.012)} 12%, transparent 34%, transparent 64%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.03 + )} 100%), linear-gradient(180deg, transparent 0%, transparent 46%, ${alpha( + theme.palette.common.white, + 0.034 + )} 49.5%, ${alpha(theme.palette.common.white, 0.05)} 50%, ${alpha( + theme.palette.common.white, + 0.034 + )} 50.5%, transparent 54%, transparent 100%)` + : `radial-gradient(140% 252% at 50% 54%, ${alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.162)} 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.096 + )} 18%, ${alpha(GROUP_ACTIVITY_BLUE.hover, 0.056)} 38%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.022 + )} 62%, transparent 88%), linear-gradient(90deg, transparent 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.009 + )} 18%, ${alpha(GROUP_ACTIVITY_BLUE.gradientMid, 0.05)} 50%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.009 + )} 82%, transparent 100%), linear-gradient(180deg, ${alpha( + theme.palette.common.white, + 0.018 + )} 0%, ${alpha(theme.palette.common.white, 0.008)} 12%, transparent 34%, transparent 64%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.02 + )} 100%), linear-gradient(180deg, transparent 0%, transparent 46%, ${alpha( + theme.palette.common.white, + 0.022 + )} 49.5%, ${alpha(theme.palette.common.white, 0.03)} 50%, ${alpha( + theme.palette.common.white, + 0.022 + )} 50.5%, transparent 54%, transparent 100%)`; + const featuredFooterStripCoreGlow = + theme.palette.mode === 'dark' + ? `radial-gradient(92% 202% at 50% 56%, ${alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.285)} 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.168 + )} 30%, ${alpha(GROUP_ACTIVITY_BLUE.primary, 0.055)} 56%, transparent 80%)` + : `radial-gradient(92% 202% at 50% 56%, ${alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.195)} 0%, ${alpha( + GROUP_ACTIVITY_BLUE.primary, + 0.114 + )} 30%, ${alpha(GROUP_ACTIVITY_BLUE.primary, 0.036)} 56%, transparent 80%)`; + + const clearInitialPreviewTimers = useEffectEvent(() => { + if (initialPreviewStartTimerRef.current) { + clearTimeout(initialPreviewStartTimerRef.current); + initialPreviewStartTimerRef.current = null; + } + if (initialPreviewEndTimerRef.current) { + clearTimeout(initialPreviewEndTimerRef.current); + initialPreviewEndTimerRef.current = null; + } + }); + + useEffect(() => { + if (isCalmMode) { + setAutoPreviewActive(false); + setExpandedPreviewApp(null); + return () => { + if (previewExpandTimerRef.current) + clearTimeout(previewExpandTimerRef.current); + if (previewCollapseTimerRef.current) + clearTimeout(previewCollapseTimerRef.current); + clearInitialPreviewTimers(); + }; + } + + let shouldRunInitialPreview = true; + + try { + shouldRunInitialPreview = + window.sessionStorage.getItem(FEATURED_INITIAL_PREVIEW_SESSION_KEY) !== + '1'; + } catch { + shouldRunInitialPreview = true; + } + + if (!shouldRunInitialPreview) { + setAutoPreviewActive(false); + return () => { + if (previewExpandTimerRef.current) + clearTimeout(previewExpandTimerRef.current); + if (previewCollapseTimerRef.current) + clearTimeout(previewCollapseTimerRef.current); + clearInitialPreviewTimers(); + }; + } + + try { + window.sessionStorage.setItem(FEATURED_INITIAL_PREVIEW_SESSION_KEY, '1'); + } catch { + // Ignore sessionStorage failures and allow the intro to behave as a normal first-mount teaser. + } + + setAutoPreviewActive(true); + + initialPreviewStartTimerRef.current = setTimeout(() => { + initialPreviewStartTimerRef.current = null; + setExpandedPreviewApp(SUBWIRE_APP_NAME); + }, FEATURED_PREVIEW_EXPAND_DELAY_MS); + + initialPreviewEndTimerRef.current = setTimeout(() => { + initialPreviewEndTimerRef.current = null; + setAutoPreviewActive(false); + setExpandedPreviewApp((current) => + current === SUBWIRE_APP_NAME ? null : current + ); + }, FEATURED_INTRO_TOTAL_DURATION_MS); + + return () => { + if (previewExpandTimerRef.current) + clearTimeout(previewExpandTimerRef.current); + if (previewCollapseTimerRef.current) + clearTimeout(previewCollapseTimerRef.current); + clearInitialPreviewTimers(); + }; + }, [clearInitialPreviewTimers, isCalmMode]); + + const clearPirateTimers = () => { + if (previewExpandTimerRef.current) { + clearTimeout(previewExpandTimerRef.current); + previewExpandTimerRef.current = null; + } + if (previewCollapseTimerRef.current) { + clearTimeout(previewCollapseTimerRef.current); + previewCollapseTimerRef.current = null; + } + }; + + const stopInitialPreview = () => { + clearInitialPreviewTimers(); + setAutoPreviewActive(false); + }; + + useEffect(() => { + try { + window.localStorage.setItem( + FEATURED_CALM_MODE_STORAGE_KEY, + isCalmMode ? '1' : '0' + ); + } catch { + // Ignore storage failures and keep the preference local-only. + } + + if (isCalmMode) { + clearPirateTimers(); + stopInitialPreview(); + setExpandedPreviewApp(null); + } + }, [isCalmMode]); + + const schedulePreviewExpand = (appName: PreviewAppName) => { + if (isCalmMode) return; + if (autoPreviewActive) { + stopInitialPreview(); + } + if (expandedPreviewApp === appName && !previewExpandTimerRef.current) + return; + if (previewCollapseTimerRef.current) { + clearTimeout(previewCollapseTimerRef.current); + previewCollapseTimerRef.current = null; + } + if (previewExpandTimerRef.current) { + clearTimeout(previewExpandTimerRef.current); + previewExpandTimerRef.current = null; + } + previewExpandTimerRef.current = setTimeout(() => { + previewExpandTimerRef.current = null; + setExpandedPreviewApp(appName); + }, FEATURED_PREVIEW_EXPAND_DELAY_MS); + }; + + const schedulePreviewCollapse = () => { + if (isCalmMode) return; + if (autoPreviewActive) return; + if (previewExpandTimerRef.current) { + clearTimeout(previewExpandTimerRef.current); + previewExpandTimerRef.current = null; + } + if (!expandedPreviewApp || previewCollapseTimerRef.current) return; + previewCollapseTimerRef.current = setTimeout(() => { + previewCollapseTimerRef.current = null; + setExpandedPreviewApp(null); + }, 70); + }; return ( - {/* Section title */} - - {t('tutorial:home.featured_apps')} - + + setIsCalmMode((current) => !current)} + sx={{ + alignItems: 'center', + borderRadius: '9px', + color: alpha( + theme.palette.text.secondary, + isCalmMode ? 0.9 : 0.68 + ), + display: 'inline-flex', + height: 30, + justifyContent: 'center', + position: 'absolute', + right: 0, + top: '50%', + transform: 'translateY(-50%)', + transition: + 'background-color 160ms ease, color 160ms ease, box-shadow 180ms ease, opacity 160ms ease', + width: 30, + '&:hover': { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.042)' + : 'rgba(24,29,36,0.05)', + color: theme.palette.text.primary, + }, + ...(isCalmMode + ? { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.03)' + : 'rgba(24,29,36,0.042)', + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 0 0 1px rgba(255,255,255,0.05)' + : 'inset 0 0 0 1px rgba(24,29,36,0.06)', + } + : null), + }} + > + {isCalmMode ? ( + + ) : ( + + )} + + + + + {td('featured_q_apps', 'Featured Q-Apps')} + + + {td( + 'featured_q_apps_subtitle', + 'Launch trusted community apps directly from your dashboard.' + )} + + + - {/* Horizontally scrollable app row */} - {officialAppsConfig.featured.map((appName) => ( - - ))} + + + {FEATURED_APP_GRID.map((appName, index) => + appName ? ( + + ) : ( + + {!isCalmMode ? ( + { + clearPirateTimers(); + }} + onMouseLeave={schedulePreviewCollapse} + /> + ) : null} + + + + ); @@ -64,14 +730,80 @@ export const HomeFeaturedApps = () => { interface AppTileProps { appName: string; + calmMode: boolean; theme: any; + expandedPreviewApp: PreviewAppName | null; + onPreviewExpandStart: (appName: PreviewAppName) => void; + onPreviewExpandEnd: () => void; } -const AppTile = ({ appName, theme }: Omit) => { +const AppTile = ({ + appName, + calmMode, + theme, + expandedPreviewApp, + onPreviewExpandStart, + onPreviewExpandEnd, +}: AppTileProps) => { const baseAvatarUrl = `${getBaseApiReactForAvatar()}/arbitrary/THUMBNAIL/${appName}/qortal_avatar?async=true`; const [imageSrc, setImageSrc] = useState(baseAvatarUrl); + const [hasTileVideoError, setHasTileVideoError] = useState(false); const hasRetriedRef = useRef(false); const retryTimeoutRef = useRef | null>(null); + const isPreviewableTile = appName in FEATURED_PREVIEW_CONFIG; + const isWideLeftTile = appName === Q_TUBE_APP_NAME; + const isWideRightTile = appName === SUBWIRE_APP_NAME; + const isWideTile = isWideLeftTile || isWideRightTile; + const tileVideoSrc = + appName in FEATURED_TILE_VIDEO_SRC + ? FEATURED_TILE_VIDEO_SRC[appName as keyof typeof FEATURED_TILE_VIDEO_SRC] + : null; + const fadeOutForPreview = + !calmMode && !!expandedPreviewApp && expandedPreviewApp !== appName; + const hideBasePreviewTile = + !calmMode && !!expandedPreviewApp && expandedPreviewApp === appName; + const allowTileHover = !expandedPreviewApp && !calmMode; + const hiddenTileStyles = { + opacity: fadeOutForPreview ? 0 : hideBasePreviewTile ? 0 : 1, + pointerEvents: fadeOutForPreview || hideBasePreviewTile ? 'none' : 'auto', + visibility: fadeOutForPreview || hideBasePreviewTile ? 'hidden' : 'visible', + } as const; + const staticWideTileBackground = + theme.palette.mode === 'dark' + ? isWideLeftTile + ? `radial-gradient(88% 122% at 18% 54%, ${alpha('#BCA2FF', 0.14)} 0%, ${alpha( + '#BCA2FF', + 0.06 + )} 26%, transparent 58%), radial-gradient(78% 112% at 82% 50%, ${alpha( + '#F2E7DA', + 0.12 + )} 0%, ${alpha('#F2E7DA', 0.045)} 24%, transparent 50%), linear-gradient(145deg, rgba(54,58,68,0.9) 0%, rgba(40,44,52,0.95) 48%, rgba(28,31,38,0.98) 100%)` + : `radial-gradient(92% 128% at 18% 52%, ${alpha('#7DB5FF', 0.2)} 0%, ${alpha( + '#7DB5FF', + 0.08 + )} 28%, transparent 60%), radial-gradient(78% 108% at 84% 46%, ${alpha( + '#F2E7DA', + 0.11 + )} 0%, ${alpha('#F2E7DA', 0.04)} 24%, transparent 52%), linear-gradient(145deg, rgba(54,58,68,0.9) 0%, rgba(40,44,52,0.95) 48%, rgba(28,31,38,0.98) 100%)` + : isWideLeftTile + ? `radial-gradient(88% 122% at 18% 54%, ${alpha('#BCA2FF', 0.12)} 0%, ${alpha( + '#BCA2FF', + 0.05 + )} 26%, transparent 58%), radial-gradient(78% 112% at 82% 50%, ${alpha( + '#F2E7DA', + 0.09 + )} 0%, ${alpha('#F2E7DA', 0.036)} 24%, transparent 50%), linear-gradient(145deg, rgba(246,248,252,0.98) 0%, rgba(235,240,247,0.99) 48%, rgba(224,230,239,1) 100%)` + : `radial-gradient(92% 128% at 18% 52%, ${alpha('#7DB5FF', 0.16)} 0%, ${alpha( + '#7DB5FF', + 0.06 + )} 28%, transparent 60%), radial-gradient(78% 108% at 84% 46%, ${alpha( + '#F2E7DA', + 0.085 + )} 0%, ${alpha('#F2E7DA', 0.034)} 24%, transparent 52%), linear-gradient(145deg, rgba(246,248,252,0.98) 0%, rgba(235,240,247,0.99) 48%, rgba(224,230,239,1) 100%)`; + + useEffect(() => { + setHasTileVideoError(false); + }, [tileVideoSrc]); useEffect(() => { return () => { @@ -81,7 +813,6 @@ const AppTile = ({ appName, theme }: Omit) => { const handleImageError = () => { if (hasRetriedRef.current) return; - console.log('handleImageError', appName); hasRetriedRef.current = true; retryTimeoutRef.current = setTimeout(() => { retryTimeoutRef.current = null; @@ -89,45 +820,599 @@ const AppTile = ({ appName, theme }: Omit) => { }, RETRY_DELAY_MS); }; - return ( + const tileButton = ( openApp(appName)} + onMouseEnter={ + isPreviewableTile && !calmMode + ? () => onPreviewExpandStart(appName as PreviewAppName) + : undefined + } + onMouseLeave={ + isPreviewableTile && !calmMode ? onPreviewExpandEnd : undefined + } sx={{ - alignItems: 'center', - bgcolor: theme.palette.background.default, + alignItems: isWideLeftTile + ? 'flex-start' + : isWideRightTile + ? 'flex-end' + : 'center', + bgcolor: + theme.palette.mode === 'dark' + ? '#181a20' + : theme.palette.background.surface, + border: `1px solid ${theme.palette.border.subtle}`, borderRadius: '10px', display: 'flex', flexDirection: 'column', - flexShrink: 0, gap: '8px', - padding: '14px 10px', - width: '120px', - '&:hover': { bgcolor: theme.palette.action.hover }, + justifyContent: 'center', + height: '100%', + minHeight: 0, + padding: '12px 10px', + transition: + 'background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease, opacity 160ms ease', + overflow: 'hidden', + position: 'relative', + width: '100%', + gridColumn: isWideTile ? 'span 2' : 'span 1', + ...(!isWideTile ? hiddenTileStyles : null), + transform: 'translateY(0)', + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.035), inset 0 -10px 18px rgba(0,0,0,0.22)' + : 'inset 0 1px 0 rgba(255,255,255,0.72), inset 0 -8px 14px rgba(15,23,42,0.06)', + '&:hover': { + bgcolor: + theme.palette.mode === 'dark' + ? '#181a20' + : theme.palette.background.paper, + borderColor: theme.palette.border.main, + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.04), inset 0 -10px 18px rgba(0,0,0,0.24), 0 8px 18px rgba(0,0,0,0.1)' + : 'inset 0 1px 0 rgba(255,255,255,0.76), inset 0 -8px 14px rgba(15,23,42,0.07), 0 8px 18px rgba(15,23,42,0.08)', + ...(allowTileHover ? { transform: 'translateY(-1px)' } : null), + }, + '&:active': { + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.03), inset 0 -8px 14px rgba(0,0,0,0.2), 0 2px 8px rgba(0,0,0,0.1)' + : 'inset 0 1px 0 rgba(255,255,255,0.68), inset 0 -8px 14px rgba(15,23,42,0.06), 0 2px 8px rgba(15,23,42,0.07)', + ...(allowTileHover ? { transform: 'translateY(0)' } : null), + }, + '&:focus-visible': { + backgroundColor: theme.palette.background.surface, + borderColor: theme.palette.border.main, + boxShadow: `inset 0 0 0 1px ${theme.palette.primary.main}`, + }, }} > - + + {appName.charAt(0)} + + + + {appName} + + + {isWideLeftTile ? ( + - - - {/* QR Code popover */} - setQrAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - transformOrigin={{ vertical: 'top', horizontal: 'center' }} - > - - - - + event.stopPropagation()} + sx={{ + left: `${avatarPanelLayout.left}px`, + overflow: 'visible', + position: 'fixed', + top: `${avatarPanelLayout.top}px`, + transformOrigin: 'top left', + width: `${avatarPanelLayout.width}px`, + zIndex: 1299, + }} + > + + + + + + Update avatar + + + {t('core:message.generic.avatar_size', { + size: MAX_SIZE_AVATAR, + postProcess: 'capitalizeFirstChar', + })} + + + + + + + + + + + {avatarPreviewUrl ? ( + + ) : ( + + )} + + + + setAvatarFile(file)}> + + + + {avatarFile + ? 'Replace image' + : t('core:action.choose_image', { + postProcess: 'capitalizeFirstChar', + })} + + + PNG, JPG, WEBP or GIF + + + + Browse + + + + + {avatarFile?.name && ( + + {avatarFile.name} + + )} + + {!name && ( + + + + {t('group:message.generic.avatar_registered_name', { + postProcess: 'capitalizeFirstChar', + })} + + + )} + + + {t('group:action.publish_avatar', { + postProcess: 'capitalizeFirstChar', + })} + + + + + + + )} + + ); }; diff --git a/src/components/Group/HomeQortinoWorkspaceCard.tsx b/src/components/Group/HomeQortinoWorkspaceCard.tsx new file mode 100644 index 00000000..fae5e9cf --- /dev/null +++ b/src/components/Group/HomeQortinoWorkspaceCard.tsx @@ -0,0 +1,6498 @@ +import { + Avatar, + Box, + Button, + ButtonBase, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Portal, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useAtomValue } from 'jotai'; +import { + type DragEvent as ReactDragEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded'; +import EditRoundedIcon from '@mui/icons-material/EditRounded'; +import GraphicEqRoundedIcon from '@mui/icons-material/GraphicEqRounded'; +import ForumRoundedIcon from '@mui/icons-material/ForumRounded'; +import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded'; +import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; +import ShoppingBagRoundedIcon from '@mui/icons-material/ShoppingBagRounded'; +import SpaRoundedIcon from '@mui/icons-material/SpaRounded'; +import SupportAgentRoundedIcon from '@mui/icons-material/SupportAgentRounded'; +import UploadRoundedIcon from '@mui/icons-material/UploadRounded'; +import VideoLibraryRoundedIcon from '@mui/icons-material/VideoLibraryRounded'; +import AddRoundedIcon from '@mui/icons-material/AddRounded'; +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; +import LibraryMusicRoundedIcon from '@mui/icons-material/LibraryMusicRounded'; +import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; +import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded'; +import { + balanceAtom, + resourceKeySelector, + txListAtom, + userInfoAtom, +} from '../../atoms/global'; +import { getArbitraryEndpointReact, getBaseApiReact } from '../../App'; +import LogoSelected from '../../assets/svgs/LogoSelected.svg'; +import ErrorBoundary from '../../common/ErrorBoundary'; +import { useFetchResources } from '../../hooks/useFetchResources'; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from '../../utils/events'; +import { openHttpUrlExternally } from '../../utils/openExternalHttp'; +import { + dashboardPanelSx, + handleDashboardPanelPointerLeave, + handleDashboardPanelPointerMove, + useDashboardPanelMouseLight, +} from './dashboardPanelEffects'; +import { QortalRequestBubbleIcon } from './GroupActivityEmptyStateGraphic'; +import { + GROUP_ACTIVITY_BLUE, + APP_BLUE_SURFACE_TEXT, + getBlueAmbientLineBackground, + getBlueTier1ButtonSx, + getBlueTier2BadgeSx, +} from './groupActivityColorSystem'; +import { GETTING_STARTED_LS_KEY } from './gettingStartedStorage'; +import { + DEFAULT_QORTINO_COMPANION_DEBUG_SETTINGS, + type QortinoCompanionDebugSettings, +} from './qortinoCompanionDebug'; +import { + DEFAULT_QORTINO_LOOK_DEBUG_SETTINGS, + type QortinoLookDebugSettings, +} from './qortinoLookDebug'; +import { + DEFAULT_QORTINO_LAYOUT_DEBUG_SETTINGS, + type QortinoLayoutDebugSettings, +} from './qortinoLayoutDebug'; +import { + fetchEarbumpRecentTracks, + fetchEarbumpTrackById, + searchEarbumpTracks, + type EarbumpTrack, +} from './earbumpLibraryApi'; +import { + getSharedEarbumpAudio, + getSharedEarbumpTrackSnapshot, + setSharedEarbumpTrackSnapshot, + stopSharedEarbumpPlayback, +} from './earbumpSharedAudio'; +import { + MusicCoverArt, + QortinoMusicPlayer, + type RepeatMode, +} from './QortinoMusicPlayer'; +import { Confetti } from '../ui/confetti'; +import { DotPattern } from '../ui/dot-pattern'; +import { + QORTINO_DONATION_BUBBLE_MESSAGE, + QORTINO_DONATION_COMPLETED_EVENT, + QORTINO_DONATION_DRAG_TYPE, + QORTINO_DONATION_GRATEFUL_DURATION_MS, + QORTINO_DONATION_OVERLAY_DURATION_MS, + QORTINO_DONATION_PREFILL_NAME, + QORTINO_DONATION_THANK_YOU_MESSAGE, +} from './qortinoDonationEasterEgg'; + +const LS_KEY = GETTING_STARTED_LS_KEY; +const AVATAR_SERVICE = 'THUMBNAIL'; +const AVATAR_IDENTIFIER = 'qortal_avatar'; +/** Advance past step 1 at this balance; labels still say "6 QORT" for onboarding copy. */ +const MIN_BALANCE_FOR_QORTS = 1.25; +export const QORTINO_WORKSPACE_SETTINGS_KEY = 'home-qortino-workspace-v1'; +const ONBOARDING_URL = 'https://qortal.dev/onboarding'; +const SUPPORT_CHAT_URL = 'https://link.qortal.dev/support'; +const ONBOARDING_RECOGNITION_DURATION_MS = 2600; +const QORTINO_MASCOT_BASE_SIZE = 168; +const QORTINO_MASCOT_SCALE = 0.68; +const QORTINO_MASCOT_SIZE = Math.round( + QORTINO_MASCOT_BASE_SIZE * QORTINO_MASCOT_SCALE +); +const QORTINO_MASCOT_STAGE_PADDING_X = 26; +const QORTINO_MASCOT_STAGE_PADDING_Y = 28; +const QORTINO_MASCOT_VERTICAL_LIFT_PX = 12; +const QORTINO_WORKSPACE_BAY_HEIGHT_PX = 251; +const HOTKEY_SLOT_COUNT = 6; +const HOTKEY_PICKER_SEARCH_DEBOUNCE_MS = 320; +const CURATED_HOTKEY_APP_NAMES = [ + 'Q-Tube', + 'Quitter', + 'Q-Mail', + 'Q-Blog', + 'Q-Trade', + 'Ear-Bump', +] as const; +type QortinoDonationOverlayState = { + message: string; + nonce: number; +}; +type QortinoGratefulState = { + message: string; + nonce: number; +}; +const QORTINO_STATUS_REFERENCE_LABEL = 'standby'; +const HOTKEY_SLOT_VALUE_SEPARATOR = '::'; +const EARBUMP_AUDIO_SERVICE = 'AUDIO'; +type WorkspaceMode = 'empty' | 'hotkeys' | 'music'; +type StepKey = 'get_six_qorts' | 'register_name' | 'load_avatar'; +type HotkeyActionId = string; +type HotkeySlotValue = string | null; +type HotkeyAppService = 'APP' | 'WEBSITE'; + +type MusicTrack = EarbumpTrack; + +type WorkspaceState = { + hotkeys: HotkeySlotValue[]; + mode: WorkspaceMode; + musicPlaying: boolean; + musicQuery: string; + onboardingCelebrationSeen: boolean; + repeatMode: RepeatMode; + selectedTrackId: string; + version: 1; +}; + +type HomeQortinoWorkspaceCardProps = { + onGettingStartedComplete?: () => void; + onOpenAppsPanel?: () => void; +}; + +type QortinoSectionRuntimeFallbackProps = { + body: string; + theme: ReturnType; + title: string; + variant: 'qortino' | 'workspace'; +}; + +type WorkspaceModuleDefinition = { + appName?: string; + appPath?: string; + description: string; + icon: typeof AppsRoundedIcon; + key: string; + label: string; + mode?: Exclude; +}; + +type HotkeyActionDefinition = { + description: string; + icon: typeof VideoLibraryRoundedIcon; + id: HotkeyActionId; + label: string; + reaction?: string; + run: () => void; +}; + +type HotkeyAppDefinition = { + appName: string; + description: string; + label: string; + service: HotkeyAppService; +}; + +type HotkeyPickerRow = + | { kind: 'heading' } + | { kind: 'divider' } + | { kind: 'app'; app: HotkeyAppDefinition; curated: boolean }; + +type WorkspaceCompanionReactionPayload = + | string + | { kind: 'locked_track'; title: string } + | { kind: 'track_rotation'; title: string }; + +type QAppResourceRecord = { + metadata?: { + description?: string; + title?: string; + }; + name?: string; + service?: string; +}; + +type TrackReadyState = 'downloading' | 'error' | 'idle' | 'ready'; + +const qortinoLookDebug: QortinoLookDebugSettings = + DEFAULT_QORTINO_LOOK_DEBUG_SETTINGS; +const qortinoLayoutDebug: QortinoLayoutDebugSettings = + DEFAULT_QORTINO_LAYOUT_DEBUG_SETTINGS; +const qortinoCompanionDebug: QortinoCompanionDebugSettings = + DEFAULT_QORTINO_COMPANION_DEBUG_SETTINGS; + +const DEFAULT_HOTKEYS: HotkeySlotValue[] = Array.from( + { length: HOTKEY_SLOT_COUNT }, + () => null +); + +const DEFAULT_WORKSPACE_STATE: WorkspaceState = { + hotkeys: DEFAULT_HOTKEYS, + mode: 'empty', + musicPlaying: false, + musicQuery: '', + onboardingCelebrationSeen: false, + repeatMode: 'all', + selectedTrackId: '', + version: 1, +}; + +const LEGACY_HOTKEY_APP_NAME_MAP: Record = { + earbump: 'Ear-Bump', + earbumpupdated: 'Ear-Bump', + 'ear-bump updated': 'Ear-Bump', + 'earbump updated': 'Ear-Bump', + Earbump: 'Ear-Bump', + 'Ear-Bump Updated': 'Ear-Bump', + 'q-blog': 'Q-Blog', + 'q-mail': 'Q-Mail', + 'q-mintership': 'q-mintership', + 'q-trade': 'Q-Trade', + 'q-tube': 'Q-Tube', + quitter: 'Quitter', +}; + +const normalizeHotkeyAppName = (value: string) => + LEGACY_HOTKEY_APP_NAME_MAP[value] ?? value; + +const encodeHotkeySlotValue = (service: HotkeyAppService, appName: string) => + `${service}${HOTKEY_SLOT_VALUE_SEPARATOR}${appName}`; + +const parseHotkeySlotValue = ( + value: string +): { appName: string; service: HotkeyAppService } => { + const [serviceCandidate, ...rest] = value.split(HOTKEY_SLOT_VALUE_SEPARATOR); + + if ( + (serviceCandidate === 'APP' || serviceCandidate === 'WEBSITE') && + rest.length > 0 + ) { + return { + appName: rest.join(HOTKEY_SLOT_VALUE_SEPARATOR), + service: serviceCandidate, + }; + } + + return { + appName: normalizeHotkeyAppName(value), + service: 'APP', + }; +}; + +const formatHotkeyAppLabel = (appName: string) => + appName + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); + +const getHotkeyAppThumbnailUrl = (appName: string) => + `${getBaseApiReact()}/arbitrary/THUMBNAIL/${appName}/qortal_avatar?async=true`; + +const HotkeyAppAvatar = ({ + appName, + radius = 12, + size = 36, +}: { + appName: string; + radius?: number; + size?: number; +}) => ( + + + +); + +const truncateQortinoBubbleMessage = ( + message: string | null, + maxChars = 96 +) => { + const trimmed = message?.trim() ?? ''; + if (!trimmed) return null; + if (trimmed.length <= maxChars) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxChars - 5)).trimEnd()}(...)`; +}; + +const QortinoSectionRuntimeFallback = ({ + body, + theme, + title, + variant, +}: QortinoSectionRuntimeFallbackProps) => { + const isDarkMode = theme.palette.mode === 'dark'; + + return ( + + {variant === 'qortino' ? ( + + ) : null} + + {title} + + + {body} + + + ); +}; + +const sanitizeWorkspaceState = (value: unknown): WorkspaceState => { + if (!value || typeof value !== 'object') { + return { ...DEFAULT_WORKSPACE_STATE }; + } + + const parsed = value as Partial; + const nextMode: WorkspaceMode = + parsed.mode === 'hotkeys' || parsed.mode === 'music' + ? parsed.mode + : 'empty'; + + const sanitizedHotkeys = Array.isArray(parsed.hotkeys) + ? parsed.hotkeys + .slice(0, HOTKEY_SLOT_COUNT) + .map( + (item): HotkeySlotValue => + typeof item === 'string' && item.trim().length > 0 + ? normalizeHotkeyAppName(item.trim()) + : null + ) + : []; + const paddedHotkeys = Array.from( + { length: HOTKEY_SLOT_COUNT }, + (_, index) => sanitizedHotkeys[index] ?? null + ); + + return { + hotkeys: paddedHotkeys, + mode: nextMode, + musicPlaying: parsed.musicPlaying === true, + musicQuery: typeof parsed.musicQuery === 'string' ? parsed.musicQuery : '', + onboardingCelebrationSeen: parsed.onboardingCelebrationSeen === true, + repeatMode: parsed.repeatMode === 'one' ? 'one' : 'all', + selectedTrackId: + typeof parsed.selectedTrackId === 'string' + ? parsed.selectedTrackId.trim() + : DEFAULT_WORKSPACE_STATE.selectedTrackId, + version: 1, + }; +}; + +const openExternalUrl = (url: string) => { + openHttpUrlExternally(url); +}; + +const dispatchAppTab = (name: string, path = '') => { + executeEvent('addTab', { data: { service: 'APP', name, path } }); +}; + +const getFallbackStorageKey = (userAddress: string | undefined) => + userAddress + ? `${QORTINO_WORKSPACE_SETTINGS_KEY}_${userAddress}` + : QORTINO_WORKSPACE_SETTINGS_KEY; + +const loadWorkspaceStateFromFallbackStorage = ( + userAddress: string | undefined +): WorkspaceState => { + if (typeof window === 'undefined') { + return { ...DEFAULT_WORKSPACE_STATE }; + } + + const fallbackKey = getFallbackStorageKey(userAddress); + + try { + const storedValue = localStorage.getItem(fallbackKey); + return sanitizeWorkspaceState(storedValue ? JSON.parse(storedValue) : null); + } catch { + return { ...DEFAULT_WORKSPACE_STATE }; + } +}; + +const persistWorkspaceState = async ( + nextState: WorkspaceState, + userAddress: string | undefined +) => { + const fallbackKey = getFallbackStorageKey(userAddress); + + try { + localStorage.setItem(fallbackKey, JSON.stringify(nextState)); + } catch { + // Fallback-only persistence best effort. + } + + try { + if (typeof window.sendMessage !== 'function') return; + await window.sendMessage('addUserSettings', { + keyValue: { + key: QORTINO_WORKSPACE_SETTINGS_KEY, + value: nextState, + }, + }); + } catch { + // Account-scoped save best effort; local fallback already written. + } +}; + +const loadWorkspaceState = async ( + userAddress: string | undefined +): Promise => { + try { + if (typeof window.sendMessage === 'function') { + const stored = await window.sendMessage('getUserSettings', { + key: QORTINO_WORKSPACE_SETTINGS_KEY, + }); + + if (stored && !stored.error) { + return sanitizeWorkspaceState(stored); + } + } + } catch { + // Fallback local storage below. + } + + return loadWorkspaceStateFromFallbackStorage(userAddress); +}; + +const resetQortinoWorkspaceOnboardingCelebration = async ( + userAddress: string | undefined +) => { + const currentState = await loadWorkspaceState(userAddress); + await persistWorkspaceState( + { + ...currentState, + onboardingCelebrationSeen: false, + }, + userAddress + ); +}; + +const formatPlaybackTime = (seconds: number) => { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const remainder = safeSeconds % 60; + return `${minutes}:${String(remainder).padStart(2, '0')}`; +}; + +const isAbortError = (error: unknown) => + error instanceof DOMException && error.name === 'AbortError'; + +const buildTrackResourceKey = (track: Pick) => + track.id && track.name + ? `${EARBUMP_AUDIO_SERVICE}-${track.name}-${track.id}` + : ''; + +const buildTrackPlaybackUrl = (track: Pick) => + track.id && track.name + ? `${getBaseApiReact()}/arbitrary/${EARBUMP_AUDIO_SERVICE}/${encodeURIComponent( + track.name + )}/${encodeURIComponent(track.id)}` + : ''; + +const getTrackReadyState = ( + rawStatus: string | null | undefined, + hasTrack: boolean +): TrackReadyState => { + if (!hasTrack) return 'idle'; + if (rawStatus === 'READY') return 'ready'; + if ( + rawStatus === 'FAILED_TO_DOWNLOAD' || + rawStatus === 'BUILD_FAILED' || + rawStatus === 'NOT_PUBLISHED' || + rawStatus === 'BLOCKED' || + rawStatus === 'UNSUPPORTED' + ) { + return 'error'; + } + + return 'downloading'; +}; + +const getTrackReadyPercent = ( + rawStatus: string | null | undefined, + percent: number +) => { + if (rawStatus === 'READY') return 100; + if (Number.isFinite(percent) && percent > 0) { + return Math.min(Math.max(percent, 0), 100); + } + + if (rawStatus === 'DOWNLOADED') return 94; + if (rawStatus === 'BUILDING') return 97; + if (rawStatus === 'REFETCHING') return 42; + if (rawStatus === 'MISSING_DATA') return 34; + if (rawStatus === 'DOWNLOADING') return 18; + if (rawStatus === 'SEARCHING') return 10; + if (rawStatus === 'PUBLISHED' || rawStatus === 'INITIAL') return 6; + return 0; +}; + +const EMPTY_MUSIC_TRACK_BASE: MusicTrack = { + artist: 'EarBump', + coverColors: ['#6EA7FF', '#243B72', '#9CCBFF'], + created: 0, + id: '', + length: '--:--', + name: 'earbump', + status: null, + streamUrl: '', + title: '', + updated: null, + uploaded: '', +}; + +const QortinoMascot = ({ + isDarkMode, + isTickled, + isListening, + isTalking, + lookDebug, + mood, + showAntenna = true, +}: { + isDarkMode: boolean; + isTickled: boolean; + isListening: boolean; + isTalking: boolean; + lookDebug: QortinoLookDebugSettings; + mood: + | 'celebrate' + | 'empty' + | 'grateful' + | 'guide' + | 'hotkeys' + | 'music' + | 'notes'; + showAntenna?: boolean; +}) => { + const expression = isTickled + ? 'ticklish' + : mood === 'music' + ? 'music' + : mood === 'guide' + ? 'guide' + : mood === 'hotkeys' + ? 'focused' + : mood === 'notes' + ? 'attentive' + : mood === 'celebrate' + ? 'delighted' + : mood === 'grateful' + ? 'delighted' + : 'calm'; + const eyeStyle = { + attentive: { height: 8, top: 79, width: 9 }, + calm: { height: 9, top: 80, width: 9 }, + delighted: { height: 8, top: 79, width: 10 }, + focused: { height: 7, top: 81, width: 10 }, + guide: { height: 10, top: 78, width: 9 }, + music: { height: 10, top: 79, width: 10 }, + ticklish: { height: 2, top: 82, width: 14 }, + }[expression]; + const mouthStyle = { + attentive: { left: 69, top: 98, width: 28 }, + calm: { left: 70, top: 97, width: 26 }, + delighted: { left: 66, top: 94, width: 34 }, + focused: { left: 71, top: 98, width: 24 }, + guide: { left: 68, top: 96, width: 30 }, + music: { left: 67, top: 94, width: 32 }, + ticklish: { left: 66, top: 93, width: 34 }, + }[expression]; + const mascotAnimation = isTickled + ? 'qortinoTicklishBounce 0.58s ease-in-out infinite' + : isListening + ? 'qortinoMusicBounce 3s ease-in-out infinite' + : 'qortinoBob 5.8s ease-in-out infinite'; + const tickleRotateDeg = isTickled ? 1.35 : 0; + const tickleTransform = isTickled + ? 'translateY(-4px) scale(1.055)' + : 'translateY(0px) scale(1)'; + const leftEyeTransform = isTickled + ? `rotate(${(-tickleRotateDeg * 0.85).toFixed(2)}deg) scaleX(1.06)` + : 'rotate(0deg) scaleX(1)'; + const rightEyeTransform = isTickled + ? `rotate(${(tickleRotateDeg * 0.85).toFixed(2)}deg) scaleX(1.06)` + : 'rotate(0deg) scaleX(1)'; + const faceRootLeft = 44; + const faceRootTop = 58; + const faceRootWidth = 80; + const faceRootHeight = 58; + const faceLeftEyeLeft = 20; + const faceRightEyeLeft = 50; + const faceEyeTop = eyeStyle.top - faceRootTop; + const faceMouthLeft = mouthStyle.left - faceRootLeft; + const faceMouthTop = mouthStyle.top - faceRootTop; + const antennaBubbleSize = 20; + const antennaBubbleOverlap = 9; + const antennaStemHeight = Math.max( + 11, + Math.round(20 * lookDebug.antennaLength) + ); + const antennaContainerHeight = + antennaBubbleSize - antennaBubbleOverlap + antennaStemHeight; + + return ( + + + + + + + + + + + + + + + + + {showAntenna ? ( + + + + + ) : null} + {mood === 'guide' && } + {mood === 'hotkeys' && ( + <> + + + + )} + {mood === 'notes' && ( + <> + + + + )} + {isListening && ( + <> + + + + + )} + {mood === 'celebrate' && ( + <> + + + + + )} + + + + + ); +}; + +const MusicNoteDecoration = ({ + delay, + isDarkMode, + left, + size = 16, + top, +}: { + delay: number; + isDarkMode: boolean; + left: string; + size?: number; + top: string; +}) => ( + + + +); + +const GuideBeacon = ({ isDarkMode }: { isDarkMode: boolean }) => ( + +); + +const RouteDecoration = ({ + delay, + isDarkMode, + left, + top, +}: { + delay: number; + isDarkMode: boolean; + left: string; + top: string; +}) => ( + + + + +); + +const NoteCardDecoration = ({ + delay, + isDarkMode, + left, + top, +}: { + delay: number; + isDarkMode: boolean; + left: string; + top: string; +}) => ( + +); + +const SparkDecoration = ({ left, top }: { left: string; top: string }) => ( + + + +); + +const HOTKEY_PICKER_VIRTUAL_GAP_PX = 7; + +const HotkeyPickerVirtualScroll = memo(function HotkeyPickerVirtualScroll({ + rows, + renderRow, +}: { + rows: HotkeyPickerRow[]; + renderRow: (row: HotkeyPickerRow) => ReactNode; +}) { + const scrollRef = useRef(null); + + const estimateSize = useCallback( + (index: number) => { + const row = rows[index]; + if (!row) return 80; + if (row.kind === 'heading') return 26; + if (row.kind === 'divider') return 12; + return 88; + }, + [rows] + ); + + const getItemKey = useCallback( + (index: number) => { + const row = rows[index]; + if (!row) return `hotkey-picker-empty-${index}`; + // Index is required for unique keys per row slot (TanStack measures by key). + if (row.kind === 'heading') return `hotkey-picker-heading-${index}`; + if (row.kind === 'divider') return `hotkey-picker-divider-${index}`; + return `hotkey-picker-app-${index}-${row.app.service}::${row.app.appName}`; + }, + [rows] + ); + + const virtualizer = useVirtualizer({ + count: rows.length, + gap: HOTKEY_PICKER_VIRTUAL_GAP_PX, + getScrollElement: () => scrollRef.current, + estimateSize, + getItemKey, + overscan: 8, + useAnimationFrameWithResizeObserver: true, + }); + + useLayoutEffect(() => { + if (rows.length === 0) return; + const el = scrollRef.current; + if (el) { + el.scrollTop = 0; + } + virtualizer.measure(); + virtualizer.scrollToOffset(0, { behavior: 'auto' }); + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + virtualizer.measure(); + virtualizer.scrollToOffset(0, { behavior: 'auto' }); + }); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [rows, virtualizer]); + + return ( + + {rows.length > 0 ? ( + + + {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + return ( + + {renderRow(row)} + + ); + })} + + + ) : null} + + ); +}); + +export const HomeQortinoWorkspaceCard = ({ + onGettingStartedComplete, + onOpenAppsPanel, +}: HomeQortinoWorkspaceCardProps) => { + const { t } = useTranslation(['tutorial', 'core']); + const qw = useCallback( + ( + suffix: string, + fallback: string, + options?: Record + ) => { + const key = `core:qortino_workspace.${suffix}` as const; + if (options) { + return String(t(key, { ...options, defaultValue: fallback })); + } + return String(t(key, fallback)); + }, + [t] + ); + const theme = useTheme(); + + const isDarkMode = theme.palette.mode === 'dark'; + const panelRef = useDashboardPanelMouseLight(); + const userInfo = useAtomValue(userInfoAtom); + const balance = useAtomValue(balanceAtom); + const txList = useAtomValue(txListAtom); + const userAddress = userInfo?.address; + const name = userInfo?.name; + const openApp = useCallback( + (appName: string, path = '') => { + if (onOpenAppsPanel) { + onOpenAppsPanel(); + } else { + executeEvent('newTabWindow', {}); + executeEvent('open-apps-mode', {}); + } + + window.setTimeout(() => { + dispatchAppTab(appName, path); + }, 90); + }, + [onOpenAppsPanel] + ); + + const workspaceModules = useMemo( + () => [ + { + description: qw( + 'modules_hotkeys_description', + 'Curated shortcuts for your most-used routes.' + ), + icon: AppsRoundedIcon, + key: 'hotkeys', + label: qw('modules_hotkeys_label', 'Hotkeys'), + mode: 'hotkeys', + }, + { + description: qw( + 'modules_music_description', + 'A compact Earbump player with search and quick playback.' + ), + icon: LibraryMusicRoundedIcon, + key: 'music', + label: qw('modules_music_label', 'Music player'), + mode: 'music', + }, + { + appName: 'q-mail', + appPath: 'to/Qortino', + description: qw( + 'modules_suggest_description', + 'Compose > Add Subject + Message & Send it our way!' + ), + icon: MailOutlineRoundedIcon, + key: 'suggest-module', + label: qw('modules_suggest_label', 'Suggest a module'), + }, + ], + [qw] + ); + + const emptyMusicTrack = useMemo( + (): MusicTrack => ({ + ...EMPTY_MUSIC_TRACK_BASE, + title: qw('music_empty_title', 'EarBump library'), + uploaded: qw('music_empty_uploaded', 'Waiting for library'), + }), + [qw] + ); + + const [dismissed, setDismissed] = useState(null); + const [paymentsFallbackTotal, setPaymentsFallbackTotal] = useState< + number | null + >(null); + const [hasAvatar, setHasAvatar] = useState(false); + const [avatarStepCompleted, setAvatarStepCompleted] = useState(false); + const [checkingAvatar, setCheckingAvatar] = useState(false); + const [qortsAcquiredAcknowledged, setQortsAcquiredAcknowledged] = + useState(false); + const [showRegisterNameDelayHint, setShowRegisterNameDelayHint] = + useState(false); + const [openQortsDialog, setOpenQortsDialog] = useState(false); + const [openMusicSearchDialog, setOpenMusicSearchDialog] = useState(false); + const [openModulePickerDialog, setOpenModulePickerDialog] = useState(false); + const [openHotkeyPickerDialog, setOpenHotkeyPickerDialog] = useState(false); + const [availableHotkeyApps, setAvailableHotkeyApps] = useState< + HotkeyAppDefinition[] + >([]); + const [hotkeyAppsError, setHotkeyAppsError] = useState(null); + const [isHotkeyAppsLoading, setIsHotkeyAppsLoading] = useState(false); + const [workspaceState, setWorkspaceState] = useState(() => + loadWorkspaceStateFromFallbackStorage(userAddress) + ); + + const [workspaceHydrated, setWorkspaceHydrated] = useState(false); + const [selectedHotkeySlot, setSelectedHotkeySlot] = useState(0); + const [hotkeySearchQuery, setHotkeySearchQuery] = useState(''); + const [debouncedHotkeySearchQuery, setDebouncedHotkeySearchQuery] = + useState(''); + const [earbumpDiscoveryTracks, setEarbumpDiscoveryTracks] = useState< + MusicTrack[] + >([]); + const [earbumpSearchTracks, setEarbumpSearchTracks] = useState( + [] + ); + const [selectedTrackSnapshot, setSelectedTrackSnapshot] = + useState(() => getSharedEarbumpTrackSnapshot()); + const [musicTrackDurations, setMusicTrackDurations] = useState< + Record + >({}); + const [isEarbumpDiscoveryLoading, setIsEarbumpDiscoveryLoading] = + useState(false); + const [isEarbumpSearchLoading, setIsEarbumpSearchLoading] = useState(false); + const [earbumpDiscoveryError, setEarbumpDiscoveryError] = useState< + string | null + >(null); + const [earbumpSearchError, setEarbumpSearchError] = useState( + null + ); + const [musicStreamError, setMusicStreamError] = useState(null); + const [ephemeralReaction, setEphemeralReaction] = useState( + null + ); + const [onboardingTransitionMessage, setOnboardingTransitionMessage] = + useState(null); + const [postOnboardingMessage, setPostOnboardingMessage] = useState< + string | null + >(null); + const [ + showOnboardingCompletionConfetti, + setShowOnboardingCompletionConfetti, + ] = useState(false); + const [isQortinoTickled, setIsQortinoTickled] = useState(false); + const [qortinoGratefulState, setQortinoGratefulState] = + useState(null); + const [qortinoDonationOverlayState, setQortinoDonationOverlayState] = + useState(null); + const audioRef = useRef(getSharedEarbumpAudio()); + const discoveryRequestRef = useRef(null); + const searchRequestRef = useRef(null); + const qortinoGratefulTimeoutRef = useRef(null); + const qortinoDonationOverlayTimeoutRef = useRef(null); + const selectedTrackRequestRef = useRef(null); + const reactionTimeoutRef = useRef | null>(null); + const onboardingMessageTimeoutRef = useRef | null>(null); + const onboardingConfettiTimeoutRef = useRef | null>(null); + const lastReactionRef = useRef(null); + const lastReactionAtRef = useRef(0); + const onboardingBubbleLockRef = useRef(false); + const onboardingBubbleHoldRef = useRef(false); + const previousOnboardingStepRef = useRef(null); + const onboardingJustCompletedRef = useRef(false); + const wasOnboardingVisibleRef = useRef(false); + const avatarCompletionAfterPanelCloseRef = useRef(false); + const downloadResource = useFetchResources(); + const clearMusicStreamError = useCallback( + () => setMusicStreamError(null), + [] + ); + + const runtimeReactionFingerprints = useMemo( + () => + new Set([ + String(t('core:quick_tools_pad.hint_notifications_desktop_only')), + String(t('core:quick_tools_pad.hint_minting_panel')), + qw('reaction_hotkeys_ready', 'Hotkeys panel ready.'), + qw('reaction_music_panel_ready', 'Music panel ready.'), + qw('reaction_music_player_ready', 'Music player ready.'), + qw('reaction_panel_cleared', 'Panel cleared.'), + ]), + [t, qw] + ); + + const resolveCompanionReaction = useCallback( + ( + payload: WorkspaceCompanionReactionPayload + ): { allowStructuredBypass: boolean; text: string } => { + if (typeof payload === 'string') { + return { + allowStructuredBypass: false, + text: payload.trim(), + }; + } + + if (payload.kind === 'locked_track') { + return { + allowStructuredBypass: true, + text: qw('reaction_locked_track', 'Locked on {{title}}.', { + title: payload.title, + }).trim(), + }; + } + + return { + allowStructuredBypass: true, + text: qw('reaction_track_rotation', '{{title}} is in rotation.', { + title: payload.title, + }).trim(), + }; + }, + [qw] + ); + + const pushReaction = useCallback( + ( + payload: WorkspaceCompanionReactionPayload, + options?: { allowDuringOnboarding?: boolean } + ) => { + const { allowStructuredBypass, text: trimmedMessage } = + resolveCompanionReaction(payload); + if (!trimmedMessage) return; + + if ( + (onboardingBubbleLockRef.current || onboardingBubbleHoldRef.current) && + options?.allowDuringOnboarding !== true + ) { + return; + } + + const isAllowedRuntimeReaction = + allowStructuredBypass || + runtimeReactionFingerprints.has(trimmedMessage); + + if ( + onboardingBubbleLockRef.current !== true && + options?.allowDuringOnboarding !== true && + !isAllowedRuntimeReaction + ) { + return; + } + + const now = Date.now(); + if ( + lastReactionRef.current === trimmedMessage && + now - lastReactionAtRef.current < 1400 + ) { + return; + } + + lastReactionRef.current = trimmedMessage; + lastReactionAtRef.current = now; + + if (reactionTimeoutRef.current) { + window.clearTimeout(reactionTimeoutRef.current); + } + setEphemeralReaction(trimmedMessage); + reactionTimeoutRef.current = window.setTimeout(() => { + setEphemeralReaction(null); + }, 6800); + }, + [resolveCompanionReaction, runtimeReactionFingerprints] + ); + + useEffect(() => { + audioRef.current = getSharedEarbumpAudio(); + + return () => { + if (reactionTimeoutRef.current) { + window.clearTimeout(reactionTimeoutRef.current); + } + + if (onboardingMessageTimeoutRef.current) { + window.clearTimeout(onboardingMessageTimeoutRef.current); + } + + if (onboardingConfettiTimeoutRef.current) { + window.clearTimeout(onboardingConfettiTimeoutRef.current); + } + + if (qortinoGratefulTimeoutRef.current) { + window.clearTimeout(qortinoGratefulTimeoutRef.current); + } + + discoveryRequestRef.current?.abort(); + searchRequestRef.current?.abort(); + selectedTrackRequestRef.current?.abort(); + }; + }, []); + + const applyWorkspaceState = useCallback( + (updater: (current: WorkspaceState) => WorkspaceState) => { + setWorkspaceState((current) => sanitizeWorkspaceState(updater(current))); + }, + [] + ); + + useEffect(() => { + const handleLogout = () => { + stopSharedEarbumpPlayback(); + setMusicStreamError(null); + applyWorkspaceState((current) => + current.musicPlaying + ? { + ...current, + musicPlaying: false, + } + : current + ); + }; + + subscribeToEvent('logout-event', handleLogout); + + return () => { + unsubscribeFromEvent('logout-event', handleLogout); + }; + }, [applyWorkspaceState]); + + useEffect(() => { + if (userAddress != null) { + return; + } + + stopSharedEarbumpPlayback(); + setIsQortinoTickled(false); + setQortinoGratefulState(null); + setMusicStreamError(null); + setSelectedTrackSnapshot(null); + }, [userAddress]); + + const handleQortinoPointerDown = useCallback( + (event: ReactPointerEvent) => { + event.currentTarget.setPointerCapture?.(event.pointerId); + setIsQortinoTickled(true); + }, + [] + ); + + const handleQortinoPointerRelease = useCallback(() => { + setIsQortinoTickled(false); + }, []); + + const showQortinoDonationOverlay = useCallback( + ({ + durationMs, + message, + mood, + statusLabel, + }: { + durationMs: number; + message: string; + }) => { + if (qortinoDonationOverlayTimeoutRef.current != null) { + window.clearTimeout(qortinoDonationOverlayTimeoutRef.current); + } + setQortinoDonationOverlayState({ + message, + nonce: Date.now(), + }); + qortinoDonationOverlayTimeoutRef.current = window.setTimeout(() => { + setQortinoDonationOverlayState(null); + qortinoDonationOverlayTimeoutRef.current = null; + }, durationMs); + }, + [] + ); + + const handleQortinoDonationDragOver = useCallback( + (event: ReactDragEvent) => { + if (!event.dataTransfer.types.includes(QORTINO_DONATION_DRAG_TYPE)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, + [] + ); + + const handleQortinoDonationDrop = useCallback( + (event: ReactDragEvent) => { + if (!event.dataTransfer.types.includes(QORTINO_DONATION_DRAG_TYPE)) { + return; + } + event.preventDefault(); + executeEvent('openPaymentInternal', { + name: QORTINO_DONATION_PREFILL_NAME, + }); + showQortinoDonationOverlay({ + durationMs: QORTINO_DONATION_OVERLAY_DURATION_MS, + message: QORTINO_DONATION_BUBBLE_MESSAGE, + }); + }, + [showQortinoDonationOverlay] + ); + + useEffect( + () => () => { + if (qortinoGratefulTimeoutRef.current != null) { + window.clearTimeout(qortinoGratefulTimeoutRef.current); + } + if (qortinoDonationOverlayTimeoutRef.current != null) { + window.clearTimeout(qortinoDonationOverlayTimeoutRef.current); + } + }, + [] + ); + + useEffect(() => { + const handleQortinoDonationCompleted = (event: Event) => { + const recipient = + ( + event as CustomEvent<{ + recipient?: string; + }> + )?.detail?.recipient ?? ''; + + if ( + recipient.trim().toLowerCase() !== + QORTINO_DONATION_PREFILL_NAME.toLowerCase() + ) { + return; + } + + if (qortinoGratefulTimeoutRef.current != null) { + window.clearTimeout(qortinoGratefulTimeoutRef.current); + } + + setQortinoGratefulState({ + message: QORTINO_DONATION_THANK_YOU_MESSAGE, + nonce: Date.now(), + }); + + qortinoGratefulTimeoutRef.current = window.setTimeout(() => { + setQortinoGratefulState(null); + qortinoGratefulTimeoutRef.current = null; + }, QORTINO_DONATION_GRATEFUL_DURATION_MS); + }; + + subscribeToEvent( + QORTINO_DONATION_COMPLETED_EVENT, + handleQortinoDonationCompleted + ); + + return () => { + unsubscribeFromEvent( + QORTINO_DONATION_COMPLETED_EVENT, + handleQortinoDonationCompleted + ); + }; + }, []); + + useEffect(() => { + if (userAddress == null) { + setDismissed(null); + setAvatarStepCompleted(false); + setQortsAcquiredAcknowledged(false); + setShowRegisterNameDelayHint(false); + setShowOnboardingCompletionConfetti(false); + avatarCompletionAfterPanelCloseRef.current = false; + return; + } + + setDismissed( + localStorage.getItem(`${LS_KEY}_${userAddress}`) === 'completed' + ); + setAvatarStepCompleted(false); + setQortsAcquiredAcknowledged(false); + setShowRegisterNameDelayHint(false); + setShowOnboardingCompletionConfetti(false); + avatarCompletionAfterPanelCloseRef.current = false; + }, [userAddress]); + + useEffect(() => { + let active = true; + setWorkspaceState(loadWorkspaceStateFromFallbackStorage(userAddress)); + setWorkspaceHydrated(false); + + void loadWorkspaceState(userAddress).then((nextState) => { + if (!active) return; + setWorkspaceState(nextState); + setWorkspaceHydrated(true); + }); + + return () => { + active = false; + }; + }, [userAddress]); + + useEffect(() => { + if (!workspaceHydrated) return; + void persistWorkspaceState(workspaceState, userAddress); + }, [userAddress, workspaceHydrated, workspaceState]); + + useEffect(() => { + if (!selectedTrackSnapshot?.id) { + return; + } + + setSharedEarbumpTrackSnapshot(selectedTrackSnapshot); + }, [selectedTrackSnapshot]); + + const checkAvatar = useCallback(async () => { + if (!name) return; + + try { + setCheckingAvatar(true); + const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=${AVATAR_SERVICE}&identifier=${AVATAR_IDENTIFIER}&limit=1&name=${name}&includemetadata=false&prefix=true`; + const response = await fetch(url); + const data = await response.json(); + setHasAvatar(Array.isArray(data) && data.length > 0); + } catch { + setHasAvatar(false); + } finally { + setCheckingAvatar(false); + } + }, [name]); + + useEffect(() => { + void checkAvatar(); + }, [checkAvatar]); + + useEffect(() => { + if (dismissed !== false || !userAddress) return; + + const balanceNum = balance != null ? Number(balance) : null; + if (balanceNum != null && balanceNum >= MIN_BALANCE_FOR_QORTS) return; + + const url = `${getBaseApiReact()}/transactions/payments/between?recipientAddress=${encodeURIComponent(userAddress)}&confirmationStatus=CONFIRMED&limit=20`; + let cancelled = false; + + fetch(url) + .then((res) => res.json()) + .then((data: Array<{ amount?: string }>) => { + if (cancelled || !Array.isArray(data)) return; + const total = data.reduce( + (sum, tx) => sum + (parseFloat(tx?.amount ?? '0') || 0), + 0 + ); + setPaymentsFallbackTotal(total); + }) + .catch(() => { + if (!cancelled) setPaymentsFallbackTotal(0); + }); + + return () => { + cancelled = true; + }; + }, [balance, dismissed, userAddress]); + + const realHasQorts = + (balance != null && Number(balance) >= MIN_BALANCE_FOR_QORTS) || + (paymentsFallbackTotal != null && + paymentsFallbackTotal >= MIN_BALANCE_FOR_QORTS); + const hasQorts = realHasQorts; + const hasName = Boolean(name); + const hasPendingRegisterName = + (txList?.some((tx) => tx?.type === 'register-name' && !tx?.done) ?? + false) && + !hasName; + const resolvedHasAvatar = hasAvatar || avatarStepCompleted; + const hasCompletionChecksPending = checkingAvatar; + + useEffect(() => { + if ( + !hasCompletionChecksPending && + hasQorts && + hasName && + resolvedHasAvatar && + dismissed === false && + userAddress + ) { + localStorage.setItem(`${LS_KEY}_${userAddress}`, 'completed'); + onboardingJustCompletedRef.current = true; + setDismissed(true); + applyWorkspaceState((current) => ({ + ...current, + onboardingCelebrationSeen: true, + })); + onGettingStartedComplete?.(); + } + }, [ + applyWorkspaceState, + dismissed, + hasCompletionChecksPending, + hasName, + hasQorts, + onGettingStartedComplete, + pushReaction, + resolvedHasAvatar, + userAddress, + ]); + + const isWorkspaceFreshlyUnlocked = + showOnboardingCompletionConfetti && Boolean(postOnboardingMessage); + + const hotkeyActions = useMemo>( + () => ({ + earbump: { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Ear-Bump', + }), + icon: LibraryMusicRoundedIcon, + id: 'earbump', + label: 'Ear-Bump', + run: () => openApp('Ear-Bump'), + }, + 'q-blog': { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Q-Blog', + }), + icon: EditRoundedIcon, + id: 'q-blog', + label: 'Q-Blog', + run: () => openApp('Q-Blog'), + }, + 'q-mail': { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Q-Mail', + }), + icon: MailOutlineRoundedIcon, + id: 'q-mail', + label: 'Q-Mail', + run: () => openApp('q-mail'), + }, + 'q-trade': { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Q-Trade', + }), + icon: ShoppingBagRoundedIcon, + id: 'q-trade', + label: 'Q-Trade', + run: () => openApp('Q-Trade'), + }, + 'q-mintership': { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Q-Mintership', + }), + icon: SpaRoundedIcon, + id: 'q-mintership', + label: 'Q-Mintership', + run: () => openApp('q-mintership'), + }, + 'q-tube': { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Q-Tube', + }), + icon: VideoLibraryRoundedIcon, + id: 'q-tube', + label: 'Q-Tube', + run: () => openApp('Q-Tube'), + }, + quitter: { + description: qw('hotkeys_launch_description', 'Launch {{appName}}', { + appName: 'Quitter', + }), + icon: ForumRoundedIcon, + id: 'quitter', + label: 'Quitter', + run: () => openApp('Quitter'), + }, + }), + [openApp, qw] + ); + + const hotkeyCatalog = useMemo( + () => + availableHotkeyApps.length > 0 + ? availableHotkeyApps + : (Object.keys(hotkeyActions) as HotkeyActionId[]).map((id) => ({ + appName: hotkeyActions[id].label, + description: hotkeyActions[id].description, + label: hotkeyActions[id].label, + service: 'APP' as const, + })), + [availableHotkeyApps.length, availableHotkeyApps, hotkeyActions] + ); + + const loadHotkeyApps = useCallback(async () => { + if (isHotkeyAppsLoading) { + return; + } + + setIsHotkeyAppsLoading(true); + setHotkeyAppsError(null); + + try { + const urls = [ + `${getBaseApiReact()}/arbitrary/resources/search?service=APP&mode=ALL&limit=0&includestatus=true&includemetadata=true`, + `${getBaseApiReact()}/arbitrary/resources/search?service=WEBSITE&mode=ALL&limit=0&includestatus=true&includemetadata=true`, + ]; + const responses = await Promise.all( + urls.map((url) => + fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + }) + ) + ); + + if (responses.some((response) => !response.ok)) { + throw new Error('Unable to load Q-Apps.'); + } + + const responseData = ( + await Promise.all(responses.map((response) => response.json())) + ).flat() as QAppResourceRecord[]; + const nextCatalog = Array.from( + new Map( + (Array.isArray(responseData) ? responseData : []) + .map((resource) => { + const appName = + typeof resource?.name === 'string' ? resource.name.trim() : ''; + const service = + resource?.service === 'WEBSITE' ? 'WEBSITE' : 'APP'; + if (!appName) { + return null; + } + + const label = + typeof resource.metadata?.title === 'string' && + resource.metadata.title.trim().length > 0 + ? resource.metadata.title.trim() + : appName; + const description = + typeof resource.metadata?.description === 'string' + ? resource.metadata.description.trim() + : ''; + + return [ + `${service.toLowerCase()}::${appName.toLowerCase()}`, + { + appName, + description, + label, + service, + } satisfies HotkeyAppDefinition, + ] as const; + }) + .filter( + (item): item is readonly [string, HotkeyAppDefinition] => + item != null + ) + ).values() + ).sort((left, right) => + left.label.localeCompare(right.label, undefined, { + sensitivity: 'base', + }) + ); + + setAvailableHotkeyApps(nextCatalog); + } catch (error) { + console.error(error); + setHotkeyAppsError( + qw('error_hotkey_catalog', 'Unable to load Q-Apps right now.') + ); + } finally { + setIsHotkeyAppsLoading(false); + } + }, [isHotkeyAppsLoading, qw]); + + useEffect(() => { + if ( + workspaceState.mode !== 'hotkeys' && + !openHotkeyPickerDialog && + availableHotkeyApps.length > 0 + ) { + return; + } + + if ( + (workspaceState.mode === 'hotkeys' || openHotkeyPickerDialog) && + availableHotkeyApps.length === 0 && + !isHotkeyAppsLoading + ) { + void loadHotkeyApps(); + } + }, [ + availableHotkeyApps.length, + isHotkeyAppsLoading, + loadHotkeyApps, + openHotkeyPickerDialog, + workspaceState.mode, + ]); + + const hotkeyAppsByName = useMemo(() => { + const nextMap = new Map(); + availableHotkeyApps.forEach((app) => { + nextMap.set( + `${app.service.toLowerCase()}${HOTKEY_SLOT_VALUE_SEPARATOR}${app.appName.toLowerCase()}`, + app + ); + if (!nextMap.has(app.appName.toLowerCase())) { + nextMap.set(app.appName.toLowerCase(), app); + } + }); + return nextMap; + }, [availableHotkeyApps]); + + const resolveHotkeyApp = useCallback( + (slotValue: string): HotkeyAppDefinition => { + const parsedSlot = parseHotkeySlotValue(slotValue); + const knownApp = + hotkeyAppsByName.get( + `${parsedSlot.service.toLowerCase()}${HOTKEY_SLOT_VALUE_SEPARATOR}${parsedSlot.appName.toLowerCase()}` + ) ?? hotkeyAppsByName.get(parsedSlot.appName.toLowerCase()); + + if (knownApp) { + return knownApp; + } + + return { + appName: parsedSlot.appName, + description: qw('hotkeys_fallback_launch', 'Launch Q-App'), + label: formatHotkeyAppLabel(parsedSlot.appName), + service: parsedSlot.service, + }; + }, + [hotkeyAppsByName, qw] + ); + + const steps = useMemo( + () => [ + { + accent: '#92B8FF', + actionLabel: t( + 'tutorial:home.get_six_qorts_way3_action', + 'Open Q-Trade' + ), + ctaLabel: t('tutorial:home.get_six_qorts', 'Get 6 QORT'), + done: hasQorts, + helper: t( + 'tutorial:home.get_qorts_workspace_hint', + 'Unlock your first 6 QORT to activate the rest of the setup.' + ), + icon: ShoppingBagRoundedIcon, + key: 'get_six_qorts' as const, + label: t('tutorial:home.get_six_qorts', 'Get 6 QORT'), + onAction: () => setOpenQortsDialog(true), + }, + { + accent: '#8DBEFF', + actionLabel: t('tutorial:home.open', 'Open'), + ctaLabel: t('tutorial:home.register_name', 'Register your name'), + done: hasName, + helper: t( + 'tutorial:home.register_name_workspace_hint', + 'A registered name turns this account into a recognizable identity.' + ), + icon: DriveFileRenameOutlineRoundedIcon, + key: 'register_name' as const, + label: hasPendingRegisterName + ? t('tutorial:home.confirming', 'Confirming') + : t('tutorial:home.register_name', 'Register your name'), + loading: hasPendingRegisterName, + onAction: () => executeEvent('openRegisterName', {}), + }, + { + accent: '#8DBEFF', + actionLabel: t('tutorial:home.open', 'Open'), + ctaLabel: t('tutorial:home.load_avatar', 'Load your avatar'), + done: resolvedHasAvatar, + helper: t( + 'tutorial:home.load_avatar_workspace_hint', + 'Give the dashboard a face so the whole space starts to feel like yours.' + ), + icon: UploadRoundedIcon, + key: 'load_avatar' as const, + label: t('tutorial:home.load_avatar', 'Load your avatar'), + loading: checkingAvatar, + onAction: () => executeEvent('openAvatarUpload', {}), + }, + ], + [ + checkingAvatar, + hasName, + hasPendingRegisterName, + hasQorts, + resolvedHasAvatar, + t, + ] + ); + + const completedCount = useMemo( + () => steps.filter((step) => step.done).length, + [steps] + ); + const currentProgressStep = useMemo( + () => Math.min(completedCount + 1, steps.length), + [completedCount, steps.length] + ); + const isOnboardingVisible = dismissed === false; + const isQortsAcquiredAwaitingNext = + isOnboardingVisible && + hasQorts && + !hasName && + !hasPendingRegisterName && + !qortsAcquiredAcknowledged; + const currentProgressStepDisplay = isQortsAcquiredAwaitingNext + ? 1 + : currentProgressStep; + const baseCurrentStep = + steps.find((step) => !step.done) ?? steps[steps.length - 1]; + const currentStep = isQortsAcquiredAwaitingNext + ? { + ...steps[0], + ctaLabel: t('tutorial:home.next', 'Next'), + done: true, + helper: t( + 'tutorial:home.qorts_acquired_hint', + 'The hardest part is over. Press Next when you are ready to register your name.' + ), + label: t('tutorial:home.qorts_acquired', 'QORT acquired'), + loading: false, + } + : baseCurrentStep; + const CurrentStepIcon = currentStep.icon; + + useEffect(() => { + onboardingBubbleLockRef.current = isOnboardingVisible; + }, [isOnboardingVisible]); + + useEffect(() => { + onboardingBubbleHoldRef.current = + onboardingTransitionMessage != null || postOnboardingMessage != null; + }, [onboardingTransitionMessage, postOnboardingMessage]); + + useEffect(() => { + if (!isOnboardingVisible) { + previousOnboardingStepRef.current = null; + return; + } + + const previousStepKey = previousOnboardingStepRef.current; + previousOnboardingStepRef.current = currentStep.key; + + if (previousStepKey == null || previousStepKey === currentStep.key) { + return; + } + + const nextRecognitionMessage = + previousStepKey === 'get_six_qorts' && currentStep.key === 'register_name' + ? t( + 'tutorial:home.onboarding_transition_hard_part_done', + 'Nice work. The hardest part is done.' + ) + : previousStepKey === 'register_name' && + currentStep.key === 'load_avatar' + ? t( + 'tutorial:home.onboarding_transition_one_more', + 'Great. One more to go.' + ) + : null; + + if (!nextRecognitionMessage) { + setOnboardingTransitionMessage(null); + return; + } + + if (onboardingMessageTimeoutRef.current) { + window.clearTimeout(onboardingMessageTimeoutRef.current); + onboardingMessageTimeoutRef.current = null; + } + + setOnboardingTransitionMessage(nextRecognitionMessage); + onboardingMessageTimeoutRef.current = window.setTimeout(() => { + onboardingMessageTimeoutRef.current = null; + setOnboardingTransitionMessage(null); + }, ONBOARDING_RECOGNITION_DURATION_MS); + }, [currentStep.key, isOnboardingVisible, t]); + + useEffect(() => { + if ( + !isOnboardingVisible || + currentStep.key !== 'register_name' || + !hasPendingRegisterName + ) { + setShowRegisterNameDelayHint(false); + return; + } + + setShowRegisterNameDelayHint(false); + const hintTimer = window.setTimeout(() => { + setShowRegisterNameDelayHint(true); + }, 5000); + + return () => { + window.clearTimeout(hintTimer); + }; + }, [currentStep.key, hasPendingRegisterName, isOnboardingVisible]); + + useEffect(() => { + const handleAvatarUploaded = () => { + if (isOnboardingVisible && currentStep.key === 'load_avatar') { + avatarCompletionAfterPanelCloseRef.current = true; + return; + } + + setHasAvatar(true); + void checkAvatar(); + }; + + const handleAvatarUploadClosed = () => { + if (!avatarCompletionAfterPanelCloseRef.current) { + return; + } + + avatarCompletionAfterPanelCloseRef.current = false; + setAvatarStepCompleted(true); + setHasAvatar(true); + void checkAvatar(); + }; + + subscribeToEvent('avatarUploaded', handleAvatarUploaded); + subscribeToEvent('avatarUploadClosed', handleAvatarUploadClosed); + + return () => { + unsubscribeFromEvent('avatarUploaded', handleAvatarUploaded); + unsubscribeFromEvent('avatarUploadClosed', handleAvatarUploadClosed); + }; + }, [checkAvatar, currentStep.key, isOnboardingVisible]); + + useEffect(() => { + const wasOnboardingVisible = wasOnboardingVisibleRef.current; + wasOnboardingVisibleRef.current = isOnboardingVisible; + + if (!wasOnboardingVisible && isOnboardingVisible) { + onboardingJustCompletedRef.current = false; + setPostOnboardingMessage(null); + setOnboardingTransitionMessage(null); + return; + } + + if (!wasOnboardingVisible || isOnboardingVisible) { + return; + } + + if (!onboardingJustCompletedRef.current) { + return; + } + + onboardingJustCompletedRef.current = false; + + if (reactionTimeoutRef.current) { + window.clearTimeout(reactionTimeoutRef.current); + reactionTimeoutRef.current = null; + } + + if (onboardingMessageTimeoutRef.current) { + window.clearTimeout(onboardingMessageTimeoutRef.current); + } + + setEphemeralReaction(null); + setOnboardingTransitionMessage(null); + setPostOnboardingMessage( + t( + 'tutorial:home.post_onboarding_workspace_ready', + 'All set. You can start building your workspace above.' + ) + ); + setShowOnboardingCompletionConfetti(true); + + if (onboardingConfettiTimeoutRef.current) { + window.clearTimeout(onboardingConfettiTimeoutRef.current); + } + + onboardingConfettiTimeoutRef.current = window.setTimeout(() => { + onboardingConfettiTimeoutRef.current = null; + setShowOnboardingCompletionConfetti(false); + }, 4200); + }, [isOnboardingVisible, t]); + + useEffect(() => { + if (workspaceState.mode === 'empty' || postOnboardingMessage == null) { + return; + } + + setPostOnboardingMessage(null); + }, [postOnboardingMessage, workspaceState.mode]); + + const musicSearchQuery = workspaceState.musicQuery.trim(); + const knownMusicTracksById = useMemo(() => { + const nextMap = new Map(); + + for (const track of earbumpDiscoveryTracks) { + nextMap.set(track.id, track); + } + + for (const track of earbumpSearchTracks) { + nextMap.set(track.id, track); + } + + if (selectedTrackSnapshot) { + nextMap.set(selectedTrackSnapshot.id, selectedTrackSnapshot); + } + + return nextMap; + }, [earbumpDiscoveryTracks, earbumpSearchTracks, selectedTrackSnapshot]); + + const resolveMusicTrack = useCallback( + (track: MusicTrack) => { + const knownDuration = musicTrackDurations[track.id]; + + if (!Number.isFinite(knownDuration) || knownDuration <= 0) { + return track; + } + + const durationLabel = formatPlaybackTime(knownDuration); + return track.length === durationLabel + ? track + : { + ...track, + length: durationLabel, + }; + }, + [musicTrackDurations] + ); + + useEffect(() => { + if (!workspaceHydrated || workspaceState.mode !== 'music') { + return undefined; + } + + discoveryRequestRef.current?.abort(); + const controller = new AbortController(); + discoveryRequestRef.current = controller; + setIsEarbumpDiscoveryLoading(true); + + void fetchEarbumpRecentTracks({ + limit: 12, + signal: controller.signal, + }) + .then((tracks) => { + setEarbumpDiscoveryTracks(tracks); + setEarbumpDiscoveryError(null); + + if (tracks.length === 0) { + return; + } + + setSelectedTrackSnapshot((current) => current ?? tracks[0]); + + applyWorkspaceState((current) => + current.selectedTrackId + ? current + : { + ...current, + selectedTrackId: tracks[0].id, + } + ); + }) + .catch((error: unknown) => { + if (isAbortError(error)) { + return; + } + + console.error('Failed to load EarBump discovery tracks', error); + setEarbumpDiscoveryTracks([]); + setEarbumpDiscoveryError( + qw('error_earbump_discovery', 'Unable to load EarBump right now.') + ); + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsEarbumpDiscoveryLoading(false); + } + }); + + return () => { + controller.abort(); + setIsEarbumpDiscoveryLoading(false); + }; + }, [applyWorkspaceState, qw, workspaceHydrated, workspaceState.mode]); + + useEffect(() => { + searchRequestRef.current?.abort(); + + if (workspaceState.mode !== 'music' || !musicSearchQuery) { + setEarbumpSearchTracks([]); + setEarbumpSearchError(null); + setIsEarbumpSearchLoading(false); + return undefined; + } + + const controller = new AbortController(); + searchRequestRef.current = controller; + const timeoutId = window.setTimeout(() => { + setIsEarbumpSearchLoading(true); + + void searchEarbumpTracks(musicSearchQuery, { + limit: 8, + signal: controller.signal, + }) + .then((tracks) => { + setEarbumpSearchTracks(tracks); + setEarbumpSearchError(null); + }) + .catch((error: unknown) => { + if (isAbortError(error)) { + return; + } + + console.error('Failed to search EarBump tracks', error); + setEarbumpSearchTracks([]); + setEarbumpSearchError( + qw('error_earbump_search', 'Unable to search EarBump right now.') + ); + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsEarbumpSearchLoading(false); + } + }); + }, 220); + + return () => { + window.clearTimeout(timeoutId); + controller.abort(); + }; + }, [musicSearchQuery, qw, workspaceState.mode]); + + useEffect(() => { + if ( + !workspaceHydrated || + workspaceState.mode !== 'music' || + !workspaceState.selectedTrackId + ) { + return undefined; + } + + const matchedTrack = knownMusicTracksById.get( + workspaceState.selectedTrackId + ); + if (matchedTrack) { + setSelectedTrackSnapshot(matchedTrack); + return undefined; + } + + selectedTrackRequestRef.current?.abort(); + const controller = new AbortController(); + selectedTrackRequestRef.current = controller; + + void fetchEarbumpTrackById(workspaceState.selectedTrackId, { + signal: controller.signal, + }) + .then((track) => { + if (track) { + setSelectedTrackSnapshot(track); + } + }) + .catch((error: unknown) => { + if (!isAbortError(error)) { + console.error('Failed to restore selected EarBump track', error); + } + }); + + return () => { + controller.abort(); + }; + }, [ + knownMusicTracksById, + workspaceHydrated, + workspaceState.mode, + workspaceState.selectedTrackId, + ]); + + const selectedTrackCandidate = workspaceState.selectedTrackId + ? (knownMusicTracksById.get(workspaceState.selectedTrackId) ?? + (selectedTrackSnapshot?.id === workspaceState.selectedTrackId + ? selectedTrackSnapshot + : null)) + : null; + const activeTrackSource = + selectedTrackCandidate ?? + earbumpDiscoveryTracks[0] ?? + selectedTrackSnapshot ?? + null; + const activeTrack = useMemo( + () => + activeTrackSource + ? resolveMusicTrack(activeTrackSource) + : emptyMusicTrack, + [activeTrackSource, emptyMusicTrack, resolveMusicTrack] + ); + const activeTrackResourceKey = useMemo( + () => buildTrackResourceKey(activeTrack), + [activeTrack] + ); + const activeTrackResource = useAtomValue( + resourceKeySelector(activeTrackResourceKey) + ); + const activeTrackResourceStatus = + typeof activeTrackResource?.status?.status === 'string' + ? activeTrackResource.status.status + : null; + const activeTrackPeerCount = + typeof activeTrackResource?.status?.numberOfPeers === 'number' + ? activeTrackResource.status.numberOfPeers + : null; + const activeTrackReadyPercent = getTrackReadyPercent( + activeTrackResourceStatus, + Number(activeTrackResource?.status?.percentLoaded ?? 0) + ); + const activeTrackReadyState = getTrackReadyState( + activeTrackResourceStatus, + Boolean(activeTrack.id) + ); + const activeTrackPlaybackUrl = + activeTrackReadyState === 'ready' ? buildTrackPlaybackUrl(activeTrack) : ''; + const isTrackPreparing = activeTrackReadyState === 'downloading'; + const isTrackReady = activeTrackReadyState === 'ready'; + const isTrackLoadError = activeTrackReadyState === 'error'; + const isResolvingSelectedTrack = + workspaceHydrated && + workspaceState.mode === 'music' && + Boolean(workspaceState.selectedTrackId) && + activeTrack.id !== workspaceState.selectedTrackId; + const activeTrackDurationSeconds = useMemo(() => { + if (!activeTrack.id) return 0; + + const audio = audioRef.current; + if (audio && Number.isFinite(audio.duration) && audio.duration > 0) { + return audio.duration; + } + + const storedDuration = musicTrackDurations[activeTrack.id]; + return Number.isFinite(storedDuration) && storedDuration > 0 + ? storedDuration + : 0; + }, [activeTrack.id, musicTrackDurations]); + const hasTrackPlaybackMetadata = activeTrackDurationSeconds > 0; + const isTrackPlayable = isTrackReady || hasTrackPlaybackMetadata; + const isTrackPeerStarved = + isTrackPreparing && + !hasTrackPlaybackMetadata && + activeTrackReadyPercent > 0 && + (activeTrackPeerCount ?? 0) === 0; + const discoveryTracks = useMemo( + () => + earbumpDiscoveryTracks + .filter((track) => track.id !== activeTrack.id) + .slice(0, 3) + .map(resolveMusicTrack), + [activeTrack.id, earbumpDiscoveryTracks, resolveMusicTrack] + ); + const browserTracks = useMemo(() => { + if (musicSearchQuery) { + return earbumpSearchTracks.map(resolveMusicTrack); + } + + return discoveryTracks; + }, [ + discoveryTracks, + earbumpSearchTracks, + musicSearchQuery, + resolveMusicTrack, + ]); + const playbackQueue = useMemo(() => { + const sourceTracks = musicSearchQuery + ? earbumpSearchTracks + : earbumpDiscoveryTracks; + const resolvedTracks = sourceTracks.map(resolveMusicTrack); + + if (!activeTrack.id) { + return resolvedTracks; + } + + return [ + activeTrack, + ...resolvedTracks.filter((track) => track.id !== activeTrack.id), + ]; + }, [ + activeTrack, + earbumpDiscoveryTracks, + earbumpSearchTracks, + musicSearchQuery, + resolveMusicTrack, + ]); + const isMusicBrowserLoading = musicSearchQuery + ? isEarbumpSearchLoading + : isEarbumpDiscoveryLoading; + const musicBrowserError = musicSearchQuery + ? earbumpSearchError + : earbumpDiscoveryError; + const musicLoadingHint = useMemo(() => { + if (!activeTrack.id) return null; + if (musicStreamError) return musicStreamError; + if (isTrackLoadError) { + return qw( + 'music_track_load_failed', + 'This track could not finish loading on your node.' + ); + } + if (hasTrackPlaybackMetadata) return null; + if (!isTrackPreparing || isTrackPlayable) return null; + + if (isTrackPeerStarved) { + return qw('music_no_peers_data', 'No peers for remaining data'); + } + + const roundedPercent = Math.round(activeTrackReadyPercent); + const peerCount = activeTrackPeerCount ?? 0; + + if (activeTrackReadyPercent > 0) { + if (peerCount > 0) { + return peerCount === 1 + ? qw( + 'music_preparing_peers_one', + 'Preparing on your node... {{percent}}% ({{count}} peer)', + { + percent: roundedPercent, + count: peerCount, + } + ) + : qw( + 'music_preparing_peers_other', + 'Preparing on your node... {{percent}}% ({{count}} peers)', + { + percent: roundedPercent, + count: peerCount, + } + ); + } + + return qw( + 'music_preparing_percent_only', + 'Preparing on your node... {{percent}}%', + { + percent: roundedPercent, + } + ); + } + + if (activeTrackResourceStatus === 'SEARCHING') { + return qw('music_searching_peers', 'Looking for peers...'); + } + + if (activeTrackResourceStatus === 'BUILDING') { + return qw( + 'music_finalizing_track', + 'Finalizing the track on your node...' + ); + } + + return qw('music_preparing_base', 'Preparing on your node...'); + }, [ + activeTrack.id, + activeTrackPeerCount, + activeTrackReadyPercent, + activeTrackResource, + activeTrackResourceStatus, + hasTrackPlaybackMetadata, + isTrackLoadError, + isTrackPeerStarved, + isTrackPreparing, + isTrackPlayable, + isTrackReady, + musicStreamError, + qw, + ]); + const musicStatusSlotMessage = useMemo( + () => + musicLoadingHint ?? + (isEarbumpDiscoveryLoading + ? qw('music_syncing_library', 'Syncing with EarBump library...') + : earbumpDiscoveryError), + [earbumpDiscoveryError, isEarbumpDiscoveryLoading, musicLoadingHint, qw] + ); + useEffect(() => { + if (workspaceState.mode !== 'music') { + return; + } + + if (!activeTrack.id || !activeTrack.name) { + setMusicStreamError(null); + return; + } + + if (activeTrackReadyState === 'ready' || hasTrackPlaybackMetadata) { + setMusicStreamError(null); + return; + } + + if (activeTrackReadyState === 'error') { + return; + } + + void downloadResource({ + identifier: activeTrack.id, + name: activeTrack.name, + service: EARBUMP_AUDIO_SERVICE, + }); + }, [ + activeTrack.id, + activeTrack.name, + activeTrackReadyState, + downloadResource, + hasTrackPlaybackMetadata, + workspaceState.mode, + ]); + + useEffect(() => { + if (!isTrackLoadError || !workspaceState.musicPlaying) { + return; + } + + applyWorkspaceState((current) => + current.musicPlaying + ? { + ...current, + musicPlaying: false, + } + : current + ); + }, [applyWorkspaceState, isTrackLoadError, workspaceState.musicPlaying]); + + useEffect(() => { + const id = window.setTimeout(() => { + setDebouncedHotkeySearchQuery(hotkeySearchQuery); + }, HOTKEY_PICKER_SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(id); + }, [hotkeySearchQuery]); + + const filteredHotkeyCatalog = useMemo(() => { + const normalized = debouncedHotkeySearchQuery.trim().toLowerCase(); + if (!normalized) return hotkeyCatalog; + + return hotkeyCatalog.filter((app) => + [app.appName, app.label, app.description].some((value) => + value.toLowerCase().includes(normalized) + ) + ); + }, [hotkeyCatalog, debouncedHotkeySearchQuery]); + + const featuredHotkeyCatalog = useMemo(() => { + const featuredOrder = new Map( + CURATED_HOTKEY_APP_NAMES.map((appName, index) => [ + appName.toLowerCase(), + index, + ]) + ); + + return filteredHotkeyCatalog + .filter((app) => featuredOrder.has(app.appName.toLowerCase())) + .sort( + (left, right) => + (featuredOrder.get(left.appName.toLowerCase()) ?? 999) - + (featuredOrder.get(right.appName.toLowerCase()) ?? 999) + ) + .slice(0, HOTKEY_SLOT_COUNT); + }, [filteredHotkeyCatalog]); + + const featuredHotkeyCatalogKeys = useMemo( + () => + new Set(featuredHotkeyCatalog.map((app) => app.appName.toLowerCase())), + [featuredHotkeyCatalog] + ); + + const libraryHotkeyCatalog = useMemo( + () => + filteredHotkeyCatalog.filter( + (app) => !featuredHotkeyCatalogKeys.has(app.appName.toLowerCase()) + ), + [featuredHotkeyCatalogKeys, filteredHotkeyCatalog] + ); + + const hotkeyPickerRows = useMemo((): HotkeyPickerRow[] => { + const rows: HotkeyPickerRow[] = []; + if (featuredHotkeyCatalog.length > 0) { + rows.push({ kind: 'heading' }); + featuredHotkeyCatalog.forEach((app) => + rows.push({ kind: 'app', app, curated: true }) + ); + } + if (featuredHotkeyCatalog.length > 0 && libraryHotkeyCatalog.length > 0) { + rows.push({ kind: 'divider' }); + } + libraryHotkeyCatalog.forEach((app) => + rows.push({ kind: 'app', app, curated: false }) + ); + return rows; + }, [featuredHotkeyCatalog, libraryHotkeyCatalog]); + + const isBayPickerOpen = openModulePickerDialog || openHotkeyPickerDialog; + + const qortinoMood = useMemo(() => { + if (qortinoGratefulState) return 'grateful' as const; + if (isBayPickerOpen) return 'guide' as const; + if (isOnboardingVisible) return 'guide' as const; + if (isWorkspaceFreshlyUnlocked) return 'celebrate' as const; + if (workspaceState.mode === 'music' && workspaceState.musicPlaying) { + return 'music' as const; + } + if (workspaceState.mode === 'hotkeys') return 'hotkeys' as const; + return 'empty' as const; + }, [ + qortinoGratefulState, + isOnboardingVisible, + isWorkspaceFreshlyUnlocked, + isBayPickerOpen, + workspaceState.mode, + workspaceState.musicPlaying, + ]); + + const persistentOnboardingMessage = useMemo(() => { + if (!isOnboardingVisible) { + return null; + } + + if (isQortsAcquiredAwaitingNext) { + return t( + 'tutorial:home.onboarding_press_next_when_ready', + 'Nice work. The hardest part is done. Press Next when you are ready.' + ); + } + + if ( + currentStep.key === 'register_name' && + hasPendingRegisterName && + showRegisterNameDelayHint + ) { + return t( + 'tutorial:home.register_name_pending_hint', + 'Saving name on-chain. This can take a moment.' + ); + } + + if (currentStep.key === 'get_six_qorts') { + return t( + 'tutorial:home.persistent_guide_get_qorts', + "Let's start with 6 QORT. Pick any option above." + ); + } + + if (currentStep.key === 'register_name') { + return t( + 'tutorial:home.persistent_guide_register_name', + 'Next, register your name.' + ); + } + + return t( + 'tutorial:home.persistent_guide_load_avatar', + 'Finally, add your avatar.' + ); + }, [ + currentStep.key, + hasPendingRegisterName, + isOnboardingVisible, + isQortsAcquiredAwaitingNext, + showRegisterNameDelayHint, + t, + ]); + const qortinoDisplayedMessage = truncateQortinoBubbleMessage( + qortinoGratefulState?.message?.trim() || + postOnboardingMessage?.trim() || + onboardingTransitionMessage?.trim() || + persistentOnboardingMessage?.trim() || + ephemeralReaction?.trim() || + null + ); + /* + if (isOnboardingVisible) { + if (currentStep.key === 'get_six_qorts') { + return 'We start with 6 QORT. Pick any route above and I’ll queue the next step.'; + } + if (currentStep.key === 'register_name') { + return 'Name next. That unlocks your identity across the hub.'; + } + return 'One last move. Give your profile a face and this bay becomes yours.'; + } + + if (isWorkspaceFreshlyUnlocked) { + return 'This bay is unlocked now. Choose the first module and I will start living around it.'; + } + + if (workspaceState.mode === 'hotkeys') { + return 'Quick routes armed. Tap a tile and I’ll keep pace.'; + } + + if (workspaceState.mode === 'music') { + if (workspaceState.musicPlaying) { + return `${activeTrack.title} is setting the tone. I’ll keep the bay calm while it plays.`; + } + return 'Drop into music mode when you want a little company.'; + } + + return 'The bay is free now. Add a widget or a hotkey deck and I’ll build around it.'; + }, [ + activeTrack.title, + currentStep.key, + ephemeralReaction, + isOnboardingVisible, + isWorkspaceFreshlyUnlocked, + isBayPickerOpen, + workspaceState.mode, + workspaceState.musicPlaying, + ]); + */ + + const firstEmptyHotkeySlot = useMemo( + () => workspaceState.hotkeys.findIndex((slot) => slot == null), + [workspaceState.hotkeys] + ); + const qortinoIsTalking = Boolean(qortinoDisplayedMessage); + const qortinoBodyStageScale = Math.max(1, qortinoLookDebug.bodyScale); + const qortinoBodyStageWidthScale = Math.max( + 1, + qortinoLookDebug.bodyScale * qortinoLookDebug.bodyWidthScale + ); + const qortinoAntennaStageBump = Math.max( + 0, + (qortinoLookDebug.antennaScale * qortinoLookDebug.antennaLength - 1) * 22 + ); + const qortinoMascotStageWidth = Math.round( + QORTINO_MASCOT_SIZE * qortinoBodyStageWidthScale + + QORTINO_MASCOT_STAGE_PADDING_X + ); + const qortinoMascotStageHeight = Math.round( + QORTINO_MASCOT_SIZE * qortinoBodyStageScale + + QORTINO_MASCOT_STAGE_PADDING_Y + + qortinoAntennaStageBump + ); + const handleCycleTrack = useCallback( + (direction: 'next' | 'previous') => { + if (playbackQueue.length === 0) { + return; + } + + const activeIndex = playbackQueue.findIndex( + (track) => track.id === workspaceState.selectedTrackId + ); + const currentIndex = activeIndex >= 0 ? activeIndex : 0; + const nextIndex = + direction === 'next' + ? (currentIndex + 1) % playbackQueue.length + : (currentIndex - 1 + playbackQueue.length) % playbackQueue.length; + const nextTrack = playbackQueue[nextIndex]; + + if (!nextTrack) { + return; + } + + setMusicStreamError(null); + setSelectedTrackSnapshot(nextTrack); + applyWorkspaceState((current) => ({ + ...current, + mode: 'music', + musicPlaying: true, + selectedTrackId: nextTrack.id, + })); + pushReaction({ kind: 'track_rotation', title: nextTrack.title }); + }, + [ + applyWorkspaceState, + playbackQueue, + pushReaction, + workspaceState.selectedTrackId, + ] + ); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return undefined; + } + + const handleLoadedMetadata = () => { + if ( + !activeTrack.id || + !Number.isFinite(audio.duration) || + audio.duration <= 0 + ) { + return; + } + + setMusicStreamError(null); + + setMusicTrackDurations((current) => { + if (current[activeTrack.id] === audio.duration) { + return current; + } + + return { + ...current, + [activeTrack.id]: audio.duration, + }; + }); + }; + + const handleEnded = () => { + if (workspaceState.repeatMode === 'one') { + audio.currentTime = 0; + void audio.play().catch((error) => { + console.error('Failed to replay EarBump track', error); + applyWorkspaceState((current) => ({ + ...current, + musicPlaying: false, + })); + }); + return; + } + + handleCycleTrack('next'); + }; + + const handleCanPlay = () => { + setMusicStreamError(null); + }; + + const handleError = () => { + console.error('Failed to stream EarBump audio track', activeTrack); + setMusicStreamError( + qw('music_playback_stalled', 'Playback stalled. Press play to retry.') + ); + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + applyWorkspaceState((current) => ({ + ...current, + musicPlaying: false, + })); + }; + + const handleStalled = () => { + if (!activeTrack.id || workspaceState.mode !== 'music') { + return; + } + + setMusicStreamError( + qw('music_stream_stalled', 'Track stalled. Rebuilding the stream...') + ); + void downloadResource({ + identifier: activeTrack.id, + name: activeTrack.name, + service: EARBUMP_AUDIO_SERVICE, + }); + }; + + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + audio.addEventListener('canplay', handleCanPlay); + audio.addEventListener('ended', handleEnded); + audio.addEventListener('error', handleError); + audio.addEventListener('stalled', handleStalled); + handleLoadedMetadata(); + + return () => { + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + audio.removeEventListener('canplay', handleCanPlay); + audio.removeEventListener('ended', handleEnded); + audio.removeEventListener('error', handleError); + audio.removeEventListener('stalled', handleStalled); + }; + }, [ + activeTrack, + activeTrack.id, + activeTrack.name, + applyWorkspaceState, + downloadResource, + handleCycleTrack, + qw, + workspaceState.mode, + workspaceState.repeatMode, + ]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) { + return; + } + + if (!workspaceHydrated || isResolvingSelectedTrack) { + return; + } + + if (!activeTrack.id || !activeTrackPlaybackUrl) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + return; + } + + if (audio.src !== activeTrackPlaybackUrl) { + audio.pause(); + audio.src = activeTrackPlaybackUrl; + audio.load(); + } + + if (workspaceState.mode !== 'music' || !workspaceState.musicPlaying) { + audio.pause(); + return; + } + + void audio.play().catch((error) => { + console.error('Failed to play EarBump stream', error); + applyWorkspaceState((current) => ({ + ...current, + musicPlaying: false, + })); + }); + }, [ + activeTrack.id, + activeTrackPlaybackUrl, + applyWorkspaceState, + isResolvingSelectedTrack, + workspaceHydrated, + workspaceState.mode, + workspaceState.musicPlaying, + ]); + + const handleSelectWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + applyWorkspaceState((current) => ({ + ...current, + mode, + musicPlaying: mode === 'music' ? current.musicPlaying : false, + onboardingCelebrationSeen: + current.onboardingCelebrationSeen || dismissed === true, + })); + + if (mode === 'hotkeys') { + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(true); + pushReaction(qw('reaction_hotkeys_ready', 'Hotkeys panel ready.')); + } else if (mode === 'music') { + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(false); + pushReaction(qw('reaction_music_panel_ready', 'Music panel ready.')); + } else { + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(false); + pushReaction(qw('reaction_panel_cleared', 'Panel cleared.')); + } + }, + [applyWorkspaceState, dismissed, pushReaction, qw] + ); + + const handleSetHotkey = useCallback( + (appName: string) => { + const parsedSlot = parseHotkeySlotValue(appName); + const knownApp = + hotkeyAppsByName.get( + `${parsedSlot.service.toLowerCase()}${HOTKEY_SLOT_VALUE_SEPARATOR}${parsedSlot.appName.toLowerCase()}` + ) ?? hotkeyAppsByName.get(parsedSlot.appName.toLowerCase()); + const nextSlotValue = encodeHotkeySlotValue( + knownApp?.service ?? parsedSlot.service, + knownApp?.appName ?? parsedSlot.appName + ); + applyWorkspaceState((current) => { + const nextHotkeys = [...current.hotkeys]; + nextHotkeys[selectedHotkeySlot] = nextSlotValue; + return { + ...current, + hotkeys: nextHotkeys, + mode: 'hotkeys', + onboardingCelebrationSeen: + current.onboardingCelebrationSeen || dismissed === true, + }; + }); + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(true); + setSelectedHotkeySlot((current) => + Math.min(current + 1, HOTKEY_SLOT_COUNT - 1) + ); + }, + [applyWorkspaceState, dismissed, hotkeyAppsByName, selectedHotkeySlot] + ); + + const handleClearHotkey = useCallback( + (slotIndex: number) => { + applyWorkspaceState((current) => { + const nextHotkeys = [...current.hotkeys]; + nextHotkeys[slotIndex] = null; + + return { + ...current, + hotkeys: nextHotkeys, + mode: 'hotkeys', + onboardingCelebrationSeen: + current.onboardingCelebrationSeen || dismissed === true, + }; + }); + setSelectedHotkeySlot(slotIndex); + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(true); + }, + [applyWorkspaceState, dismissed] + ); + + const handleRunHotkey = useCallback( + (slotValue: string) => { + if (!slotValue) { + return; + } + + const parsedSlot = parseHotkeySlotValue(slotValue); + const knownApp = + hotkeyAppsByName.get( + `${parsedSlot.service.toLowerCase()}${HOTKEY_SLOT_VALUE_SEPARATOR}${parsedSlot.appName.toLowerCase()}` + ) ?? hotkeyAppsByName.get(parsedSlot.appName.toLowerCase()); + + executeEvent('addTab', { + data: { + name: knownApp?.appName ?? parsedSlot.appName, + path: '', + service: knownApp?.service ?? parsedSlot.service, + }, + }); + executeEvent('open-apps-mode', {}); + }, + [hotkeyAppsByName] + ); + + const handleToggleTrack = useCallback( + (trackId: string) => { + if (!trackId) { + return; + } + + const audio = audioRef.current; + const isSameTrack = workspaceState.selectedTrackId === trackId; + const wasPlaying = workspaceState.musicPlaying; + const track = knownMusicTracksById.get(trackId) ?? null; + const hadPlaybackFailure = + isTrackLoadError || musicStreamError != null || audio?.error != null; + + if (isSameTrack && !wasPlaying && hadPlaybackFailure) { + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } + setMusicStreamError(null); + if (track) { + void downloadResource({ + identifier: track.id, + name: track.name, + service: EARBUMP_AUDIO_SERVICE, + }); + } + } + + applyWorkspaceState((current) => { + return { + ...current, + mode: 'music', + musicPlaying: isSameTrack ? !current.musicPlaying : true, + onboardingCelebrationSeen: + current.onboardingCelebrationSeen || dismissed === true, + selectedTrackId: trackId, + }; + }); + if (track) { + setSelectedTrackSnapshot(track); + } + + if (!isSameTrack) { + setMusicStreamError(null); + } + + if (track && !isSameTrack) { + pushReaction({ kind: 'locked_track', title: track.title }); + return; + } + if (!wasPlaying && isTrackPlayable) { + pushReaction(qw('reaction_music_player_ready', 'Music player ready.')); + return; + } + return; + }, + [ + applyWorkspaceState, + dismissed, + downloadResource, + isTrackLoadError, + isTrackPlayable, + knownMusicTracksById, + musicStreamError, + pushReaction, + qw, + workspaceState.musicPlaying, + workspaceState.selectedTrackId, + ] + ); + + const handleToggleRepeatMode = useCallback(() => { + applyWorkspaceState((current) => ({ + ...current, + repeatMode: current.repeatMode === 'all' ? 'one' : 'all', + })); + }, [applyWorkspaceState]); + + const handleSelectTrackFromBrowser = useCallback( + (trackId: string) => { + setOpenMusicSearchDialog(false); + handleToggleTrack(trackId); + }, + [handleToggleTrack] + ); + + const handleOpenModulePicker = useCallback(() => { + applyWorkspaceState((current) => ({ + ...current, + onboardingCelebrationSeen: true, + })); + setOpenHotkeyPickerDialog(false); + setOpenModulePickerDialog(true); + }, [applyWorkspaceState]); + + const handleOpenHotkeyPicker = useCallback( + (slotIndex = 0) => { + applyWorkspaceState((current) => ({ + ...current, + mode: 'hotkeys', + onboardingCelebrationSeen: + current.onboardingCelebrationSeen || dismissed === true, + })); + setSelectedHotkeySlot(slotIndex); + setHotkeySearchQuery(''); + setDebouncedHotkeySearchQuery(''); + setOpenModulePickerDialog(false); + setOpenHotkeyPickerDialog(true); + }, + [applyWorkspaceState, dismissed] + ); + + const workspaceLabelColor = alpha(theme.palette.text.secondary, 0.78); + const subtleLine = getBlueAmbientLineBackground(theme, 'soft'); + const curatedAccentBlue = isDarkMode + ? alpha(GROUP_ACTIVITY_BLUE.gradientTop, 0.96) + : alpha(GROUP_ACTIVITY_BLUE.gradientBottom, 0.92); + + const renderHotkeyPickerRow = useCallback( + (row: HotkeyPickerRow): ReactNode => { + if (row.kind === 'heading') { + return ( + + {t('core:qortino_workspace.section_recommended', 'Recommended')} + + ); + } + if (row.kind === 'divider') { + return ( + + ); + } + const app = row.app; + const curated = row.curated; + return ( + + handleSetHotkey(encodeHotkeySlotValue(app.service, app.appName)) + } + sx={{ + alignItems: 'center', + background: curated + ? `linear-gradient(180deg, ${alpha('#8DB8FF', 0.18)} 0%, ${alpha( + '#6EA7FF', + 0.08 + )} 100%)` + : theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.03)' + : 'rgba(20,24,32,0.03)', + border: curated + ? `1px solid ${alpha('#8DB8FF', 0.16)}` + : `1px solid ${alpha(theme.palette.common.white, isDarkMode ? 0.06 : 0.12)}`, + borderRadius: '14px', + boxSizing: 'border-box', + display: 'grid', + gap: '12px', + gridTemplateColumns: '40px minmax(0, 1fr) auto', + justifyItems: 'stretch', + px: 0.95, + py: 0.82, + position: 'relative', + textAlign: 'left', + width: '100%', + ...(curated + ? { + '&:hover': { + background: `linear-gradient(180deg, ${alpha('#8DB8FF', 0.26)} 0%, ${alpha( + '#6EA7FF', + 0.12 + )} 100%)`, + }, + } + : {}), + }} + > + {curated ? ( + + {qw('curated_badge', '[ CURATED ]')} + + ) : null} + + + + + + {app.label} + + + {app.description || app.appName} + + + + + ); + }, + [curatedAccentBlue, handleSetHotkey, isDarkMode, qw, t, theme] + ); + /* + useEffect(() => { + const handleAvatar = () => { + pushReaction('Avatar tools open. Let’s give this place a face.'); + }; + + const handleRegisterName = () => { + pushReaction('Name flow open. This is where the hub starts recognizing you.'); + }; + + const handleReceive = () => { + pushReaction('Receive panel open. Hold steady and let the address do the work.'); + }; + + const handleSend = () => { + pushReaction('Send panel open. We can move carefully from here.'); + }; + + const handleAppsLibrarySearch = ( + event: Event + ) => { + const query = + ( + event as CustomEvent<{ + data?: { + query?: string; + }; + }> + )?.detail?.data?.query ?? ''; + + if (typeof query === 'string' && query.trim().length > 0) { + pushReaction(`App search is tuned to ${query.trim()}.`); + return; + } + + pushReaction('App library open. We can wire the next lane from here.'); + }; + + const handleContextHint = (event: Event) => { + const message = + ( + event as CustomEvent<{ + data?: { message?: string }; + }> + )?.detail?.data?.message ?? ''; + + if (typeof message === 'string' && message.trim().length > 0) { + pushReaction(message.trim()); + } + }; + + const handleAddTab = (event: Event) => { + const data = + ( + event as CustomEvent<{ + data?: { + identifier?: string; + name?: string; + path?: string; + service?: string; + }; + }> + )?.detail?.data ?? {}; + const rawName = data.name?.toLowerCase?.() ?? ''; + + if (rawName === 'q-tube') { + pushReaction('Q-Tube launched. Feed the signal.'); + return; + } + + if (rawName === 'quitter') { + pushReaction('Quitter is live. Let’s read the pulse.'); + return; + } + + if (rawName === 'q-mail') { + pushReaction('Q-Mail opened. Quiet channel, clear signal.'); + return; + } + + if (rawName === 'q-blog') { + pushReaction('Q-Blog is up. This lane is for making a mark.'); + return; + } + + if (rawName === 'q-trade') { + pushReaction('Q-Trade is open. I’ll keep the board steady.'); + return; + } + + if (rawName === 'q-mintership') { + pushReaction('Q-Mintership opened. This is the path toward joining the minters.'); + return; + } + + if (rawName === 'earbump') { + pushReaction('Earbump tab open. If you play something, I’ll vibe with you.'); + } + }; + + subscribeToEvent('openAvatarUpload', handleAvatar); + subscribeToEvent('openRegisterName', handleRegisterName); + subscribeToEvent('openSendQortInternal', handleSend); + subscribeToEvent('openReceiveQortInternal', handleReceive); + subscribeToEvent('openAppsLibrarySearch', handleAppsLibrarySearch); + subscribeToEvent('qortinoContextHint', handleContextHint); + subscribeToEvent('addTab', handleAddTab); + + return () => { + unsubscribeFromEvent('openAvatarUpload', handleAvatar); + unsubscribeFromEvent('openRegisterName', handleRegisterName); + unsubscribeFromEvent('openSendQortInternal', handleSend); + unsubscribeFromEvent('openReceiveQortInternal', handleReceive); + unsubscribeFromEvent('openAppsLibrarySearch', handleAppsLibrarySearch); + unsubscribeFromEvent('qortinoContextHint', handleContextHint); + unsubscribeFromEvent('addTab', handleAddTab); + }; + }, [pushReaction]); + */ + + useEffect(() => { + const handleContextHint = (event: Event) => { + const message = + ( + event as CustomEvent<{ + data?: { message?: string }; + }> + )?.detail?.data?.message ?? ''; + + if (typeof message === 'string' && message.trim().length > 0) { + pushReaction(message.trim()); + } + }; + + subscribeToEvent('qortinoContextHint', handleContextHint); + + return () => { + unsubscribeFromEvent('qortinoContextHint', handleContextHint); + }; + }, [pushReaction]); + + /* + useEffect(() => { + if (dismissed !== true) return; + + const interval = window.setInterval(() => { + const recentlySpoke = Date.now() - lastReactionAtRef.current < 240000; + if ( + recentlySpoke || + openModulePickerDialog || + openHotkeyPickerDialog || + openMusicSearchDialog || + workspaceState.musicPlaying + ) { + return; + } + + const nextFact = QORTINO_IDLE_FACTS[idleFactIndexRef.current]; + idleFactIndexRef.current = + (idleFactIndexRef.current + 1) % QORTINO_IDLE_FACTS.length; + pushReaction(nextFact); + }, 300000); + + return () => { + window.clearInterval(interval); + }; + }, [ + dismissed, + openHotkeyPickerDialog, + openModulePickerDialog, + openMusicSearchDialog, + pushReaction, + workspaceState.musicPlaying, + ]); + */ + + const handleRunCurrentStepAction = useCallback(() => { + if (currentStep.key === 'register_name') { + pushReaction( + qw( + 'reaction_name_flow', + 'Name flow open. This is where the hub starts recognizing you.' + ) + ); + } else if (currentStep.key === 'load_avatar') { + pushReaction( + qw( + 'reaction_avatar_flow', + "Avatar flow ready. Let's give this place a face." + ) + ); + } + + currentStep.onAction(); + }, [currentStep, pushReaction, qw]); + + const currentStepPrimaryAction = useMemo(() => { + if (isQortsAcquiredAwaitingNext) { + return { + label: t('tutorial:home.next', 'Next'), + onClick: () => { + setQortsAcquiredAcknowledged(true); + if (onboardingMessageTimeoutRef.current) { + window.clearTimeout(onboardingMessageTimeoutRef.current); + } + setOnboardingTransitionMessage( + t( + 'tutorial:home.onboarding_transition_hard_part_done', + 'Nice work. The hardest part is done.' + ) + ); + onboardingMessageTimeoutRef.current = window.setTimeout(() => { + onboardingMessageTimeoutRef.current = null; + setOnboardingTransitionMessage(null); + }, ONBOARDING_RECOGNITION_DURATION_MS); + }, + }; + } + + if (currentStep.key === 'get_six_qorts') { + return { + label: t('tutorial:home.get_six_qorts_way1_action', 'Go to onboarding'), + onClick: () => { + pushReaction( + qw( + 'reaction_onboarding_route', + "Onboarding route open. I'll keep the next step warm." + ) + ); + openExternalUrl(ONBOARDING_URL); + }, + }; + } + + return { + label: currentStep.ctaLabel, + loading: currentStep.loading === true, + onClick: handleRunCurrentStepAction, + }; + }, [ + currentStep.ctaLabel, + currentStep.key, + currentStep.loading, + handleRunCurrentStepAction, + isQortsAcquiredAwaitingNext, + pushReaction, + qw, + t, + ]); + + const currentStepSecondaryActions = useMemo(() => { + if (currentStep.key !== 'get_six_qorts' || isQortsAcquiredAwaitingNext) { + return []; + } + + return [ + { + label: t( + 'tutorial:home.get_six_qorts_way2_action', + 'Open support chat' + ), + onClick: () => { + pushReaction( + qw( + 'reaction_support_chat', + "Support chat is open. Ask for the 6 QORT and I'll queue step two." + ) + ); + openExternalUrl(SUPPORT_CHAT_URL); + }, + }, + { + label: t('tutorial:home.get_six_qorts_way3_action', 'Open Q-Trade'), + onClick: () => { + pushReaction( + qw( + 'reaction_q_trade_open', + "Q-Trade is up. If you grab QORT there, I'll take you forward." + ) + ); + openApp('Q-Trade'); + }, + }, + ]; + }, [ + currentStep.key, + isQortsAcquiredAwaitingNext, + openApp, + pushReaction, + qw, + t, + ]); + + const currentStepGetQortMethods = useMemo(() => { + if (currentStep.key !== 'get_six_qorts' || isQortsAcquiredAwaitingNext) { + return []; + } + + return [ + { + description: t( + 'tutorial:home.get_six_qorts_way1', + 'Finish the onboarding instruction on qortal.dev' + ), + icon: SchoolRoundedIcon, + key: 'onboarding', + label: currentStepPrimaryAction.label, + onClick: currentStepPrimaryAction.onClick, + recommended: true, + }, + { + description: t( + 'tutorial:home.get_six_qorts_way2', + 'Ask in the Nextcloud support chat for 6 QORT.' + ), + icon: SupportAgentRoundedIcon, + key: 'support', + label: t( + 'tutorial:home.get_six_qorts_way2_action', + 'Open support chat' + ), + onClick: () => { + pushReaction( + qw( + 'reaction_support_chat', + "Support chat is open. Ask for the 6 QORT and I'll queue step two." + ) + ); + openExternalUrl(SUPPORT_CHAT_URL); + }, + recommended: false, + }, + { + description: t( + 'tutorial:home.get_six_qorts_way3', + 'Buy QORT using Q-Trade' + ), + icon: ShoppingBagRoundedIcon, + key: 'q-trade', + label: t('tutorial:home.get_six_qorts_way3_action', 'Open Q-Trade'), + onClick: () => { + pushReaction( + qw( + 'reaction_q_trade_open', + "Q-Trade is up. If you grab QORT there, I'll take you forward." + ) + ); + openApp('Q-Trade'); + }, + recommended: false, + }, + ]; + }, [ + currentStep.key, + currentStepPrimaryAction.label, + currentStepPrimaryAction.onClick, + isQortsAcquiredAwaitingNext, + openApp, + pushReaction, + qw, + t, + ]); + + const workspaceBayBackground = + theme.palette.mode === 'dark' + ? `linear-gradient(180deg, ${alpha('#20242D', 0.9)} 0%, ${alpha( + '#171B23', + 0.96 + )} 100%)` + : `linear-gradient(180deg, ${alpha('#FFFFFF', 0.72)} 0%, ${alpha( + '#F3F6FB', + 0.9 + )} 100%)`; + const workspaceBaySeparatorExtension = Math.max( + qortinoLayoutDebug.separatorOffsetY, + 0 + ); + const workspaceBayHeightPx = + QORTINO_WORKSPACE_BAY_HEIGHT_PX + workspaceBaySeparatorExtension; + const onboardingCompanionLift = 8; + + const workspaceBaySection = ( + + {isOnboardingVisible ? ( + + + + {t('tutorial:home.getting_started', 'Getting started')} + + + {qw('onboarding_step_progress', 'Step {{current}} / {{total}}', { + current: currentProgressStepDisplay, + total: steps.length, + })} + + + {currentStep.key === 'get_six_qorts' && + !isQortsAcquiredAwaitingNext ? ( + + + + + + {currentStep.label} + + + {t( + 'tutorial:home.get_qorts_workspace_hint', + 'Unlock your first 6 QORT to activate the rest of the setup.' + )} + + + + + + {currentStepGetQortMethods.map((method, index) => ( + + ))} + + + ) : ( + + + + + + {currentStep.label} + + + {currentStep.helper} + + + + + + {currentStepSecondaryActions.length > 0 ? ( + + {currentStepSecondaryActions.map((action, index) => ( + + ))} + + ) : null} + + + )} + + ) : workspaceState.mode === 'empty' ? ( + + + + + + {qw('workspace_empty_prompt', 'Choose what lives above QORTINO.')} + + + ) : workspaceState.mode === 'hotkeys' ? ( + + + + {qw('workspace_header_hotkeys', 'Hotkeys')} + + + + handleOpenHotkeyPicker( + firstEmptyHotkeySlot >= 0 ? firstEmptyHotkeySlot : 0 + ) + } + size="small" + sx={{ + borderRadius: '9px', + color: alpha('#9FC4FF', 0.92), + height: '30px', + width: '30px', + '&:hover': { + background: alpha('#8DB8FF', 0.1), + }, + }} + > + + + handleSelectWorkspaceMode('empty')} + size="small" + sx={{ + borderRadius: '9px', + color: alpha(theme.palette.text.secondary, 0.84), + height: '30px', + width: '30px', + '&:hover': { + background: alpha( + theme.palette.common.white, + isDarkMode ? 0.05 : 0.08 + ), + }, + }} + > + + + + + + + {workspaceState.hotkeys.map((appName, index) => { + const app = appName ? resolveHotkeyApp(appName) : null; + + return ( + + app + ? handleRunHotkey(app.appName) + : handleOpenHotkeyPicker(index) + } + sx={{ + alignItems: 'center', + background: 'transparent', + border: '1px solid transparent', + borderRadius: '14px', + display: 'flex', + flexDirection: 'column', + gap: 0.8, + height: '100%', + justifyContent: 'center', + minWidth: 0, + px: 0.7, + py: 0.7, + transition: + 'background 150ms ease, box-shadow 150ms ease, transform 120ms ease', + '&:hover': { + background: `linear-gradient(180deg, ${alpha('#9FC4FF', 0.18)} 0%, ${alpha( + '#8DB8FF', + 0.1 + )} 58%, ${alpha('#8DB8FF', 0.04)} 100%)`, + boxShadow: `inset 0 0 0 1px ${alpha('#9FC4FF', 0.14)}`, + transform: 'translateY(-1px)', + }, + }} + > + {app ? ( + <> + + + {app.label} + + + ) : ( + <> + + + + + {qw('workspace_add_app', 'Add app')} + + + )} + + ); + })} + + + ) : ( + handleSelectWorkspaceMode('empty')} + onCycleTrack={handleCycleTrack} + onOpenSearch={() => setOpenMusicSearchDialog(true)} + onToggleRepeatMode={handleToggleRepeatMode} + onToggleTrack={handleToggleTrack} + qortinoLayoutDebug={qortinoLayoutDebug} + repeatMode={workspaceState.repeatMode} + title={qw('workspace_header_music', 'Music player')} + /> + )} + + ); + + const qortinoStatusLabel = isQortinoTickled + ? 'ticklish' + : qortinoGratefulState + ? 'grateful' + : isOnboardingVisible + ? 'helpful' + : isWorkspaceFreshlyUnlocked + ? 'happy' + : workspaceState.mode === 'music' && workspaceState.musicPlaying + ? 'listening' + : 'idle'; + const qortinoMascotCenteredOffsetY = Math.round( + (QORTINO_MASCOT_SIZE - qortinoMascotStageHeight) / 2 + ); + const qortinoCelebrationConfettiOptions = useMemo( + () => ({ + colors: ['#8DB8FF', '#A7CAFF', '#D7E6FF', '#FFFFFF'], + drift: 0, + gravity: 0.72, + origin: { x: 0.48, y: 0.9 }, + particleCount: 68, + scalar: 0.82, + spread: 84, + startVelocity: 24, + ticks: 180, + }), + [] + ); + const renderQortinoCompanionPreviewSection = useCallback( + ({ + displayedMessage, + isTickled, + isListening, + isTalking, + messageKey, + mood, + onMascotDragOver, + onMascotDrop, + onMascotPointerDown, + onMascotPointerRelease, + showConfetti = false, + statusLabel, + }: { + displayedMessage: string | null; + isTickled: boolean; + isListening: boolean; + isTalking: boolean; + messageKey: string; + mood: 'celebrate' | 'empty' | 'grateful' | 'guide' | 'hotkeys' | 'music'; + onMascotDragOver?: (event: ReactDragEvent) => void; + onMascotDrop?: (event: ReactDragEvent) => void; + onMascotPointerDown?: (event: ReactPointerEvent) => void; + onMascotPointerRelease?: () => void; + showConfetti?: boolean; + statusLabel: string; + }) => { + const shouldShowQortinoConfetti = showConfetti; + + return ( + + + {shouldShowQortinoConfetti ? ( +
}> + + + + + + ); +}; + +describe('HomeQortinoWorkspaceCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders the onboarding workspace without tripping the boundary', async () => { + renderCard(); + + await waitFor(() => { + expect(screen.getByText('Getting started')).toBeInTheDocument(); + }); + + expect(screen.getByText('QORTINO')).toBeInTheDocument(); + expect(screen.queryByText('boundary fallback')).not.toBeInTheDocument(); + }); + + it('renders the unlocked companion view without tripping the boundary', async () => { + localStorage.setItem(`${LS_KEY}_QADDR`, 'completed'); + + renderCard({ + balance: 10, + name: 'b-test', + qortinoSettings: { + hotkeys: Array.from({ length: 8 }, () => null), + mode: 'empty', + musicPlaying: false, + musicQuery: '', + onboardingCelebrationSeen: true, + repeatMode: 'all', + selectedTrackId: 'midnight-relay', + version: 1, + }, + }); + + await waitFor(() => { + expect(screen.getByText('QORTINO')).toBeInTheDocument(); + }); + + expect( + screen.getByText('Choose what lives above QORTINO.') + ).toBeInTheDocument(); + expect(screen.queryByText('boundary fallback')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Group/dashboardPanelEffects.ts b/src/components/Group/dashboardPanelEffects.ts new file mode 100644 index 00000000..2d777c3a --- /dev/null +++ b/src/components/Group/dashboardPanelEffects.ts @@ -0,0 +1,140 @@ +import { useRef } from 'react'; +import { Theme } from '@mui/material/styles'; + +type DashboardPanelVariant = 'base' | 'accent' | 'utility'; + +const resolvePanelVariant = (theme: Theme, variant: DashboardPanelVariant) => { + const isDarkMode = theme.palette.mode === 'dark'; + + if (variant === 'accent') { + return isDarkMode + ? { + backgroundColor: '#232730', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.028) 0%, rgba(255,255,255,0.012) 34%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(255,255,255,0.082)', + boxShadow: '0 12px 24px rgba(0, 0, 0, 0.14)', + topEdge: 'rgba(255,255,255,0.105)', + topGlow: + 'radial-gradient(62% 36px at 50% 0%, rgba(132,175,240,0.19) 0%, rgba(132,175,240,0.09) 38%, rgba(132,175,240,0.03) 62%, transparent 82%)', + topGlowOpacity: 1, + } + : { + backgroundColor: '#F6F1E9', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.26) 34%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(28,36,52,0.08)', + boxShadow: '0 12px 24px rgba(72, 58, 40, 0.08)', + topEdge: 'rgba(255,255,255,0.76)', + topGlow: + 'radial-gradient(62% 34px at 50% 0%, rgba(132,175,240,0.16) 0%, rgba(132,175,240,0.06) 44%, transparent 82%)', + topGlowOpacity: 0.9, + }; + } + + if (variant === 'utility') { + return isDarkMode + ? { + backgroundColor: '#191c23', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.016) 0%, rgba(255,255,255,0.006) 22%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(255,255,255,0.06)', + boxShadow: '0 10px 20px rgba(0, 0, 0, 0.1)', + topEdge: 'rgba(255,255,255,0.075)', + topGlow: 'none', + topGlowOpacity: 0, + } + : { + backgroundColor: '#F2ECE2', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0.2) 22%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(28,36,52,0.07)', + boxShadow: '0 10px 20px rgba(72, 58, 40, 0.06)', + topEdge: 'rgba(255,255,255,0.68)', + topGlow: 'none', + topGlowOpacity: 0, + }; + } + + return isDarkMode + ? { + backgroundColor: '#1D2027', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.018) 0%, rgba(255,255,255,0.008) 22%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(255,255,255,0.064)', + boxShadow: '0 10px 22px rgba(0, 0, 0, 0.12)', + topEdge: 'rgba(255,255,255,0.082)', + topGlow: 'none', + topGlowOpacity: 0, + } + : { + backgroundColor: '#F7F1E8', + backgroundImage: + 'linear-gradient(180deg, rgba(255,255,255,0.62) 0%, rgba(255,255,255,0.22) 22%, rgba(255,255,255,0) 100%)', + borderColor: 'rgba(28,36,52,0.07)', + boxShadow: '0 10px 20px rgba(72, 58, 40, 0.06)', + topEdge: 'rgba(255,255,255,0.7)', + topGlow: 'none', + topGlowOpacity: 0, + }; +}; + +export const dashboardPanelSx = ( + theme: Theme, + variant: DashboardPanelVariant = 'base' +) => { + const surface = resolvePanelVariant(theme, variant); + + return { + position: 'relative', + overflow: 'visible', + isolation: 'isolate', + backgroundColor: surface.backgroundColor, + border: `1px solid ${surface.borderColor}`, + boxShadow: surface.boxShadow, + backgroundImage: surface.backgroundImage, + transition: + 'border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, background-image 180ms ease', + '&::before': { + content: '""', + position: 'absolute', + left: '12px', + right: '12px', + top: 0, + height: '1px', + pointerEvents: 'none', + zIndex: 0, + background: `linear-gradient(90deg, transparent 0%, ${surface.topEdge} 16%, ${surface.topEdge} 84%, transparent 100%)`, + opacity: 0.95, + }, + '&::after': { + content: '""', + position: 'absolute', + left: '18%', + right: '18%', + top: '-10px', + height: '26px', + pointerEvents: 'none', + zIndex: -1, + background: surface.topGlow, + filter: 'blur(12px)', + opacity: surface.topGlowOpacity, + }, + '& > :not(.dashboard-panel-decoration)': { + position: 'relative', + zIndex: 1, + }, + }; +}; + +export const handleDashboardPanelPointerMove = ( + _event: React.MouseEvent +) => undefined; + +export const handleDashboardPanelPointerLeave = ( + _event: React.MouseEvent +) => undefined; + +export const useDashboardPanelMouseLight = () => { + return useRef(null); +}; diff --git a/src/components/Group/earbumpLibraryApi.ts b/src/components/Group/earbumpLibraryApi.ts new file mode 100644 index 00000000..044b3cd4 --- /dev/null +++ b/src/components/Group/earbumpLibraryApi.ts @@ -0,0 +1,433 @@ +import { getBaseApiReact } from '../../utils/globalApi'; + +export type EarbumpTrack = { + artist: string; + coverColors: [string, string, string]; + created: number; + id: string; + length: string; + name: string; + status: string | null; + streamUrl: string; + title: string; + updated: number | null; + uploaded: string; +}; + +const EARBUMP_SEARCH_ENDPOINT = '/arbitrary/resources/search'; +const EARBUMP_SONG_IDENTIFIER_PREFIX = 'earbump_song_'; +/** Parallel music catalog (searched and merged with EarBump results). */ +const ENJOYMUSIC_SONG_IDENTIFIER_PREFIX = 'enjoymusic_song'; + +type SearchResponseItem = { + created?: number; + identifier?: string; + metadata?: { + author?: string; + description?: string; + title?: string; + }; + name?: string; + service?: string; + status?: { + status?: string; + }; + updated?: number | null; +}; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const toSafeString = (value: unknown) => + typeof value === 'string' ? value : ''; + +const toSafeNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +/** Matches percent-encoded bytes (e.g. %20) in metadata or description fields. */ +const PERCENT_ENCODED_BYTE_PATTERN = /%[0-9A-Fa-f]{2}/; + +const decodeDisplayStringIfEncoded = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed || !PERCENT_ENCODED_BYTE_PATTERN.test(trimmed)) { + return trimmed; + } + + try { + const decoded = decodeURIComponent(trimmed.replace(/\+/g, ' ')); + const normalized = decoded.trim(); + return normalized.length > 0 ? normalized : trimmed; + } catch { + return trimmed; + } +}; + +const slugifySearchQuery = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s_-]+/g, ' ') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + +const formatRelativeTimestamp = (timestamp: number) => { + const diffMs = Math.max(0, Date.now() - timestamp); + const diffMinutes = Math.floor(diffMs / 60_000); + + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) { + return `${diffMinutes} min${diffMinutes === 1 ? '' : 's'} ago`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } + + const diffWeeks = Math.floor(diffDays / 7); + if (diffWeeks < 5) { + return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`; + } + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) { + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`; + } + + const diffYears = Math.floor(diffDays / 365); + return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`; +}; + +const hashSeed = (value: string) => { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash << 5) - hash + value.charCodeAt(index); + hash |= 0; + } + + return Math.abs(hash); +}; + +const hslToHex = (hue: number, saturation: number, lightness: number) => { + const s = saturation / 100; + const l = lightness / 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = l - c / 2; + let red = 0; + let green = 0; + let blue = 0; + + if (hue < 60) { + red = c; + green = x; + } else if (hue < 120) { + red = x; + green = c; + } else if (hue < 180) { + green = c; + blue = x; + } else if (hue < 240) { + green = x; + blue = c; + } else if (hue < 300) { + red = x; + blue = c; + } else { + red = c; + blue = x; + } + + const toHex = (channel: number) => + Math.round((channel + m) * 255) + .toString(16) + .padStart(2, '0'); + + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; +}; + +const buildCoverColors = (seedValue: string): [string, string, string] => { + const seed = hashSeed(seedValue); + const hue = seed % 360; + + return [ + hslToHex(hue, 78, 62), + hslToHex((hue + 42) % 360, 58, 28), + hslToHex((hue + 14) % 360, 84, 78), + ]; +}; + +const parseDescriptionFields = (description: string) => { + const fields = description.split(';'); + const parsed: Record = {}; + + for (const field of fields) { + const [rawKey, rawValue] = field.split('='); + if (!rawKey || !rawValue) continue; + + const key = rawKey.trim().toLowerCase(); + if (key !== 'title' && key !== 'author') continue; + parsed[key] = decodeDisplayStringIfEncoded(rawValue.trim()); + } + + return parsed; +}; + +const mapSearchResponseItem = (value: unknown): EarbumpTrack | null => { + if (!isRecord(value)) { + return null; + } + + const resource = value as SearchResponseItem; + const identifier = toSafeString(resource.identifier).trim(); + const name = toSafeString(resource.name).trim(); + const service = toSafeString(resource.service).trim(); + const created = toSafeNumber(resource.created); + + if (!identifier || !name || service !== 'AUDIO' || created == null) { + return null; + } + + const metadataTitle = decodeDisplayStringIfEncoded( + toSafeString(resource.metadata?.title).trim() + ); + const metadataAuthor = decodeDisplayStringIfEncoded( + toSafeString(resource.metadata?.author).trim() + ); + const description = toSafeString(resource.metadata?.description).trim(); + const parsedDescription = parseDescriptionFields(description); + const title = decodeDisplayStringIfEncoded( + parsedDescription.title || metadataTitle || identifier + ); + const artist = decodeDisplayStringIfEncoded( + parsedDescription.author || metadataAuthor || name + ); + + return { + artist, + coverColors: buildCoverColors(`${name}:${identifier}:${title}:${artist}`), + created, + id: identifier, + length: '--:--', + name, + status: toSafeString(resource.status?.status).trim() || null, + streamUrl: `${getBaseApiReact()}/arbitrary/AUDIO/${encodeURIComponent(name)}/${encodeURIComponent(identifier)}`, + title, + updated: toSafeNumber(resource.updated), + uploaded: formatRelativeTimestamp(created), + }; +}; + +const dedupeTracks = (tracks: EarbumpTrack[]) => { + const seenIds = new Set(); + + return tracks.filter((track) => { + if (seenIds.has(track.id)) { + return false; + } + + seenIds.add(track.id); + return true; + }); +}; + +const shuffleTracksDeterministic = ( + tracks: EarbumpTrack[], + seed: number +): EarbumpTrack[] => { + const copy = tracks.slice(); + let state = seed >>> 0; + const random = () => { + state = (1664525 * state + 1013904223) >>> 0; + return state / 0xffffffff; + }; + + for (let i = copy.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + const tmp = copy[i]; + copy[i] = copy[j]!; + copy[j] = tmp!; + } + + return copy; +}; + +const mergeAndShuffleTrackResults = ( + earbumpTracks: EarbumpTrack[], + enjoyTracks: EarbumpTrack[], + shuffleSeed: number +): EarbumpTrack[] => { + const merged = dedupeTracks([...earbumpTracks, ...enjoyTracks]); + return shuffleTracksDeterministic(merged, shuffleSeed); +}; + +const fetchDualIdentifierSearch = async ( + query: string, + perSourceLimit: number, + options?: { offset?: number; signal?: AbortSignal } +) => { + const paramsEarbump = buildSearchParams(query, { + limit: perSourceLimit, + offset: options?.offset, + identifierPrefix: EARBUMP_SONG_IDENTIFIER_PREFIX, + }); + const paramsEnjoy = buildSearchParams(query, { + limit: perSourceLimit, + offset: options?.offset, + identifierPrefix: ENJOYMUSIC_SONG_IDENTIFIER_PREFIX, + }); + + const [earbumpResult, enjoyResult] = await Promise.allSettled([ + fetchTrackSearch(paramsEarbump, options?.signal), + fetchTrackSearch(paramsEnjoy, options?.signal), + ]); + + const earbumpTracks = + earbumpResult.status === 'fulfilled' ? earbumpResult.value : []; + const enjoyTracks = + enjoyResult.status === 'fulfilled' ? enjoyResult.value : []; + + if (earbumpResult.status === 'rejected' && enjoyResult.status === 'rejected') { + throw earbumpResult.reason; + } + + return mergeAndShuffleTrackResults( + earbumpTracks, + enjoyTracks, + hashSeed(query) + ); +}; + +const fetchTrackSearch = async ( + params: URLSearchParams, + signal?: AbortSignal +) => { + const response = await fetch( + `${getBaseApiReact()}${EARBUMP_SEARCH_ENDPOINT}?${params.toString()}`, + { + cache: 'no-store', + signal, + } + ); + + if (!response.ok) { + throw new Error(`EarBump search failed with status ${response.status}`); + } + + const parsed = (await response.json()) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('Unexpected EarBump search response shape'); + } + + return dedupeTracks( + parsed + .map(mapSearchResponseItem) + .filter((track): track is EarbumpTrack => track != null) + ); +}; + +const buildSearchParams = ( + query: string, + options?: { + limit?: number; + offset?: number; + useIdentifierQuery?: boolean; + identifierPrefix?: string; + } +) => { + const identifierPrefix = + options?.identifierPrefix ?? EARBUMP_SONG_IDENTIFIER_PREFIX; + + const params = new URLSearchParams({ + excludeblocked: 'true', + includemetadata: 'true', + includestatus: 'true', + limit: String(options?.limit ?? 12), + mode: 'ALL', + offset: String(options?.offset ?? 0), + reverse: 'true', + service: 'AUDIO', + }); + + if (options?.useIdentifierQuery === true) { + params.set('query', identifierPrefix); + } else { + params.set('identifier', identifierPrefix); + params.set('query', query); + } + + return params; +}; + +export const fetchEarbumpRecentTracks = async (options?: { + limit?: number; + offset?: number; + signal?: AbortSignal; +}) => + fetchTrackSearch( + buildSearchParams('', { + limit: options?.limit, + offset: options?.offset, + useIdentifierQuery: true, + }), + options?.signal + ); + +export const searchEarbumpTracks = async ( + query: string, + options?: { limit?: number; offset?: number; signal?: AbortSignal } +) => { + const perSourceLimit = options?.limit ?? 8; + + const normalizedQuery = slugifySearchQuery(query); + if (!normalizedQuery) { + return fetchEarbumpRecentTracks(options); + } + + const primaryResults = await fetchDualIdentifierSearch( + normalizedQuery, + perSourceLimit, + options + ); + + if (primaryResults.length > 0) { + return primaryResults; + } + + const rawQuery = query.trim().toLowerCase(); + if (!rawQuery || rawQuery === normalizedQuery) { + return primaryResults; + } + + return fetchDualIdentifierSearch(rawQuery, perSourceLimit, options); +}; + +export const fetchEarbumpTrackById = async ( + trackId: string, + options?: { signal?: AbortSignal } +) => { + const trimmedTrackId = trackId.trim(); + if (!trimmedTrackId) return null; + + const params = new URLSearchParams({ + excludeblocked: 'true', + includemetadata: 'true', + includestatus: 'true', + identifier: trimmedTrackId, + limit: '1', + mode: 'ALL', + offset: '0', + reverse: 'true', + service: 'AUDIO', + }); + + const tracks = await fetchTrackSearch(params, options?.signal); + return tracks[0] ?? null; +}; diff --git a/src/components/Group/earbumpSharedAudio.ts b/src/components/Group/earbumpSharedAudio.ts new file mode 100644 index 00000000..29e820bb --- /dev/null +++ b/src/components/Group/earbumpSharedAudio.ts @@ -0,0 +1,41 @@ +import type { EarbumpTrack } from './earbumpLibraryApi'; + +let sharedEarbumpAudioInstance: HTMLAudioElement | null = null; +let sharedEarbumpTrackSnapshot: EarbumpTrack | null = null; + +export const getSharedEarbumpAudio = (): HTMLAudioElement | null => { + if (typeof window === 'undefined') { + return null; + } + + if (sharedEarbumpAudioInstance == null) { + sharedEarbumpAudioInstance = new Audio(); + sharedEarbumpAudioInstance.preload = 'metadata'; + } + + return sharedEarbumpAudioInstance; +}; + +export const getSharedEarbumpTrackSnapshot = () => sharedEarbumpTrackSnapshot; + +export const setSharedEarbumpTrackSnapshot = ( + track: EarbumpTrack | null +) => { + sharedEarbumpTrackSnapshot = track ? { ...track } : null; +}; + +export const stopSharedEarbumpAudio = (audio: HTMLAudioElement | null) => { + if (!audio) { + return; + } + + audio.pause(); + audio.removeAttribute('src'); + audio.load(); +}; + +/** Pause playback and clear track snapshot (logout / workspace teardown). */ +export const stopSharedEarbumpPlayback = (): void => { + stopSharedEarbumpAudio(getSharedEarbumpAudio()); + setSharedEarbumpTrackSnapshot(null); +}; diff --git a/src/components/Group/gettingStartedStorage.ts b/src/components/Group/gettingStartedStorage.ts new file mode 100644 index 00000000..f9ad7869 --- /dev/null +++ b/src/components/Group/gettingStartedStorage.ts @@ -0,0 +1 @@ +export const GETTING_STARTED_LS_KEY = 'getting_started_status'; diff --git a/src/components/Group/groupActivityColorSystem.ts b/src/components/Group/groupActivityColorSystem.ts new file mode 100644 index 00000000..85d5a3ca --- /dev/null +++ b/src/components/Group/groupActivityColorSystem.ts @@ -0,0 +1,14 @@ +export { + APP_BLUE as GROUP_ACTIVITY_BLUE, + APP_BLUE_SURFACE_TEXT, + getBlueAmbientFieldBackground, + getBlueAmbientLineBackground, + getBlueAmbientPillGlowBackground, + getBlueAmbientSeamBackground, + getBlueTier1ButtonSx, + getBlueTier1PillSurface, + getBlueTier2BadgeSx, + getBlueTier3DotSx, + getBlueTier3ProgressBackground, + getBlueTier3StepperState, +} from '../../styles/blueMaterial'; diff --git a/src/components/Group/groupApi.ts b/src/components/Group/groupApi.ts index 7f96b4d2..bb7a0c34 100644 --- a/src/components/Group/groupApi.ts +++ b/src/components/Group/groupApi.ts @@ -33,6 +33,46 @@ export async function getPrimaryNameForAvatar(address: string): Promise return ''; } +export async function getPrimaryNamesForAddresses( + addresses: string[] +): Promise> { + const uniqueAddresses = Array.from( + new Set(addresses.filter((address) => Boolean(address))) + ); + if (uniqueAddresses.length === 0) return {}; + + const response = await fetch(`${getBaseApiReactForPrimaryName()}/names/list`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(uniqueAddresses), + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + const names = await response.json(); + const primaryNamesByAddress: Record = {}; + + if (Array.isArray(names)) { + names.forEach((item) => { + if (typeof item?.owner !== 'string') return; + primaryNamesByAddress[item.owner] = + typeof item?.name === 'string' ? item.name : ''; + }); + } + + uniqueAddresses.forEach((address) => { + if (primaryNamesByAddress[address] === undefined) { + primaryNamesByAddress[address] = ''; + } + }); + + return primaryNamesByAddress; +} + export const getPublishesFromAdmins = async ( admins: string[], groupId: string diff --git a/src/components/Group/groupTypes.ts b/src/components/Group/groupTypes.ts index be4d64ca..3abbb802 100644 --- a/src/components/Group/groupTypes.ts +++ b/src/components/Group/groupTypes.ts @@ -2,8 +2,7 @@ export interface GroupProps { myAddress: string; desktopViewMode: string; isMain?: boolean; - isOpenDrawerProfile?: boolean; logoutFunc?: () => Promise; + onOpenSettings?: () => void; setDesktopViewMode: (mode: string) => void; - setIsOpenDrawerProfile: (open: boolean) => void; } diff --git a/src/components/Group/qmailUtils.ts b/src/components/Group/qmailUtils.ts index cc0f669f..d8479fc2 100644 --- a/src/components/Group/qmailUtils.ts +++ b/src/components/Group/qmailUtils.ts @@ -1,10 +1,6 @@ import moment from 'moment'; import { TIME_WEEKS_1_IN_MILLISECONDS } from '../../constants/constants'; -export function isLessThanOneWeekOld(timestamp: number): boolean { - return timestamp > Date.now() - TIME_WEEKS_1_IN_MILLISECONDS; -} - export function formatEmailDate(timestamp: number): string { const date = moment(timestamp); const now = moment(); diff --git a/src/components/Group/qortinoCompanionDebug.ts b/src/components/Group/qortinoCompanionDebug.ts new file mode 100644 index 00000000..363bd3ce --- /dev/null +++ b/src/components/Group/qortinoCompanionDebug.ts @@ -0,0 +1,17 @@ +export type QortinoCompanionDebugSettings = { + bubbleOffsetX: number; + bubbleOffsetY: number; + nameOffsetX: number; + nameOffsetY: number; + statusOffsetX: number; + statusOffsetY: number; +}; + +export const DEFAULT_QORTINO_COMPANION_DEBUG_SETTINGS: QortinoCompanionDebugSettings = { + bubbleOffsetX: -7, + bubbleOffsetY: -6, + nameOffsetX: -6, + nameOffsetY: 36, + statusOffsetX: -56, + statusOffsetY: 12, +}; diff --git a/src/components/Group/qortinoDonationEasterEgg.ts b/src/components/Group/qortinoDonationEasterEgg.ts new file mode 100644 index 00000000..8fd87ee5 --- /dev/null +++ b/src/components/Group/qortinoDonationEasterEgg.ts @@ -0,0 +1,17 @@ +export const QORTINO_DONATION_DRAG_TYPE = + 'application/x-qortino-wallet-donation'; + +export const QORTINO_DONATION_PREFILL_NAME = 'Qortino'; + +export const QORTINO_DONATION_COMPLETED_EVENT = + 'qortinoDonationCompleted'; + +export const QORTINO_DONATION_BUBBLE_MESSAGE = + 'Such an astute observer, you discovered my super-secret Donation page!'; + +export const QORTINO_DONATION_THANK_YOU_MESSAGE = + 'Thank you for your kind donation!'; + +export const QORTINO_DONATION_OVERLAY_DURATION_MS = 4800; + +export const QORTINO_DONATION_GRATEFUL_DURATION_MS = 10000; diff --git a/src/components/Group/qortinoLayoutDebug.ts b/src/components/Group/qortinoLayoutDebug.ts new file mode 100644 index 00000000..25629d94 --- /dev/null +++ b/src/components/Group/qortinoLayoutDebug.ts @@ -0,0 +1,20 @@ +export type QortinoLayoutDebugSettings = { + musicHeaderOffsetY: number; + nodeStatusOffsetY: number; + prevNextOffsetY: number; + progressOffsetY: number; + separatorOffsetY: number; + titleAuthorOffsetY: number; + vinylOffsetY: number; +}; + +export const DEFAULT_QORTINO_LAYOUT_DEBUG_SETTINGS: QortinoLayoutDebugSettings = + { + musicHeaderOffsetY: 15, + nodeStatusOffsetY: -5, + prevNextOffsetY: 15, + progressOffsetY: 28, + separatorOffsetY: 24, + titleAuthorOffsetY: 19, + vinylOffsetY: 13, + }; diff --git a/src/components/Group/qortinoLookDebug.ts b/src/components/Group/qortinoLookDebug.ts new file mode 100644 index 00000000..4da201e0 --- /dev/null +++ b/src/components/Group/qortinoLookDebug.ts @@ -0,0 +1,17 @@ +export type QortinoLookDebugSettings = { + antennaLength: number; + antennaScale: number; + bodyScale: number; + bodyWidthScale: number; + faceScale: number; + logoScale: number; +}; + +export const DEFAULT_QORTINO_LOOK_DEBUG_SETTINGS: QortinoLookDebugSettings = { + antennaLength: 0.85, + antennaScale: 1.7, + bodyScale: 0.95, + bodyWidthScale: 1, + faceScale: 1.45, + logoScale: 1.7, +}; diff --git a/src/components/Group/useWebsocketStatus.tsx b/src/components/Group/useWebsocketStatus.tsx index 63cba291..cccfd80f 100644 --- a/src/components/Group/useWebsocketStatus.tsx +++ b/src/components/Group/useWebsocketStatus.tsx @@ -1,24 +1,45 @@ import { useEffect, useRef } from 'react'; -import { getBaseApiReactSocket } from '../../App'; +import { + cleanUrl, + getProtocol, + groupApiSocket, +} from '../../background/background'; import { executeEvent, subscribeToEvent, unsubscribeFromEvent, } from '../../utils/events'; -import { useSetAtom } from 'jotai'; -import { nodeInfosAtom } from '../../atoms/global'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { nodeInfosAtom, selectedNodeInfoAtom } from '../../atoms/global'; export const useWebsocketStatus = () => { const lastPopup = useRef(null); const setNodeInfos = useSetAtom(nodeInfosAtom); + const selectedNode = useAtomValue(selectedNodeInfoAtom); const socketRef = useRef(null); + const connectionIdRef = useRef(0); const timeoutIdRef = useRef(null); // No-pong timeout (close if no pong in 5s) const groupSocketTimeoutRef = useRef(null); // Next ping in 45s + const reconnectTimeoutRef = useRef(null); + const verifyCoreTimeoutRef = useRef(null); + + const getStatusSocketBase = (nodeUrl?: string | null) => { + if (!nodeUrl) return groupApiSocket; + const protocol = getProtocol(nodeUrl) === 'http' ? 'ws://' : 'wss://'; + return `${protocol}${cleanUrl(nodeUrl)}`; + }; + const forceCloseWebSocket = () => { + clearTimeout(timeoutIdRef.current); + clearTimeout(groupSocketTimeoutRef.current); + clearTimeout(reconnectTimeoutRef.current); + clearTimeout(verifyCoreTimeoutRef.current); + timeoutIdRef.current = null; + groupSocketTimeoutRef.current = null; + reconnectTimeoutRef.current = null; + verifyCoreTimeoutRef.current = null; if (socketRef.current) { - clearTimeout(timeoutIdRef.current); - clearTimeout(groupSocketTimeoutRef.current); socketRef.current.close(1000, 'forced'); socketRef.current = null; } @@ -41,13 +62,26 @@ export const useWebsocketStatus = () => { }; useEffect(() => { - const pingHeads = () => { + const connectionId = connectionIdRef.current + 1; + connectionIdRef.current = connectionId; + const selectedNodeUrl = selectedNode?.url; + const socketBase = getStatusSocketBase(selectedNodeUrl); + const isCurrentConnection = (socket?: WebSocket | null) => { + if (connectionIdRef.current !== connectionId) return false; + if (socket && socketRef.current !== socket) return false; + return true; + }; + + const pingHeads = (socket: WebSocket) => { try { - if (socketRef.current?.readyState === WebSocket.OPEN) { - socketRef.current.send('ping'); + if ( + isCurrentConnection(socket) && + socket.readyState === WebSocket.OPEN + ) { + socket.send('ping'); timeoutIdRef.current = setTimeout(() => { - if (socketRef.current) { - socketRef.current.close(); + if (isCurrentConnection(socket)) { + socket.close(); clearTimeout(groupSocketTimeoutRef.current); } }, 5000); // Close if no pong in 5 seconds @@ -58,55 +92,72 @@ export const useWebsocketStatus = () => { }; const initWebsocketMessageGroup = async () => { + if (!isCurrentConnection()) return; forceCloseWebSocket(); // Ensure we close any existing connection + if (!isCurrentConnection()) return; try { - const socketLink = `${getBaseApiReactSocket()}/websockets/admin/status`; - socketRef.current = new WebSocket(socketLink); + const socketLink = `${socketBase}/websockets/admin/status`; + const socket = new WebSocket(socketLink); + socketRef.current = socket; - socketRef.current.onopen = () => { - setTimeout(pingHeads, 50); // Initial ping + socket.onopen = () => { + setTimeout(() => pingHeads(socket), 50); // Initial ping }; - socketRef.current.onmessage = (e) => { + socket.onmessage = (e) => { + if (!isCurrentConnection(socket)) return; try { if (e.data === 'pong') { clearTimeout(timeoutIdRef.current); - groupSocketTimeoutRef.current = setTimeout(pingHeads, 20000); // Ping every 20 seconds + groupSocketTimeoutRef.current = setTimeout( + () => pingHeads(socket), + 20000 + ); // Ping every 20 seconds return; } const data = JSON.parse(e.data); if (data?.height) { - setNodeInfos(data); + setNodeInfos({ + ...data, + sourceNodeUrl: selectedNodeUrl, + receivedAt: Date.now(), + }); } } catch (error) { console.error('Error parsing onmessage data:', error); } }; - socketRef.current.onclose = (event) => { + socket.onclose = (event) => { + if (!isCurrentConnection(socket)) return; clearTimeout(timeoutIdRef.current); clearTimeout(groupSocketTimeoutRef.current); setNodeInfos({}); console.warn(`WebSocket closed: ${event.reason || 'unknown reason'}`); if (event.reason !== 'forced' && event.code !== 1000) { if (!lastPopup.current || Date.now() - lastPopup.current > 600000) { - setTimeout(() => { - sendMessageVerifyCoreNotRunning(); + verifyCoreTimeoutRef.current = setTimeout(() => { + if (isCurrentConnection(socket)) { + sendMessageVerifyCoreNotRunning(); + } }, 18_000); lastPopup.current = Date.now(); } - setTimeout(() => initWebsocketMessageGroup(), 10000); // Retry after 10 seconds + reconnectTimeoutRef.current = setTimeout(() => { + if (isCurrentConnection()) { + initWebsocketMessageGroup(); + } + }, 10000); // Retry after 10 seconds } }; - socketRef.current.onerror = (error) => { + socket.onerror = (error) => { + if (!isCurrentConnection(socket)) return; console.error('WebSocket error:', error); clearTimeout(timeoutIdRef.current); clearTimeout(groupSocketTimeoutRef.current); - if (socketRef.current) { - socketRef.current.close(); - } + socket.close(); }; } catch (error) { console.error('Error initializing WebSocket:', error); @@ -116,9 +167,10 @@ export const useWebsocketStatus = () => { initWebsocketMessageGroup(); // Initialize WebSocket on component mount return () => { + connectionIdRef.current += 1; forceCloseWebSocket(); // Clean up WebSocket on component unmount }; - }, []); + }, [selectedNode?.apikey, selectedNode?.url]); return null; }; diff --git a/src/components/Language/LanguageSelector.tsx b/src/components/Language/LanguageSelector.tsx index e15727ba..7ff09392 100644 --- a/src/components/Language/LanguageSelector.tsx +++ b/src/components/Language/LanguageSelector.tsx @@ -1,75 +1,199 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '../../i18n/i18n'; import { - Box, - Button, - FormControl, + ButtonBase, + Menu, MenuItem, - Select, - Tooltip, + Typography, useTheme, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import LanguageIcon from '@mui/icons-material/Language'; -const LanguageSelector = () => { +type LanguageSelectorProps = { + sidebar?: boolean; +}; + +function languageBase(code: string) { + return code.split('-')[0]; +} + +const LanguageSelector = ({ sidebar = false }: LanguageSelectorProps) => { const { i18n, t } = useTranslation(['core']); - const [showSelect, setShowSelect] = useState(false); const theme = useTheme(); - const selectorRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const sidebarButtonSx = { + alignItems: 'center', + borderRadius: '14px', + color: theme.palette.text.secondary, + display: 'flex', + flexDirection: 'column', + gap: 1, + justifyContent: 'flex-start', + minHeight: 58, + py: 1, + transition: 'background-color 180ms ease, color 180ms ease, box-shadow 140ms ease', + width: 56, + '& .sidebarSelectorIconWrap': { + transition: 'transform 150ms ease, color 180ms ease', + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.18)}, inset 0 1px 0 ${alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.03 : 0.12 + )}`, + '& .sidebarSelectorIconWrap': { + transform: 'translateY(-1px)', + }, + }, + '&:focus-visible': { + backgroundColor: alpha( + theme.palette.action.hover, + theme.palette.mode === 'dark' ? 0.72 : 0.82 + ), + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.22)}, inset 0 1px 0 ${alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.03 : 0.12 + )}`, + color: theme.palette.text.primary, + '& .sidebarSelectorIconWrap': { + transform: 'translateY(-1px)', + }, + }, + } as const; - const handleChange = (e) => { - const newLang = e.target.value; - i18n.changeLanguage(newLang); - setShowSelect(false); + const handleChange = (newLang: string) => { + void i18n.changeLanguage(newLang); + try { + localStorage.setItem('i18nextLng', newLang); + } catch { + /* ignore quota / privacy mode */ + } + setAnchorEl(null); }; - const currentLang = i18n.language; - const { name, flag } = - supportedLanguages[currentLang] || supportedLanguages['en']; + const currentBase = languageBase(i18n.language); + const { name } = + supportedLanguages[currentBase as keyof typeof supportedLanguages] || + supportedLanguages.en; + const currentLangCode = currentBase.startsWith('en') + ? 'EN' + : currentBase.slice(0, 2).toUpperCase(); return ( - - {!showSelect && ( - - )} - - {showSelect && ( - - - - )} - + {currentLangCode} + + )} + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'center', horizontal: 'right' }} + transformOrigin={{ vertical: 'center', horizontal: 'left' }} + /* Footer chrome (e.g. NotAuthenticatedFooter) uses z-index 2000; default modal menu is ~1300, so without this the anchor button paints over the panel. */ + sx={{ zIndex: (muiTheme) => muiTheme.zIndex.modal + 1100 }} + slotProps={{ + paper: { + sx: { + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.border.subtle}`, + boxShadow: + theme.palette.mode === 'dark' + ? '0 12px 28px rgba(0,0,0,0.35)' + : '0 10px 24px rgba(0,0,0,0.14)', + minWidth: 170, + ml: 1, + }, + }, + }} + > + {(Object.entries(supportedLanguages) as [string, { name: string; flag: string }][]).map( + ([code, langData]) => ( + handleChange(code)} + > + {langData.flag} {code.toUpperCase()} - {langData.name} + + ) + )} + + ); }; diff --git a/src/components/Minting/Minting.tsx b/src/components/Minting/Minting.tsx index d5d2f82b..dfa3c486 100644 --- a/src/components/Minting/Minting.tsx +++ b/src/components/Minting/Minting.tsx @@ -1,7 +1,5 @@ import { - Alert, alpha, - AppBar, Box, Button, Dialog, @@ -9,22 +7,12 @@ import { DialogContent, DialogTitle, IconButton, - Snackbar, - Tab, - Tabs, - Toolbar, Typography, useTheme, } from '@mui/material'; -import { - SyntheticEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import CloseIcon from '@mui/icons-material/Close'; +import mintingWatermark from '../../assets/minting/blue-grey-menu-button2-1.png'; import { getBaseApiReact } from '../../App'; import { executeEvent, @@ -37,7 +25,6 @@ import { useModal } from '../../hooks/useModal.tsx'; import { useAtom, useSetAtom } from 'jotai'; import { memberGroupsAtom, txListAtom } from '../../atoms/global'; import { Trans, useTranslation } from 'react-i18next'; -import { TransitionUp } from '../../common/Transitions.tsx'; import { nextLevel, averageBlockDay, @@ -45,13 +32,13 @@ import { dayReward, levelUpBlocks, levelUpDays, - mintingStatus, countMintersInLevel, currentTier, tierPercent, countReward, countRewardDay, } from './MintingStats.tsx'; +import { CustomizedSnackbars } from '../Snackbar/Snackbar'; export type AddressLevelEntry = { level: number; @@ -61,10 +48,17 @@ export type AddressLevelEntry = { export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const setTxList = useSetAtom(txListAtom); const [groups] = useAtom(memberGroupsAtom); + const theme = useTheme(); + const { t } = useTranslation([ + 'auth', + 'core', + 'group', + 'question', + 'tutorial', + ]); const [mintingAccounts, setMintingAccounts] = useState([]); const [accountInfo, setAccountInfo] = useState(null); - const [mintingKey, setMintingKey] = useState(''); const [rewardShares, setRewardShares] = useState([]); const [adminInfo, setAdminInfo] = useState({}); const [nodeStatus, setNodeStatus] = useState({}); @@ -73,19 +67,14 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const [openSnack, setOpenSnack] = useState(false); const [isLoading, setIsLoading] = useState(false); const [nodeHeightBlock, setNodeHeightBlock] = useState({}); - const [valueMintingTab, setValueMintingTab] = useState(0); const { isShow: isShowNext, onOk, show: showNext } = useModal(); - const theme = useTheme(); - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); const [info, setInfo] = useState(null); const [names, setNames] = useState({}); - const [accountInfos, setAccountInfos] = useState({}); + const [statsAccountInfo, setStatsAccountInfo] = useState(null); + const [isStatsLoading, setIsStatsLoading] = useState(false); + const [selectedMintingAccountKey, setSelectedMintingAccountKey] = useState( + null + ); const [showWaitDialog, setShowWaitDialog] = useState(false); const timeoutNodeStatusRef = useRef | null>( null @@ -93,6 +82,7 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const timeoutAdminInfoRef = useRef | null>( null ); + const isPartOfMintingGroup = useMemo(() => { if (groups?.length === 0) return false; return !!groups?.find((item) => item?.groupId?.toString() === '694'); @@ -124,68 +114,55 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const response = await fetch(url); const nameData = await response.json(); if (nameData?.name) { - setNames((prev) => { - return { - ...prev, - [address]: nameData?.name, - }; - }); + setNames((prev) => ({ + ...prev, + [address]: nameData?.name, + })); } else { - setNames((prev) => { - return { - ...prev, - [address]: null, - }; - }); + setNames((prev) => ({ + ...prev, + [address]: null, + })); } } catch (error) { console.log(error); } }; - function a11yProps(index: number) { - return { - id: `simple-tab-${index}`, - 'aria-controls': `simple-tabpanel-${index}`, - }; - } - - const getAccountInfo = async (address: string, others?: boolean) => { + const getAccountInfo = async (address: string) => { try { - if (!others) { - setIsLoading(true); - } + setIsLoading(true); const url = `${getBaseApiReact()}/addresses/${address}`; const response = await fetch(url); if (!response.ok) { throw new Error('network error'); } const data = await response.json(); - if (others) { - setAccountInfos((prev) => { - return { - ...prev, - [address]: data, - }; - }); - } else { - setAccountInfo(data); - } + setAccountInfo(data); } catch (error) { console.log(error); } finally { - if (!others) { - setIsLoading(false); - } + setIsLoading(false); } }; - const daysToNextLevel = levelUpDays( - accountInfo, - adminInfo, - nodeHeightBlock, - nodeStatus - ); + const getStatsAccountInfo = useCallback(async (address: string) => { + if (!address) return; + try { + setIsStatsLoading(true); + const url = `${getBaseApiReact()}/addresses/${address}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error('network error'); + } + const data = await response.json(); + setStatsAccountInfo(data); + } catch (error) { + console.log(error); + } finally { + setIsStatsLoading(false); + } + }, []); const refreshRewardShare = () => { if (!myAddress) return; @@ -217,7 +194,7 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { } catch (error) { console.log(error); } finally { - timeoutAdminInfoRef.current = setTimeout(getAccountInfo, 30000); + timeoutAdminInfoRef.current = setTimeout(getAdminInfo, 30000); } }, []); @@ -261,9 +238,7 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { setAddressLevel(data); const level7 = data.find((entry) => entry.level === 7)?.count || 0; const level8 = data.find((entry) => entry.level === 8)?.count || 0; - const tier4Count = - parseFloat(level7.toString()) + parseFloat(level8.toString()); - setTier4Online(tier4Count); + setTier4Online(level7 + level8); } } catch (error) { console.error('Request failed', error); @@ -272,7 +247,7 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const getRewardShares = useCallback(async (address) => { try { - const url = `${getBaseApiReact()}/addresses/rewardshares?involving=${address}`; // TODO check API (still useful?) + const url = `${getBaseApiReact()}/addresses/rewardshares?involving=${address}`; const response = await fetch(url); if (!response.ok) { throw new Error('network error'); @@ -285,27 +260,145 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { } }, []); - const addMintingAccount = useCallback(async (val) => { - try { - setIsLoading(true); + const addMintingAccount = useCallback( + async (val) => { + try { + setIsLoading(true); + return await new Promise((res, rej) => { + window + .sendMessage( + 'ADMIN_ACTION', + { + type: 'addmintingaccount', + value: val, + }, + 180000, + true + ) + .then((response) => { + if (!response?.error) { + res(response); + setTimeout(() => { + getMintingAccounts(); + }, 300); + return; + } + rej({ message: response.error }); + }) + .catch((error) => { + rej({ + message: + error.message || + t('core:message.error.generic', { + postProcess: 'capitalizeFirstChar', + }), + }); + }); + }); + } catch (error) { + setInfo({ + type: 'error', + message: + error?.message || + t('core:message.error.minting_account_add', { + postProcess: 'capitalizeFirstChar', + }), + }); + setOpenSnack(true); + } finally { + setIsLoading(false); + } + }, + [getMintingAccounts, t] + ); + + const removeMintingAccount = useCallback( + async (val, acct) => { + try { + setIsLoading(true); + return await new Promise((res, rej) => { + window + .sendMessage( + 'ADMIN_ACTION', + { + type: 'removemintingaccount', + value: val, + }, + 180000, + true + ) + .then((response) => { + if (!response?.error) { + res(response); + setTimeout(() => { + getMintingAccounts(); + }, 300); + return; + } + rej({ message: response.error }); + }) + .catch((error) => { + rej({ + message: + error.message || + t('core:message.error.generic', { + postProcess: 'capitalizeFirstChar', + }), + }); + }); + }); + } catch (error) { + setInfo({ + type: 'error', + message: + error?.message || + t('core:message.error.minting_account_remove', { + postProcess: 'capitalizeFirstChar', + }), + }); + setOpenSnack(true); + } finally { + setIsLoading(false); + } + }, + [getMintingAccounts, t] + ); + + const createRewardShare = useCallback( + async (publicKey, recipient) => { + const fee = await getFee('REWARD_SHARE'); + + await show({ + message: t('core:message.question.perform_transaction', { + action: 'REWARD_SHARE', + postProcess: 'capitalizeFirstChar', + }), + publishFee: fee.fee + ' QORT', + }); + return await new Promise((res, rej) => { window - .sendMessage( - 'ADMIN_ACTION', - { - type: 'addmintingaccount', - value: val, - }, - 180000, - true - ) + .sendMessage('createRewardShare', { + recipientPublicKey: publicKey, + }) .then((response) => { if (!response?.error) { + setTxList((prev) => [ + { + recipient, + ...response, + type: 'add-rewardShare', + label: t('group:message.success.rewardshare_add', { + postProcess: 'capitalizeFirstChar', + }), + labelDone: t('group:message.success.rewardshare_add_label', { + postProcess: 'capitalizeFirstChar', + }), + done: false, + }, + ...prev, + ]); res(response); - setMintingKey(''); - setTimeout(() => { - getMintingAccounts(); - }, 300); return; } rej({ message: response.error }); @@ -320,42 +413,20 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { }); }); }); - } catch (error) { - setInfo({ - type: 'error', - message: - error?.message || - t('core:message.error.minting_account_add', { - postProcess: 'capitalizeFirstChar', - }), - }); - setOpenSnack(true); - } finally { - setIsLoading(false); - } - }, []); + }, + [setTxList, show, t] + ); - const removeMintingAccount = useCallback(async (val, acct) => { - try { - setIsLoading(true); + const getRewardSharePrivateKey = useCallback( + async (publicKey) => { return await new Promise((res, rej) => { window - .sendMessage( - 'ADMIN_ACTION', - { - type: 'removemintingaccount', - value: val, - }, - 180000, - true - ) + .sendMessage('getRewardSharePrivateKey', { + recipientPublicKey: publicKey, + }) .then((response) => { if (!response?.error) { res(response); - - setTimeout(() => { - getMintingAccounts(); - }, 300); return; } rej({ message: response.error }); @@ -370,95 +441,9 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { }); }); }); - } catch (error) { - setInfo({ - type: 'error', - message: - error?.message || - t('core:message.error.minting_account_remove', { - postProcess: 'capitalizeFirstChar', - }), - }); - setOpenSnack(true); - } finally { - setIsLoading(false); - } - }, []); - - const createRewardShare = useCallback(async (publicKey, recipient) => { - const fee = await getFee('REWARD_SHARE'); - - await show({ - message: t('core:message.question.perform_transaction', { - action: 'REWARD_SHARE', - postProcess: 'capitalizeFirstChar', - }), - publishFee: fee.fee + ' QORT', - }); - - return await new Promise((res, rej) => { - window - .sendMessage('createRewardShare', { - recipientPublicKey: publicKey, - }) - .then((response) => { - if (!response?.error) { - setTxList((prev) => [ - { - recipient, - ...response, - type: 'add-rewardShare', - label: t('group:message.success.rewardshare_add', { - postProcess: 'capitalizeFirstChar', - }), - labelDone: t('group:message.success.rewardshare_add_label', { - postProcess: 'capitalizeFirstChar', - }), - done: false, - }, - ...prev, - ]); - res(response); - return; - } - rej({ message: response.error }); - }) - .catch((error) => { - rej({ - message: - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }), - }); - }); - }); - }, []); - - const getRewardSharePrivateKey = useCallback(async (publicKey) => { - return await new Promise((res, rej) => { - window - .sendMessage('getRewardSharePrivateKey', { - recipientPublicKey: publicKey, - }) - .then((response) => { - if (!response?.error) { - res(response); - return; - } - rej({ message: response.error }); - }) - .catch((error) => { - rej({ - message: - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }), - }); - }); - }); - }, []); + }, + [t] + ); const waitUntilRewardShareIsConfirmed = async (timeoutMs = 600000) => { const pollingInterval = 30000; @@ -466,16 +451,16 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); while (Date.now() - startTime < timeoutMs) { - const rewardShares = await getRewardShares(myAddress); - const findRewardShare = rewardShares?.find( + const rewardSharesResult = await getRewardShares(myAddress); + const findRewardShare = rewardSharesResult?.find( (item) => item?.recipient === myAddress && item?.mintingAccount === myAddress ); if (findRewardShare) { - return true; // Exit early if found + return true; } - await sleep(pollingInterval); // Wait before the next poll + await sleep(pollingInterval); } throw new Error( @@ -501,9 +486,7 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { await createRewardShare(accountInfo?.publicKey, myAddress); setShowWaitDialog(true); await waitUntilRewardShareIsConfirmed(); - await showNext({ - message: '', - }); + await showNext({ message: '' }); const privateRewardShare = await getRewardSharePrivateKey( accountInfo?.publicKey @@ -544,840 +527,1187 @@ export const Minting = ({ setIsOpenMinting, myAddress, show }) => { timeoutAdminInfoRef.current = null; } }; - }, []); + }, [getAdminInfo, getMintingAccounts, getNodeStatus]); useEffect(() => { if (!myAddress) return; getRewardShares(myAddress); getAccountInfo(myAddress); - }, [myAddress]); + }, [myAddress, getRewardShares]); + + const effectiveSelectedMintingKey = useMemo(() => { + if (!mintingAccounts?.length) return null; + if ( + selectedMintingAccountKey && + mintingAccounts.some((a) => a?.mintingAccount === selectedMintingAccountKey) + ) { + return selectedMintingAccountKey; + } + const mine = mintingAccounts.find( + (a) => + a?.recipientAccount === myAddress || a?.mintingAccount === myAddress + ); + return mine?.mintingAccount ?? mintingAccounts[0]?.mintingAccount ?? null; + }, [mintingAccounts, selectedMintingAccountKey, myAddress]); + + const statsAddress = useMemo(() => { + if (mintingAccounts?.length > 0 && effectiveSelectedMintingKey) { + return effectiveSelectedMintingKey; + } + return myAddress || ''; + }, [mintingAccounts, effectiveSelectedMintingKey, myAddress]); + + useEffect(() => { + if (!statsAddress) { + setStatsAccountInfo(null); + return; + } + if (statsAddress === myAddress) { + if (accountInfo?.address === statsAddress) { + setStatsAccountInfo(accountInfo); + return; + } + getStatsAccountInfo(statsAddress); + return; + } + getStatsAccountInfo(statsAddress); + }, [statsAddress, myAddress, accountInfo, getStatsAccountInfo]); - const handleClose = () => { + const handleCloseSnack = () => { setOpenSnack(false); setTimeout(() => { setInfo(null); }, 250); }; - const handleChange = (event: SyntheticEvent, newValue: number) => { - setValueMintingTab(newValue); + const closeMinting = () => { + setIsOpenMinting(false); + }; + + const openQMintership = useCallback(() => { + setIsOpenMinting(false); + window.setTimeout(() => { + executeEvent('addTab', { + data: { service: 'APP', name: 'q-mintership', path: '' }, + }); + executeEvent('open-apps-mode', {}); + }, 0); + }, [setIsOpenMinting]); + + const formatMetric = ( + value: number | string | null | undefined, + digits = 2, + suffix = '' + ) => { + const numericValue = + typeof value === 'number' ? value : Number.parseFloat(String(value)); + return Number.isFinite(numericValue) + ? `${numericValue.toFixed(digits)}${suffix}` + : '-'; }; + const isNodeSynchronizing = nodeStatus?.isSynchronizing === true; + const daysToNextLevel = levelUpDays( + statsAccountInfo, + adminInfo, + nodeHeightBlock, + nodeStatus + ); + const progressLevel = nextLevel(statsAccountInfo?.level); + const progressBlocks = formatMetric( + levelUpBlocks(statsAccountInfo, nodeStatus), + 0 + ); + const progressDays = + typeof daysToNextLevel === 'number' && Number.isFinite(daysToNextLevel) + ? Math.max(0, Math.round(daysToNextLevel)) + : null; + const walletDisplayName = + handleNames(accountInfo?.address) || myAddress || '-'; + const statsDisplayName = + handleNames(statsAccountInfo?.address) || statsAddress || '-'; + const showAddressLine = + !!statsAccountInfo?.address && statsDisplayName !== statsAccountInfo?.address; + const showWalletAddressLine = + !!accountInfo?.address && walletDisplayName !== accountInfo?.address; + const viewingNodeMintingSelection = + !!effectiveSelectedMintingKey && + !!mintingAccounts?.some( + (a) => a?.mintingAccount === effectiveSelectedMintingKey + ); + + const userMintingState = useMemo(() => { + if (isNodeSynchronizing) { + return { + tone: 'syncing', + title: t('core:minting.panel.state_syncing_title'), + description: t('core:minting.panel.state_syncing_body'), + }; + } + if (viewingNodeMintingSelection) { + return { + tone: 'active', + title: t('core:minting.panel.state_active_title'), + description: t( + 'core:minting.panel.state_active_body_node_selection' + ), + }; + } + if (accountIsMinting) { + return { + tone: 'active', + title: t('core:minting.panel.state_active_title'), + description: t('core:minting.panel.state_active_body_account'), + }; + } + if (!isPartOfMintingGroup) { + return { + tone: 'inactive', + title: t('core:minting.panel.state_inactive_title'), + description: t('core:minting.panel.state_inactive_body_not_in_group'), + }; + } + if (mintingAccounts?.length > 1) { + return { + tone: 'inactive', + title: t('core:minting.panel.state_inactive_title'), + description: t('core:minting.panel.state_inactive_body_multi_key'), + }; + } + return { + tone: 'inactive', + title: t('core:minting.panel.state_inactive_title'), + description: t('core:minting.panel.state_inactive_body_ready'), + }; + }, [ + accountIsMinting, + isNodeSynchronizing, + isPartOfMintingGroup, + mintingAccounts?.length, + t, + viewingNodeMintingSelection, + ]); + + const statusToneStyles = { + active: { + background: alpha('#74d28f', theme.palette.mode === 'dark' ? 0.085 : 0.11), + borderColor: alpha('#74d28f', 0.11), + accent: '#86d89d', + }, + syncing: { + background: alpha('#9eb8df', theme.palette.mode === 'dark' ? 0.08 : 0.1), + borderColor: alpha('#9eb8df', 0.1), + accent: '#a8c2ea', + }, + inactive: { + background: alpha('#e59aa7', theme.palette.mode === 'dark' ? 0.07 : 0.095), + borderColor: alpha('#e59aa7', 0.09), + accent: '#f0a6b2', + }, + } as const; + + const currentStatusTone = statusToneStyles[userMintingState.tone]; + + const nextStepDescription = useMemo(() => { + if (!isPartOfMintingGroup) { + return t('core:minting.panel.next_step_visit_q_mintership'); + } + if (accountIsMinting) { + return t('core:minting.panel.next_step_already_active'); + } + if (isNodeSynchronizing) { + return t('core:minting.panel.next_step_wait_sync'); + } + if (mintingAccounts?.length > 1) { + return t('group:message.generic.minting_keys_per_node', { + postProcess: 'capitalizeFirstChar', + }); + } + return t('core:minting.panel.next_step_start_when_ready'); + }, [ + accountIsMinting, + isNodeSynchronizing, + isPartOfMintingGroup, + mintingAccounts?.length, + t, + ]); + + const sectionLabelSx = { + color: alpha(theme.palette.text.secondary, 0.64), + display: 'block', + fontSize: '0.68rem', + fontWeight: 700, + letterSpacing: '0.11em', + mb: 0.9, + textTransform: 'uppercase', + } as const; + + const surfaceCardSx = { + background: + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(22,25,32,0.68) 0%, rgba(18,21,28,0.76) 100%)' + : 'linear-gradient(180deg, rgba(249,251,254,0.84) 0%, rgba(243,247,251,0.92) 100%)', + border: `1px solid ${alpha(theme.palette.divider, 0.18)}`, + borderRadius: '12px', + boxShadow: 'none', + } as const; + + const sectionDividerSx = { + mt: 2.15, + pt: 2.05, + borderTop: `1px solid ${alpha(theme.palette.divider, 0.16)}`, + } as const; + + const metricListRowSx = { + alignItems: 'center', + display: 'grid', + gap: 1.5, + gridTemplateColumns: { xs: '1fr', sm: 'minmax(0, 1fr) auto' }, + py: 1.05, + '&:not(:last-of-type)': { + borderBottom: `1px solid ${alpha(theme.palette.divider, 0.14)}`, + }, + } as const; + + const metricLabelSx = { + color: alpha(theme.palette.text.secondary, 0.6), + fontSize: '0.88rem', + } as const; + + const metricValueSx = { + fontSize: '0.93rem', + fontWeight: 700, + textAlign: 'right', + } as const; + + const silkyPrimaryButtonSx = { + background: + 'linear-gradient(180deg, rgba(151,189,246,0.98) 0%, rgba(120,163,228,0.98) 100%)', + border: '1px solid rgba(201,223,255,0.42)', + borderRadius: '10px', + boxShadow: + 'inset 0 1px 0 rgba(255,255,255,0.34), 0 10px 24px rgba(44,88,152,0.24)', + color: '#0E1827', + fontWeight: 800, + letterSpacing: '0.01em', + px: 2.1, + py: 1.1, + textTransform: 'none', + '&:hover': { + background: + 'linear-gradient(180deg, rgba(160,196,250,1) 0%, rgba(128,171,233,1) 100%)', + boxShadow: + 'inset 0 1px 0 rgba(255,255,255,0.36), 0 12px 26px rgba(44,88,152,0.28)', + }, + '&.Mui-disabled': { + background: alpha(theme.palette.action.disabledBackground, 0.9), + borderColor: alpha(theme.palette.divider, 0.36), + boxShadow: 'none', + color: theme.palette.text.disabled, + }, + } as const; + + const silkyDangerButtonSx = { + background: + 'linear-gradient(180deg, rgba(248,150,160,0.96) 0%, rgba(226,101,118,0.98) 100%)', + border: '1px solid rgba(255,214,219,0.4)', + borderRadius: '10px', + boxShadow: + 'inset 0 1px 0 rgba(255,255,255,0.3), 0 10px 24px rgba(126,29,44,0.22)', + color: '#2A0C12', + fontWeight: 800, + letterSpacing: '0.01em', + px: 1.8, + py: 0.95, + textTransform: 'none', + '&:hover': { + background: + 'linear-gradient(180deg, rgba(250,158,168,1) 0%, rgba(230,110,127,1) 100%)', + boxShadow: + 'inset 0 1px 0 rgba(255,255,255,0.34), 0 12px 26px rgba(126,29,44,0.26)', + }, + } as const; + + const summaryMetricRows = [ + { + label: t('core:minting.reward_per_day', { + postProcess: 'capitalizeEachFirstChar', + }), + value: + formatMetric( + countRewardDay( + statsAccountInfo, + addressLevel, + adminInfo, + nodeHeightBlock, + nodeStatus, + tier4Online + ), + 4 + ) + ' QORT', + }, + ]; + + const blockchainRows = [ + { + label: t('core:minting.average_blocktime', { + postProcess: 'capitalizeEachFirstChar', + }), + value: t('core:time.second', { + count: parseFloat(formatMetric(averageBlockTime(adminInfo, nodeHeightBlock))), + postProcess: 'capitalizeEachFirstChar', + }), + }, + { + label: t('core:minting.average_blocks_per_day', { + postProcess: 'capitalizeEachFirstChar', + }), + value: formatMetric(averageBlockDay(adminInfo, nodeHeightBlock)), + }, + { + label: t('core:minting.average_created_qorts_per_day', { + postProcess: 'capitalizeEachFirstChar', + }), + value: formatMetric(dayReward(adminInfo, nodeHeightBlock, nodeStatus)) + ' QORT', + }, + ]; + + const directRewardRows = [ + { + label: t('core:minting.current_tier', { + postProcess: 'capitalizeEachFirstChar', + }), + value: t('core:minting.current_tier_content', { + tier: currentTier(statsAccountInfo?.level) + ? currentTier(statsAccountInfo?.level)[0] + : '', + levels: currentTier(statsAccountInfo?.level) + ? currentTier(statsAccountInfo?.level)[1] + : '', + postProcess: 'capitalizeEachFirstChar', + }), + }, + { + label: t('core:minting.tier_share_per_block', { + postProcess: 'capitalizeEachFirstChar', + }), + value: + formatMetric(tierPercent(statsAccountInfo, tier4Online), 0) + ' %', + }, + { + label: t('core:minting.reward_per_block', { + postProcess: 'capitalizeEachFirstChar', + }), + value: + formatMetric( + countReward( + statsAccountInfo, + addressLevel, + nodeStatus, + tier4Online + ), + 8 + ) + ' QORT', + }, + ]; + + const networkContextRows = [ + { + label: t('core:minting.total_minter_in_tier', { + postProcess: 'capitalizeEachFirstChar', + }), + value: formatMetric( + countMintersInLevel( + statsAccountInfo?.level, + addressLevel, + tier4Online + ), + 0 + ), + }, + ]; + return ( - - - - - {t('group:message.generic.manage_minting', { - postProcess: 'capitalizeFirstChar', - })} - + <> + + + + + {t('group:message.generic.manage_minting', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('core:minting.panel.dialog_subtitle')} + + setIsOpenMinting(false)} + onClick={closeMinting} aria-label={t('core:action.close', { postProcess: 'capitalizeFirstChar', })} sx={{ - bgcolor: theme.palette.background.default, - color: theme.palette.text.primary, + borderRadius: '8px', + color: theme.palette.text.secondary, + height: 34, + width: 34, + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + }, }} > - - - - + + - - - - - - - {valueMintingTab === 0 && ( - - + > + + + )} - {/* Blockchain Statistics */} - - {t('core:minting.blockchain_statistics', { - postProcess: 'capitalizeEachFirstChar', - })} - + + - {[ - { - label: t('core:minting.average_blocktime', { - postProcess: 'capitalizeEachFirstChar', - }), - value: t('core:time.second', { - count: parseFloat( - averageBlockTime(adminInfo, nodeHeightBlock).toFixed(2) - ), - postProcess: 'capitalizeEachFirstChar', - }), - }, - { - label: t('core:minting.average_blocks_per_day', { - postProcess: 'capitalizeEachFirstChar', - }), - value: averageBlockDay(adminInfo, nodeHeightBlock).toFixed(2), - }, - { - label: t('core:minting.average_created_qorts_per_day', { - postProcess: 'capitalizeEachFirstChar', - }), - value: dayReward(adminInfo, nodeHeightBlock, nodeStatus).toFixed(2), - }, - ].map((row, i, arr) => ( - - - {row.label} - - - {row.value} - - - ))} - - - {/* Account Details */} - - {t('core:minting.account_details', { - postProcess: 'capitalizeEachFirstChar', - })} - - - - {t('core:minting.current_status', { - postProcess: 'capitalizeEachFirstChar', - })} + /> + + + {mintingAccounts?.length > 0 ? ( + + + {t('core:minting.panel.section_your_wallet')} + + + {walletDisplayName} + + {showWalletAddressLine ? ( + + {accountInfo?.address} + + ) : null} + + ) : null} + + + + {mintingAccounts?.length > 0 + ? t('core:minting.panel.section_minter_profile_stats') + : t('core:minting.panel.section_account_identity')} - - {mintingStatus(nodeStatus)} + + {statsDisplayName} + {showAddressLine ? ( + + {statsAccountInfo?.address} + + ) : null} + - - {t('core:minting.current_level', { - postProcess: 'capitalizeEachFirstChar', - })} + + {t('core:minting.panel.section_progress_next_level')} - {accountInfo?.level ?? '—'} + {t('core:level', { postProcess: 'capitalizeFirstChar' })}{' '} + {statsAccountInfo?.level ?? '-'} - - - - {t('core:minting.blocks_next_level', { - postProcess: 'capitalizeEachFirstChar', + + {progressBlocks} + + + {t('core:minting.panel.blocks_to_level', { + level: progressLevel ?? '-', })} - - {levelUpBlocks(accountInfo, nodeStatus).toFixed(0) || '—'} + + {t('core:minting.panel.minting_for_approx_days', { + days: progressDays ?? '-', + })} + - - }} - values={{ - level: nextLevel(accountInfo?.level), - count: daysToNextLevel?.toFixed(2), - }} - tOptions={{ postProcess: ['capitalizeFirstChar'] }} - /> + + {t('core:minting.panel.section_next_step')} + + {nextStepDescription} + + + {isPartOfMintingGroup && !accountIsMinting ? ( + + + {mintingAccounts?.length > 1 ? ( + + {t('group:message.generic.minting_keys_per_node', { + postProcess: 'capitalizeFirstChar', + })} + + ) : null} + + ) : null} + + {!isPartOfMintingGroup ? ( + + + + ) : null} - - {/* Rewards Info */} - - {t('core:minting.rewards_info', { - postProcess: 'capitalizeEachFirstChar', - })} - - - {[ - { - label: t('core:minting.current_tier', { - postProcess: 'capitalizeEachFirstChar', - }), - value: t('core:minting.current_tier_content', { - tier: currentTier(accountInfo?.level) - ? currentTier(accountInfo?.level)[0] - : '', - levels: currentTier(accountInfo?.level) - ? currentTier(accountInfo?.level)[1] - : '', - postProcess: 'capitalizeEachFirstChar', - }), - }, - { - label: t('core:minting.total_minter_in_tier', { - postProcess: 'capitalizeEachFirstChar', - }), - value: - countMintersInLevel( - accountInfo?.level, - addressLevel, - tier4Online - )?.toFixed(0) || '—', - }, - { - label: t('core:minting.tier_share_per_block', { - postProcess: 'capitalizeEachFirstChar', - }), - value: - tierPercent(accountInfo, tier4Online)?.toFixed(0) + ' %', - }, - { - label: t('core:minting.reward_per_block', { - postProcess: 'capitalizeEachFirstChar', - }), - value: - countReward( - accountInfo, - addressLevel, - nodeStatus, - tier4Online - ).toFixed(8) + ' QORT', - }, - { - label: t('core:minting.reward_per_day', { - postProcess: 'capitalizeEachFirstChar', - }), - value: - countRewardDay( - accountInfo, - addressLevel, - adminInfo, - nodeHeightBlock, - nodeStatus, - tier4Online - ).toFixed(8) + ' QORT', - }, - ].map((row, i, arr) => ( + {mintingAccounts?.length > 0 ? ( - - {row.label} + + {t('core:minting.panel.section_node_minting_accounts')} - {row.value} + {t('core:minting.panel.select_account_for_stats_hint')} + + {mintingAccounts?.map((acct) => { + const isSelected = + acct?.mintingAccount === effectiveSelectedMintingKey; + return ( + + setSelectedMintingAccountKey(acct?.mintingAccount) + } + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedMintingAccountKey(acct?.mintingAccount); + } + }} + sx={{ + backgroundColor: alpha( + theme.palette.background.default, + theme.palette.mode === 'dark' ? 0.14 : 0.34 + ), + border: `1px solid ${ + isSelected + ? alpha(theme.palette.primary.main, 0.55) + : alpha(theme.palette.divider, 0.14) + }`, + borderRadius: '10px', + boxShadow: isSelected + ? `0 0 0 1px ${alpha(theme.palette.primary.main, 0.35)}` + : 'none', + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + gap: 1, + outline: 'none', + p: 1.15, + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + }} + > + + + {handleNames(acct?.mintingAccount)} + + {isSelected ? ( + + {t('core:minting.panel.badge_viewing_stats')} + + ) : null} + + + + ); + })} + + {mintingAccounts?.length > 1 ? ( + + {t('group:message.generic.minting_keys_per_node_different', { + postProcess: 'capitalizeFirstChar', + })} + + ) : null} - ))} + ) : null} + - - - )} - - {valueMintingTab === 1 && ( - - {isLoading && ( - - - - )} - - - {/* Account info */} - - {t('auth:account.account_one', { - postProcess: 'capitalizeFirstChar', - })} - + - + {t('core:minting.current_status')} + + - - {t('auth:address', { postProcess: 'capitalizeFirstChar' })} - - - {handleNames(accountInfo?.address)} - - - + - - {t('core:level', { postProcess: 'capitalizeFirstChar' })} - + {userMintingState.description} + + + + + {summaryMetricRows.map((row) => ( + {row.label} - {accountInfo?.level ?? '—'} + {row.value} - + ))} - {/* Start minting */} - {isPartOfMintingGroup && !accountIsMinting && ( - + + {t('core:minting.blockchain_statistics')} + + - - {mintingAccounts?.length > 1 && ( - - {t('group:message.generic.minting_keys_per_node', { - postProcess: 'capitalizeFirstChar', - })} - - )} + {t('core:minting.panel.reference_only')} + + + {blockchainRows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} - )} + - {/* Minting accounts list */} - {mintingAccounts?.length > 0 && ( - <> + + + {t('core:minting.rewards_info')} + + + - {t('group:message.generic.node_minting_account', { - postProcess: 'capitalizeFirstChar', - })} + {statsAddress === myAddress + ? t('core:minting.panel.rewards_info_subtitle_own') + : t('core:minting.panel.rewards_info_subtitle_selected')} - - {accountIsMinting && ( - - - {t('group:message.generic.node_minting_key', { - postProcess: 'capitalizeFirstChar', - })} + + {directRewardRows.map((row) => ( + + + {row.label} - - )} - {mintingAccounts?.map((acct, i) => ( - - - {t('group:message.generic.minting_account', { - postProcess: 'capitalizeFirstChar', - })}{' '} - {handleNames(acct?.mintingAccount)} + + {row.value} - ))} - {mintingAccounts?.length > 1 && ( - - - {t( - 'group:message.generic.minting_keys_per_node_different', - { postProcess: 'capitalizeFirstChar' } - )} - - - )} - - )} + - {/* Not part of minting group */} - {!isPartOfMintingGroup && ( - {t('group:message.generic.minter_group', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('group:message.generic.mintership_app', { - postProcess: 'capitalizeFirstChar', - })} - - + {t('core:minting.panel.network_details')} + + + {networkContextRows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + - )} + + + + + - {showWaitDialog && ( - - - {isShowNext - ? t('core:message.generic.confirmed', { - postProcess: 'capitalizeFirstChar', - }) - : t('core:message.generic.wait', { - postProcess: 'capitalizeFirstChar', - })} - + {showWaitDialog && ( + + + {isShowNext + ? t('core:message.generic.confirmed', { + postProcess: 'capitalizeFirstChar', + }) + : t('core:message.generic.wait', { + postProcess: 'capitalizeFirstChar', + })} + - - {!isShowNext && ( - - {t('group:message.success.rewardshare_creation', { - postProcess: 'capitalizeFirstChar', - })} - - )} + + {!isShowNext && ( + + {t('group:message.success.rewardshare_creation', { + postProcess: 'capitalizeFirstChar', + })} + + )} - {isShowNext && ( - - {t('group:message.success.rewardshare_confirmed', { - postProcess: 'capitalizeFirstChar', - })} - - )} - - - - - - - )} - + {isShowNext && ( + + {t('group:message.success.rewardshare_confirmed', { + postProcess: 'capitalizeFirstChar', + })} + + )} - )} - - + + + + )} + + - - {info?.message} - - - + setOpen={setOpenSnack} + info={info} + setInfo={setInfo} + /> + ); }; diff --git a/src/components/NewUsersCTA.tsx b/src/components/NewUsersCTA.tsx index a2a176e2..b745b0ad 100644 --- a/src/components/NewUsersCTA.tsx +++ b/src/components/NewUsersCTA.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Typography } from '@mui/material'; +import { openHttpUrlExternally } from '../utils/openExternalHttp'; import { useAtomValue } from 'jotai'; import { balanceAtom } from '../atoms/global'; import { Spacer } from '../common/Spacer'; @@ -72,13 +72,7 @@ export const NewUsersCTA = () => { backgroundColor: '#4297E2', }} onClick={() => { - if (window?.electronAPI?.openExternal) { - window.electronAPI.openExternal( - 'https://link.qortal.dev/support' - ); - } else { - window.open('https://link.qortal.dev/support', '_blank'); - } + openHttpUrlExternally('https://link.qortal.dev/support'); }} > Nextcloud diff --git a/src/components/NotAuthenticated.tsx b/src/components/NotAuthenticated.tsx index 2cb9a7e4..a601d288 100644 --- a/src/components/NotAuthenticated.tsx +++ b/src/components/NotAuthenticated.tsx @@ -1,972 +1,1083 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; -import { Spacer } from '../common/Spacer'; -import { CustomButton, TextP, TextSpan } from '../styles/App-styles'; -import { - Box, - Button, - ButtonBase, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - Input, - styled, - Switch, - TextField, - Typography, - useTheme, -} from '@mui/material'; +import { Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import AddRoundedIcon from '@mui/icons-material/AddRounded'; +import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded'; +import AccountCircleRoundedIcon from '@mui/icons-material/AccountCircleRounded'; +import BoltRoundedIcon from '@mui/icons-material/BoltRounded'; +import HubRoundedIcon from '@mui/icons-material/HubRounded'; +import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded'; +import authIntroAudioSrc from '../assets/audio/light-transition-351939.mp3'; import Logo1Dark from '../assets/svgs/Logo1Dark.svg'; -import HelpIcon from '@mui/icons-material/Help'; -import { CustomizedSnackbars } from './Snackbar/Snackbar'; -import { cleanUrl, gateways } from '../background/background.ts'; -import Tooltip, { TooltipProps, tooltipClasses } from '@mui/material/Tooltip'; -import { useTranslation } from 'react-i18next'; +import { useAtomValue } from 'jotai'; +import { selectedNodeInfoAtom } from '../atoms/global'; import { - getDefaultLocalNodeUrl, HTTPS_EXT_NODE_QORTAL_LINK, isLocalNodeUrl, - LOCALHOST_12391, -} from '../constants/constants.ts'; -import { useAtom, useAtomValue } from 'jotai'; +} from '../constants/constants'; +import { Wallets } from './Wallets'; +import { AuthButton, AuthFrame } from './Auth/AuthShell'; +import { ConnectionModeModal } from './Auth/ConnectionModeModal'; import { - hasSeenGettingStartedAtom, - selectedNodeInfoAtom, - statusesAtom, -} from '../atoms/global.ts'; -import { useHandleTutorials } from '../hooks/useHandleTutorials'; -import { useAuth } from '../hooks/useAuth.tsx'; -import { nodeDisplay } from '../utils/helpers.ts'; + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AuthUnlockTransitionSnapshot } from '../types/authTransition'; + +type IntroLogoMetrics = { + finalHeight: number; + finalLeft: number; + finalTop: number; + finalWidth: number; + initialHeight: number; + initialLeft: number; + initialTop: number; + initialWidth: number; + overshootTop: number; +}; + +type IntroStage = 'pending' | 'ready' | 'running' | 'settling' | 'complete'; + +const AUTH_UI_ANIMATIONS_STORAGE_KEY = 'hub_ui_animations_enabled'; +const AUTH_INTRO_OVERSHOOT_PX = 7; +const AUTH_INTRO_START_DELAY_MS = 560; +const AUTH_INTRO_FRAME_SETTLE_MS = 32; +const AUTH_INTRO_MOVE_MS = 860; +const AUTH_INTRO_SETTLE_MS = 300; +const AUTH_INTRO_COMPLETE_DELAY_MS = 620; +const AUTH_INTRO_SETTLE_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; +const AUTH_INTRO_AUDIO_DURATION_SECONDS = 8.1; +const AUTH_INTRO_AUDIO_PEAK_SECONDS = 2.44; +const AUTH_INTRO_AUDIO_VOLUME = 0.42; +const AUTH_INTRO_AUDIO_START_OFFSET_SECONDS = Math.max( + 0, + AUTH_INTRO_AUDIO_PEAK_SECONDS - + (AUTH_INTRO_START_DELAY_MS + + AUTH_INTRO_FRAME_SETTLE_MS + + AUTH_INTRO_MOVE_MS + + AUTH_INTRO_SETTLE_MS) / + 1000 +); +const AUTH_INTRO_LOGO_RING_DOWN_MS = Math.round( + (AUTH_INTRO_AUDIO_DURATION_SECONDS - AUTH_INTRO_AUDIO_PEAK_SECONDS) * + 1000 +); +const AUTH_INTRO_LOGO_RING_MS = + AUTH_INTRO_SETTLE_MS + AUTH_INTRO_LOGO_RING_DOWN_MS; +const AUTH_INTRO_LOGO_RING_PEAK_PERCENT = `${Math.round( + (AUTH_INTRO_SETTLE_MS / AUTH_INTRO_LOGO_RING_MS) * 100 +)}%`; +const AUTH_INTRO_LOGO_FINAL_FILTER = + 'brightness(1.23) contrast(1.06) saturate(1.1) drop-shadow(0 0 10px rgba(26,130,255,0.24))'; +const AUTH_INTRO_LOGO_PEAK_FILTER = + 'brightness(1.32) contrast(1.08) saturate(1.14) drop-shadow(0 0 17px rgba(31,136,255,0.38))'; +const AUTH_INTRO_LOGO_MID_RING_FILTER = + 'brightness(1.29) contrast(1.075) saturate(1.13) drop-shadow(0 0 15px rgba(31,136,255,0.32))'; +const AUTH_INTRO_LOGO_LATE_RING_FILTER = + 'brightness(1.26) contrast(1.065) saturate(1.115) drop-shadow(0 0 12px rgba(29,132,255,0.27))'; +const AUTH_INTRO_LOGO_SETTLING_FILTER = + 'brightness(1.2) contrast(1.05) saturate(1.08)'; +const AUTH_INTRO_LOGO_SETTLING_PEAK_FILTER = + 'brightness(1.3) contrast(1.08) saturate(1.13) drop-shadow(0 0 14px rgba(31,136,255,0.32))'; +const AUTH_INTRO_LOGO_SETTLING_MID_FILTER = + 'brightness(1.265) contrast(1.07) saturate(1.12) drop-shadow(0 0 12px rgba(31,136,255,0.27))'; +const AUTH_INTRO_LOGO_SETTLING_LATE_FILTER = + 'brightness(1.225) contrast(1.055) saturate(1.095) drop-shadow(0 0 8px rgba(29,132,255,0.18))'; +let hasAuthIntroPlayedThisSession = false; + +const areAuthAnimationsEnabled = () => { + if (typeof window === 'undefined') return true; + + try { + const storedValue = window.localStorage.getItem( + AUTH_UI_ANIMATIONS_STORAGE_KEY + ); + + if (storedValue === null) return true; + + return JSON.parse(storedValue) !== false; + } catch { + return true; + } +}; export const manifestData = { version: '1.0.0', }; -export const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: theme.palette.background.paper, - color: theme.palette.text.primary, - maxWidth: 320, - padding: '20px', - fontSize: theme.typography.pxToRem(12), - }, -})); - -function removeTrailingSlash(url: string) { - return url.trim().replace(/\/+$/, ''); -} - export const NotAuthenticated = ({ setExtstate, - handleSetGlobalApikey, - setUseLocalNode, + setRawWallet, + rawWallet, + onWalletUnlockStart, }) => { - const { handleSaveNodeInfo } = useAuth(); - const [openSnack, setOpenSnack] = useState(false); - const [infoSnack, setInfoSnack] = useState(null); - const [show, setShow] = useState(false); - const [mode, setMode] = useState('list'); - const [customNodes, setCustomNodes] = useState(null); - const [url, setUrl] = useState('https://'); - const [customApikey, setCustomApiKey] = useState(''); - const [showSelectApiKey, setShowSelectApiKey] = useState(false); - const [enteredApiKey, setEnteredApiKey] = useState(''); - const [selectedNode, setSelectedNode] = useAtom(selectedNodeInfoAtom); - const [customNodeToSaveIndex, setCustomNodeToSaveIndex] = useState(null); - const { showTutorial } = useHandleTutorials(); - const hasSeenGettingStarted = useAtomValue(hasSeenGettingStartedAtom); const theme = useTheme(); - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); - - const handleFileChangeApiKey = (event) => { - setShowSelectApiKey(false); - const file = event.target.files[0]; // Get the selected file - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const text = e.target.result; // Get the file content - - if (customNodes) { - setCustomNodes((prev) => { - const copyPrev = [...prev]; - const findLocalIndex = copyPrev?.findIndex((item) => - isLocalNodeUrl(item?.url) - ); - if (findLocalIndex === -1) { - copyPrev.unshift({ - url: getDefaultLocalNodeUrl(), - apikey: text, - }); - } else { - copyPrev[findLocalIndex] = { - url: getDefaultLocalNodeUrl(), - apikey: text, - }; - } - window.sendMessage('setCustomNodes', copyPrev).catch((error) => { - console.error( - 'Failed to set custom nodes:', - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }) - ); - }); - return copyPrev; + const isLight = theme.palette.mode === 'light'; + const { t } = useTranslation(['auth']); + const selectedNode = useAtomValue(selectedNodeInfoAtom); + const [isConnectionModeOpen, setIsConnectionModeOpen] = useState(false); + const [isEntryAccountsReady, setIsEntryAccountsReady] = useState(false); + const [isUnlockLeaving, setIsUnlockLeaving] = useState(false); + const [isLogoRingDownActive, setIsLogoRingDownActive] = useState(false); + const logoRef = useRef(null); + const introAudioRef = useRef(null); + const globalMotionOverrideRef = useRef<{ + element: HTMLStyleElement; + wasDisabled?: boolean; + } | null>(null); + const [isIntroLogoReady, setIsIntroLogoReady] = useState(false); + const [introMetrics, setIntroMetrics] = useState(null); + const [introStage, setIntroStage] = useState('pending'); + const usingLocalNode = isLocalNodeUrl(selectedNode?.url); + const customNodeStatusLabel = + selectedNode?.name?.trim() || selectedNode?.url?.trim() || ''; + const connectionLabel = usingLocalNode + ? t('auth:authentication_form.using_local_node', { + postProcess: 'capitalizeFirstChar', + }) + : selectedNode?.url === HTTPS_EXT_NODE_QORTAL_LINK + ? t('auth:authentication_form.using_public_node', { + postProcess: 'capitalizeFirstChar', + }) + : customNodeStatusLabel + ? t('auth:authentication_form.using_custom_node_named', { + label: customNodeStatusLabel, + defaultValue: 'Using {{label}}', + postProcess: 'capitalizeFirstChar', + }) + : t('auth:authentication_form.using_custom_node', { + postProcess: 'capitalizeFirstChar', }); + const featureItems = useMemo( + () => [ + { + id: 'secure', + icon: , + + title: t('auth:intro_features.secure_title'), + text: t('auth:intro_features.secure_text'), + }, + { + id: 'fast', + icon: , + + title: t('auth:intro_features.fast_title'), + text: t('auth:intro_features.fast_text'), + }, + { + id: 'decentralized', + icon: , + + title: t('auth:intro_features.decentralized_title'), + text: t('auth:intro_features.decentralized_text'), + }, + { + id: 'built', + icon: , + + title: t('auth:intro_features.built_title'), + text: t('auth:intro_features.built_text'), + }, + ], + [t] + ); + const handleEntryAccountsReady = useCallback(() => { + setIsEntryAccountsReady(true); + }, []); + const handleWalletUnlockStart = useCallback( + (snapshot: AuthUnlockTransitionSnapshot) => { + const audio = introAudioRef.current; + if (audio) { + audio.pause(); + try { + audio.currentTime = 0; + } catch { + // Some media backends reject seeking before metadata is ready. } - }; - reader.readAsText(file); // Read the file as text - } + } + setIsLogoRingDownActive(false); + setIsUnlockLeaving(true); + onWalletUnlockStart?.(snapshot); + }, + [onWalletUnlockStart] + ); + const hasAccountsText = ( + + ); + const introComplete = introStage === 'complete'; + const introCardGlowOpacity = + introComplete ? 1 : introStage === 'settling' ? 0.76 : 0; + const shouldAnimateIntro = !introComplete; + const isLogoAnimating = + introStage === 'running' || introStage === 'settling'; + const isMainAuthContentVisible = introComplete || isLogoAnimating; + const shouldRenderIntroOverlay = shouldAnimateIntro || isLogoRingDownActive; + const isIntroOverlayAtRest = isLogoAnimating || isLogoRingDownActive; + const restoreGlobalMotionOverride = () => { + const override = globalMotionOverrideRef.current; + + if (!override) return; + + override.element.disabled = override.wasDisabled ?? false; + globalMotionOverrideRef.current = null; + }; + const prepareIntroMetrics = () => { + const logoElement = + logoRef.current || + (typeof document !== 'undefined' + ? (document.querySelector( + '[data-auth-logo-target="entry-logo"]' + ) as HTMLImageElement | null) + : null); + + if (!logoElement) return false; + + const rect = logoElement.getBoundingClientRect(); + const initialWidth = rect.width * 1.45; + const initialHeight = rect.height * 1.45; + const initialTop = window.innerHeight / 2 - initialHeight / 2; + const yDirection = rect.top >= initialTop ? 1 : -1; + + setIntroMetrics({ + finalHeight: rect.height, + finalLeft: rect.left, + finalTop: rect.top, + finalWidth: rect.width, + initialHeight, + initialLeft: window.innerWidth / 2 - initialWidth / 2, + initialTop, + initialWidth, + overshootTop: rect.top + yDirection * AUTH_INTRO_OVERSHOOT_PX, + }); + + return true; }; + const revealSx = (delayMs: number) => + introComplete + ? {} + : { + animation: isLogoAnimating + ? `authIntroReveal 400ms cubic-bezier(0.4, 0, 0.2, 1) ${delayMs}ms both` + : 'none', + opacity: 0, + pointerEvents: 'none', + transform: 'translateY(6px)', + }; + const cardRevealSx = (delayMs: number) => + introComplete + ? {} + : { + animation: isLogoAnimating + ? `authIntroCardReveal 320ms cubic-bezier(0.4, 0, 0.2, 1) ${delayMs}ms both` + : 'none', + opacity: 0, + pointerEvents: 'none', + }; + const stopIntroAudio = useCallback(() => { + const audio = introAudioRef.current; + + if (!audio) return; + + audio.pause(); + audio.volume = AUTH_INTRO_AUDIO_VOLUME; + try { + audio.currentTime = 0; + } catch { + // Some media backends reject seeking before metadata is ready. + } + }, []); + const playIntroAudio = useCallback(() => { + if (!areAuthAnimationsEnabled()) return; + + const audio = introAudioRef.current ?? new Audio(authIntroAudioSrc); + introAudioRef.current = audio; + + audio.pause(); + audio.preload = 'auto'; + audio.volume = AUTH_INTRO_AUDIO_VOLUME; + try { + audio.currentTime = AUTH_INTRO_AUDIO_START_OFFSET_SECONDS; + } catch { + // If metadata is still loading, playback can start from the beginning. + } + + void audio.play().catch(() => { + // Autoplay can be blocked outside Electron before user interaction. + }); + }, []); useEffect(() => { - window - .sendMessage('getCustomNodesFromStorage') - .then((response) => { - setCustomNodes(response || []); - if (window?.electronAPI?.setAllowedDomains) { - window.electronAPI.setAllowedDomains( - response?.map((node) => node.url) - ); - } - }) - .catch((error) => { - console.error( - 'Failed to get custom nodes from storage:', - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }) - ); - }); + if (!areAuthAnimationsEnabled()) { + setIsIntroLogoReady(true); + return; + } + + let isMounted = true; + const logoImage = new Image(); + const markLogoReady = () => { + if (isMounted) { + setIsIntroLogoReady(true); + } + }; + const fallbackTimer = window.setTimeout(markLogoReady, 1200); + + logoImage.onload = () => { + window.clearTimeout(fallbackTimer); + markLogoReady(); + }; + logoImage.onerror = () => { + window.clearTimeout(fallbackTimer); + markLogoReady(); + }; + logoImage.src = Logo1Dark; + + if (typeof logoImage.decode === 'function') { + logoImage + .decode() + .then(() => { + window.clearTimeout(fallbackTimer); + markLogoReady(); + }) + .catch(() => { + window.clearTimeout(fallbackTimer); + markLogoReady(); + }); + } + + return () => { + isMounted = false; + window.clearTimeout(fallbackTimer); + }; }, []); - const addCustomNode = () => { - setMode('add-node'); - }; + useEffect(() => { + if (!areAuthAnimationsEnabled()) return undefined; - const saveCustomNodes = async (myNodes, isFullListOfNodes) => { - let nodes = [...(myNodes || [])]; - if (!isFullListOfNodes && customNodeToSaveIndex !== null) { - nodes.splice(customNodeToSaveIndex, 1, { - url: removeTrailingSlash(url), - apikey: customApikey?.trim() || '', - }); - } else if (!isFullListOfNodes && url) { - nodes.push({ - url: removeTrailingSlash(url), - apikey: customApikey?.trim() || '', - }); + const audio = new Audio(authIntroAudioSrc); + audio.preload = 'auto'; + audio.volume = AUTH_INTRO_AUDIO_VOLUME; + introAudioRef.current = audio; + audio.load(); + + return () => { + stopIntroAudio(); + introAudioRef.current = null; + }; + }, [stopIntroAudio]); + + useLayoutEffect(() => { + if (!isEntryAccountsReady) return; + + const globalMotionOverride = + typeof document !== 'undefined' + ? (document.getElementById( + 'hub-ui-animations-style' + ) as HTMLStyleElement | null) + : null; + const wasGlobalMotionOverrideDisabled = globalMotionOverride?.disabled; + if (hasAuthIntroPlayedThisSession || !areAuthAnimationsEnabled()) { + setIntroStage('complete'); + return; } - if (!isFullListOfNodes && url) { - await handleSaveNodeInfo({ - url: removeTrailingSlash(url), - apikey: customApikey?.trim() || '', - }); + + hasAuthIntroPlayedThisSession = true; + + // The dashboard UI Animations toggle injects a global transition killer. + // If the user logs out, that style can still be mounted during auth. + if (globalMotionOverride) { + globalMotionOverrideRef.current = { + element: globalMotionOverride, + wasDisabled: wasGlobalMotionOverrideDisabled, + }; + globalMotionOverride.disabled = true; } - setCustomNodes(nodes); - - setCustomNodeToSaveIndex(null); - if (!nodes) return; - window - .sendMessage('setCustomNodes', nodes) - .then((response) => { - if (response) { - setMode('list'); - setUrl('https://'); - setCustomApiKey(''); - if (window?.electronAPI?.setAllowedDomains) { - window.electronAPI.setAllowedDomains( - nodes?.map((node) => node.url) - ); - } - // add alert if needed - } - }) - .catch((error) => { - console.error( - 'Failed to set custom nodes:', - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }) - ); - }); - }; + if (!prepareIntroMetrics()) { + setIntroStage('complete'); + return; + } + + setIntroStage('ready'); + + return () => { + restoreGlobalMotionOverride(); + }; + }, [isEntryAccountsReady]); + + useEffect(() => { + if (introStage !== 'ready' || !isIntroLogoReady) return; + + let loadTimer = 0; + let secondFrame = 0; + let firstFrame = 0; + const startIntro = () => { + playIntroAudio(); + loadTimer = window.setTimeout(() => { + firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + setIntroStage('running'); + }); + }); + }, AUTH_INTRO_START_DELAY_MS); + }; + + if (document.readyState === 'complete') { + startIntro(); + } else { + window.addEventListener('load', startIntro, { once: true }); + } + + return () => { + window.removeEventListener('load', startIntro); + window.clearTimeout(loadTimer); + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + }; + }, [introStage, isIntroLogoReady, playIntroAudio]); + + useEffect(() => { + if (introStage !== 'running') return; + + const settleTimer = window.setTimeout(() => { + setIsLogoRingDownActive(true); + setIntroStage('settling'); + }, AUTH_INTRO_MOVE_MS); + + return () => { + window.clearTimeout(settleTimer); + }; + }, [introStage]); + + useEffect(() => { + if (!isLogoRingDownActive) return; + + const ringTimer = window.setTimeout(() => { + setIsLogoRingDownActive(false); + }, AUTH_INTRO_LOGO_RING_MS); + + return () => { + window.clearTimeout(ringTimer); + }; + }, [isLogoRingDownActive]); + + useEffect(() => { + if (introStage !== 'settling') return; + + const completeTimer = window.setTimeout(() => { + setIntroStage('complete'); + restoreGlobalMotionOverride(); + }, AUTH_INTRO_COMPLETE_DELAY_MS); + + return () => { + window.clearTimeout(completeTimer); + }; + }, [introStage, stopIntroAudio]); + + useEffect(() => { + if (introStage !== 'running' && introStage !== 'settling') return; + + const completeIntroOnResize = () => { + setIntroStage('complete'); + setIsLogoRingDownActive(false); + restoreGlobalMotionOverride(); + }; + + window.addEventListener('resize', completeIntroOnResize); + + return () => { + window.removeEventListener('resize', completeIntroOnResize); + }; + }, [introStage, stopIntroAudio]); return ( <> - - - - - - - {t('auth:welcome', { postProcess: 'capitalizeFirstChar' })} - - {' '} - QORTAL - - - - - - + + + - {t('auth:tips.qortal_account', { - postProcess: 'capitalizeFirstChar', - })} - - - } - > - setExtstate('wallets')}> - {t('auth:account.account_many', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - + /> + + + - {t('auth:tips.new_users', { - postProcess: 'capitalizeFirstChar', - })} + {t('auth:entry.title')} - - - {t('auth:tips.new_account', { - postProcess: 'capitalizeFirstChar', - })} + {t('auth:entry.subtitle')} - - } - > - { - setExtstate('create-wallet'); - }} - sx={{ - backgroundColor: - hasSeenGettingStarted === false && theme.palette.other.positive, - color: - hasSeenGettingStarted === false && theme.palette.text.primary, - '&:hover': { - backgroundColor: - hasSeenGettingStarted === false && theme.palette.other.unread, - color: - hasSeenGettingStarted === false && theme.palette.text.primary, - }, - }} - > - {t('auth:action.create_account', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - {t('auth:node.using', { postProcess: 'capitalizeFirstChar' })}:{' '} - {nodeDisplay(selectedNode?.url)} - - <> - + - - <> - - {t('auth:advanced_users', { postProcess: 'capitalizeFirstChar' })} - + {hasAccountsText} + - {/* - { + setIsLogoRingDownActive(false); + stopIntroAudio(); + setExtstate('create-wallet'); }} - control={ - { - if (!event.target.checked) { - validateApiKey(currentNode); - } else { - setCurrentNode({ - url: getDefaultLocalNodeUrl(), - }); - setUseLocalNode(false); - window - .sendMessage('setApiKey', null) - .then((response) => { - if (response) { - setApiKey(null); - handleSetGlobalApikey(null); - } - }) - .catch((error) => { - console.error( - t('auth:message.error.set_apikey', { - postProcess: 'capitalizeFirstChar', - }), - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }) - ); - }); - } - }} - disabled={false} - /> - } - label={ - isLocal - ? t('auth:node.use_public', { - postProcess: 'capitalizeFirstChar', - }) - : t('auth:node.use_custom', { - postProcess: 'capitalizeFirstChar', - }) - } - /> - */} - {/* {currentNode?.url === getDefaultLocalNodeUrl() && ( - <> - + + {t('auth:entry.create_account')} + + + { + setIsLogoRingDownActive(false); + stopIntroAudio(); + setExtstate('wallets'); + }} + sx={{ + alignItems: 'center', + border: isLight + ? `1px solid ${theme.palette.border.main}` + : '1px solid rgba(255,255,255,0.1)', + borderRadius: '8px', + color: theme.palette.text.primary, + display: 'inline-flex', + gap: 0.8, + height: 42, + justifyContent: 'center', + transition: + 'background-color 160ms ease, border-color 160ms ease', + width: '100%', + '&:hover': { + backgroundColor: isLight + ? theme.palette.action.hover + : 'rgba(255,255,255,0.035)', + borderColor: isLight + ? theme.palette.border.main + : 'rgba(255,255,255,0.15)', + }, + }} + > + - {t('auth:apikey.key', { postProcess: 'capitalizeFirstChar' })} - : {importedApiKey} + {t('auth:entry.import_account')} - - )} */} - - - - - - {t('auth:build_version', { postProcess: 'capitalizeFirstChar' })}: - {` `} - {manifestData?.version} - - - - - {show && ( - - - {t('auth:node.custom_many', { postProcess: 'capitalizeAll' })} - + + - - {mode === 'list' && ( + + - + + + setIsConnectionModeOpen(true)} + sx={{ + alignItems: 'center', + color: theme.palette.text.secondary, + display: 'inline-flex', + gap: 0.8, + minWidth: 0, + p: 0, + '&:hover': { + color: theme.palette.text.primary, + }, + }} + > + + + {t('auth:connection_mode.title')} + + + + + + + {featureItems.map((item) => ( + + + {item.icon} + + + - - {getDefaultLocalNodeUrl()} (Local node) - - - - - - - + - - {HTTPS_EXT_NODE_QORTAL_LINK} (Public node) - - - - - - - - {customNodes?.map((node, index) => { - return ( - - - {node?.url} - - - - - - - - - - - ); - })} + {item.text} + - )} + + ))} + + + - {mode === 'add-node' && ( - - { - setUrl(e.target.value); - }} - /> - - { - setCustomApiKey(e.target.value); - }} - /> - - )} - - - - - {mode === 'list' && ( - - )} - - {mode === 'list' && ( - <> - - - )} - - {mode === 'add-node' && ( - <> - - - - - )} - - - )} - {showSelectApiKey && ( - - - {t('auth:apikey.enter', { postProcess: 'capitalizeFirstChar' })} - - - - - setEnteredApiKey(e.target.value)} - /> - - - - - - - - - - + /> + )} - {/* TODO update tutorial */} - {/* { - showTutorial('create-account', true); - }} - sx={{ - position: 'fixed', - bottom: '25px', - right: '25px', - }} - > - - */} + + setIsConnectionModeOpen(false)} + /> ); }; diff --git a/src/components/PasswordField/PasswordField.tsx b/src/components/PasswordField/PasswordField.tsx index 5bf18398..abc90783 100644 --- a/src/components/PasswordField/PasswordField.tsx +++ b/src/components/PasswordField/PasswordField.tsx @@ -5,10 +5,15 @@ import { TextFieldProps, styled, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import { forwardRef, useState } from 'react'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import VisibilityIcon from '@mui/icons-material/Visibility'; +type PasswordFieldProps = TextFieldProps & { + suppressAutofill?: boolean; +}; + export const CustomInput = styled(TextField)(({ theme }) => ({ width: '183px', borderRadius: '8px', @@ -37,6 +42,25 @@ export const CustomInput = styled(TextField)(({ theme }) => ({ border: `0.5px solid ${theme.palette.divider}`, }, }, + '& .MuiInputAdornment-root .MuiButtonBase-root': { + borderRadius: 6, + color: + theme.palette.mode === 'dark' + ? 'rgba(214,221,233,0.42)' + : alpha(theme.palette.text.secondary, 0.82), + minWidth: 0, + opacity: 0.88, + padding: 4, + transition: 'color 160ms ease, opacity 160ms ease, background-color 160ms ease', + }, + '& .MuiInputAdornment-root .MuiButtonBase-root:hover': { + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.035)' + : alpha(theme.palette.common.black, 0.06), + color: theme.palette.text.primary, + opacity: 1, + }, '& .MuiInput-underline:before': { borderBottom: 'none', }, @@ -52,42 +76,103 @@ export const CustomInput = styled(TextField)(({ theme }) => ({ fill: theme.palette.secondary, }, }, + '& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus': + { + WebkitBoxShadow: + theme.palette.mode === 'dark' + ? '0 0 0 100px rgb(38, 42, 50) inset' + : '0 0 0 100px rgb(248, 250, 253) inset', + WebkitTextFillColor: theme.palette.text.primary, + caretColor: theme.palette.text.primary, + transition: 'background-color 9999s ease-out 0s', + }, })); -export const PasswordField = forwardRef( - ({ ...props }, ref) => { +export const PasswordField = forwardRef( + ( + { + InputProps, + inputProps, + onBlur, + onFocus, + onMouseDown, + suppressAutofill = false, + value, + ...props + }, + ref + ) => { const [canViewPassword, setCanViewPassword] = useState(false); + const [isEditable, setIsEditable] = useState(!suppressAutofill); + const hasValue = typeof value === 'string' ? value.length > 0 : Boolean(value); return ( { + if (suppressAutofill) { + setIsEditable(true); + } + onFocus?.(event); + }} + onMouseDown={(event) => { + if (suppressAutofill) { + setIsEditable(true); + } + onMouseDown?.(event); + }} + onBlur={(event) => { + if (suppressAutofill && !hasValue) { + setIsEditable(false); + } + onBlur?.(event); + }} InputProps={{ + ...InputProps, + readOnly: + Boolean(InputProps?.readOnly) || + (suppressAutofill ? !isEditable && !hasValue : false), endAdornment: ( - { - setCanViewPassword((prevState) => !prevState); - }} - > - {canViewPassword ? ( - - - - ) : ( - - - - )} - + <> + {InputProps?.endAdornment} + { + setCanViewPassword((prevState) => !prevState); + }} + > + {canViewPassword ? ( + + + + ) : ( + + + + )} + + ), }} + inputProps={{ + ...inputProps, + ...(suppressAutofill + ? { + autoComplete: 'new-password', + 'data-1p-ignore': 'true', + 'data-lpignore': 'true', + spellCheck: 'false', + } + : {}), + }} inputRef={ref} {...props} /> diff --git a/src/components/Profile/AuthenticatedProfile.tsx b/src/components/Profile/AuthenticatedProfile.tsx index 83d294fa..a3a4985f 100644 --- a/src/components/Profile/AuthenticatedProfile.tsx +++ b/src/components/Profile/AuthenticatedProfile.tsx @@ -13,7 +13,6 @@ export type AuthenticatedProfileProps = ProfileLeftProps & { onOpenSettings: () => void; onOpenDrawerLookup: () => void; onOpenWalletsApp: () => void; - onOpenDrawerProfile: () => void; getUserInfo: (useTimer?: boolean) => Promise; onOpenMinting: () => void; showTutorial: (key: string, force?: boolean) => void; @@ -38,7 +37,6 @@ export const AuthenticatedProfile = ({ onOpenSettings, onOpenDrawerLookup, onOpenWalletsApp, - onOpenDrawerProfile, getUserInfo, onOpenMinting, showTutorial, diff --git a/src/components/Profile/ChatWidgetReopenIcon.tsx b/src/components/Profile/ChatWidgetReopenIcon.tsx index c3560528..991a900b 100644 --- a/src/components/Profile/ChatWidgetReopenIcon.tsx +++ b/src/components/Profile/ChatWidgetReopenIcon.tsx @@ -2,7 +2,7 @@ import { ButtonBase, Tooltip, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useAtom, useAtomValue } from 'jotai'; import { chatWidgetClosedAtom, memberGroupsAtom } from '../../atoms/global'; -import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded'; +import MoreRoundedIcon from '@mui/icons-material/MoreRounded'; import { Spacer } from '../../common/Spacer'; const tooltipSlotProps = (theme: any) => ({ @@ -19,8 +19,15 @@ const tooltipSlotProps = (theme: any) => ({ }, }); -/** Right-sidebar icon to reopen the chat widget. Subscribes to atoms; only visible when widget is closed and user has groups. */ -export function ChatWidgetReopenIcon({ inTitleBar = false }: { inTitleBar?: boolean } = {}) { +export function ChatWidgetReopenIcon({ + inTitleBar = false, + buttonSx = undefined, + iconSx = undefined, +}: { + inTitleBar?: boolean; + buttonSx?: any; + iconSx?: any; +} = {}) { const theme = useTheme(); const { t } = useTranslation(['group']); const [chatWidgetClosed, setChatWidgetClosed] = useAtom(chatWidgetClosedAtom); @@ -36,7 +43,19 @@ export function ChatWidgetReopenIcon({ inTitleBar = false }: { inTitleBar?: bool aria-label={t('group:group.messaging', { postProcess: 'capitalizeFirstChar', })} - sx={inTitleBar ? { width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1 } : undefined} + sx={{ + ...(inTitleBar + ? { + width: 32, + height: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 1, + } + : {}), + ...(buttonSx || {}), + }} > - + ); if (inTitleBar) { - return ( -
- {icon} -
- ); + return icon; } return ( <> diff --git a/src/components/QMailStatus.tsx b/src/components/QMailStatus.tsx index 61dca28c..40981380 100644 --- a/src/components/QMailStatus.tsx +++ b/src/components/QMailStatus.tsx @@ -1,73 +1,108 @@ -import { useMemo } from 'react'; -import { mailsAtom, qMailLastEnteredTimestampAtom } from '../atoms/global'; -import { isLessThanOneWeekOld } from './Group/qmailUtils'; -import { ButtonBase, Tooltip, useTheme } from '@mui/material'; -import { executeEvent } from '../utils/events'; import { Mail } from '@mui/icons-material'; +import { ButtonBase, Tooltip, useTheme } from '@mui/material'; +import { useAtom, useAtomValue } from 'jotai'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useAtom } from 'jotai'; +import { + isNotificationSeenInAppFromKeyTimes, + notificationSeenInAppKeyTimesAtom, + paymentNotificationsAtom, + qMailLastEnteredTimestampAtom, +} from '../atoms/global'; +import { executeEvent } from '../utils/events'; -export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); - const theme = useTheme(); +function toTimestampMs(value) { + if (value == null || typeof value !== 'number') return null; + return value < 1e12 ? value * 1000 : value; +} - const [lastEnteredTimestamp, setLastEnteredTimestamp] = useAtom( - qMailLastEnteredTimestampAtom - ); - const [mails, setMails] = useAtom(mailsAtom); +export const QMailStatus = ({ + compact = false, + buttonSx = undefined, + iconSx = undefined, + tooltipPlacement = undefined, +}: { + compact?: boolean; + buttonSx?: any; + iconSx?: any; + tooltipPlacement?: 'bottom' | 'left' | 'right' | 'top'; +}) => { + const { t } = useTranslation(['core']); + const theme = useTheme(); + const [, setLastEnteredTimestamp] = useAtom(qMailLastEnteredTimestampAtom); + const notifications = useAtomValue(paymentNotificationsAtom); + const seenInAppKeyTimes = useAtomValue(notificationSeenInAppKeyTimesAtom); const hasNewMail = useMemo(() => { - if (mails?.length === 0) return false; - const latestMail = mails[0]; - if (!lastEnteredTimestamp && isLessThanOneWeekOld(latestMail?.created)) - return true; - if ( - lastEnteredTimestamp < latestMail?.created && - isLessThanOneWeekOld(latestMail?.created) - ) - return true; - return false; - }, [lastEnteredTimestamp, mails]); + return (notifications ?? []).some((notification) => { + const isQMail = + notification?.event === 'RESOURCE_PUBLISHED' && + (notification?.notificationId === 'q-mail-notification' || + notification?.appName === 'Q-Mail'); + if (!isQMail) return false; + const timestamp = toTimestampMs( + notification?.data?.created ?? + notification?.data?.timestamp ?? + notification?.timestamp + ); + if (timestamp == null) return false; + return !isNotificationSeenInAppFromKeyTimes( + notification, + seenInAppKeyTimes + ); + }); + }, [notifications, seenInAppKeyTimes]); - const button = ( + return ( { - executeEvent('addTab', { data: { service: 'APP', name: 'q-mail' } }); + executeEvent('addTab', { + data: { + name: 'Q-Mail', + navigateIfAlreadyOpen: true, + service: 'APP', + }, + }); executeEvent('open-apps-mode', {}); setLastEnteredTimestamp(Date.now()); }} - style={{ + sx={{ position: 'relative', - ...(compact && { width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }), + ...(compact && { + alignItems: 'center', + borderRadius: 1, + display: 'flex', + height: 32, + justifyContent: 'center', + width: 32, + }), + ...(buttonSx || {}), }} > {hasNewMail && ( -
)} @@ -76,18 +111,11 @@ export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { })} } - placement={compact ? 'bottom' : 'left'} - arrow - sx={{ fontSize: compact ? '20' : '24' }} slotProps={{ + arrow: { sx: { color: theme.palette.background.paper } }, tooltip: { sx: { - color: theme.palette.text.primary, backgroundColor: theme.palette.background.paper, - }, - }, - arrow: { - sx: { color: theme.palette.text.primary, }, }, @@ -97,18 +125,10 @@ export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { sx={{ color: theme.palette.text.secondary, fontSize: compact ? 20 : undefined, + ...(iconSx || {}), }} /> ); - - if (compact) { - return ( -
- {button} -
- ); - } - return button; }; diff --git a/src/components/QortPayment.tsx b/src/components/QortPayment.tsx index ae7cd6bf..82eee89b 100644 --- a/src/components/QortPayment.tsx +++ b/src/components/QortPayment.tsx @@ -3,19 +3,30 @@ import { Box, Button, CircularProgress, + IconButton, + InputAdornment, TextField, Typography, useTheme, } from '@mui/material'; -import { useState } from 'react'; -import { Spacer } from '../common/Spacer'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { useEffect, useState } from 'react'; import { getFee } from '../background/background.ts'; import { useTranslation } from 'react-i18next'; import BoundedNumericTextField from '../common/BoundedNumericTextField.tsx'; -import { PasswordField } from './PasswordField/PasswordField.tsx'; import { ErrorText } from './ErrorText/ErrorText.tsx'; +import { getBlueTier1ButtonSx } from '../styles/blueMaterial'; +import { executeEvent } from '../utils/events'; +import { QORTINO_DONATION_COMPLETED_EVENT } from './Group/qortinoDonationEasterEgg'; -export const QortPayment = ({ balance, show, onSuccess, defaultPaymentTo }) => { +export const QortPayment = ({ + balance, + show, + onSuccess, + defaultPaymentTo, + compact = false, +}) => { const theme = useTheme(); const { t } = useTranslation([ 'auth', @@ -27,14 +38,98 @@ export const QortPayment = ({ balance, show, onSuccess, defaultPaymentTo }) => { const [paymentTo, setPaymentTo] = useState(defaultPaymentTo); const [paymentAmount, setPaymentAmount] = useState(0); const [paymentPassword, setPaymentPassword] = useState(''); + const [isPaymentPasswordEditable, setIsPaymentPasswordEditable] = + useState(false); const [sendPaymentError, setSendPaymentError] = useState(''); - const [sendPaymentSuccess, setSendPaymentSuccess] = useState(''); const [isLoadingSendCoin, setIsLoadingSendCoin] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const isDarkMode = theme.palette.mode === 'dark'; + const secondaryTextColor = alpha( + theme.palette.text.secondary, + isDarkMode ? 0.9 : 0.82 + ); + const inputSurface = alpha( + isDarkMode ? theme.palette.common.white : theme.palette.text.primary, + isDarkMode ? 0.032 : 0.042 + ); + const sectionDivider = alpha(theme.palette.divider, isDarkMode ? 0.18 : 0.22); + const inputBorder = alpha(theme.palette.divider, isDarkMode ? 0.16 : 0.2); + + useEffect(() => { + setPaymentTo(defaultPaymentTo || ''); + }, [defaultPaymentTo]); + + const fieldLabelSx = { + color: secondaryTextColor, + fontSize: compact ? '0.75rem' : '0.77rem', + fontWeight: 400, + letterSpacing: '0.014em', + lineHeight: 1.2, + } as const; + + const fieldGroupSx = { + display: 'flex', + flexDirection: 'column', + gap: compact ? '6px' : '7px', + } as const; + const formatSendPaymentError = (message: unknown) => { + const rawMessage = + typeof message === 'string' + ? message.trim() + : message instanceof Error + ? message.message.trim() + : ''; + + if (/\bNO_BALANCE\b/i.test(rawMessage)) { + return t('question:message.error.insufficient_balance_qort', { + defaultValue: 'Your QORT balance is insufficient.', + postProcess: 'capitalizeFirstChar', + }); + } + + return rawMessage; + }; + + const textFieldSurfaceSx = { + '& .MuiOutlinedInput-root': { + backgroundColor: inputSurface, + borderRadius: '10px', + color: theme.palette.text.primary, + minHeight: compact ? '42px' : '44px', + '& fieldset': { + borderColor: inputBorder, + }, + '&:hover fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.28), + }, + '&.Mui-focused fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.42), + }, + }, + '& .MuiOutlinedInput-input': { + fontSize: compact ? '0.92rem' : '0.94rem', + fontWeight: 500, + padding: compact ? '9px 12px' : '10px 13px', + }, + '& .MuiOutlinedInput-input::placeholder': { + color: alpha(theme.palette.text.secondary, isDarkMode ? 0.78 : 0.72), + fontWeight: 400, + opacity: 1, + }, + '& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus': + { + WebkitBoxShadow: isDarkMode + ? '0 0 0 100px rgb(28, 31, 38) inset' + : '0 0 0 100px rgb(248, 250, 253) inset', + WebkitTextFillColor: theme.palette.text.primary, + caretColor: theme.palette.text.primary, + transition: 'background-color 9999s ease-out 0s', + }, + } as const; const sendCoinFunc = async () => { try { setSendPaymentError(''); - setSendPaymentSuccess(''); if (!paymentTo) { setSendPaymentError( t('auth:action.enter_recipient', { @@ -80,14 +175,18 @@ export const QortPayment = ({ balance, show, onSuccess, defaultPaymentTo }) => { }) .then((response) => { if (response?.error) { - setSendPaymentError(response.error); + setSendPaymentError(formatSendPaymentError(response.error)); } else { + executeEvent(QORTINO_DONATION_COMPLETED_EVENT, { + recipient: paymentTo.trim(), + }); onSuccess(); } setIsLoadingSendCoin(false); }) .catch((error) => { console.error('Failed to send coin:', error); + setSendPaymentError(formatSendPaymentError(error)); setIsLoadingSendCoin(false); }); } catch (error) { @@ -100,181 +199,205 @@ export const QortPayment = ({ balance, show, onSuccess, defaultPaymentTo }) => { sx={{ display: 'flex', flexDirection: 'column', - flexGrow: 1, - overflowY: 'auto', - p: 2, + gap: compact ? '14px' : '16px', + px: compact ? 2 : 2.5, + py: compact ? 1.6 : 2.1, }} > - - - {/* Page title + balance */} - - - {t('core:action.transfer_qort', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - {t('core:balance', { postProcess: 'capitalizeFirstChar' })} - - - {balance?.toFixed(2)} QORT - - - - - {/* Recipient */} - + - - {t('core:to', { postProcess: 'capitalizeFirstChar' })} - - setPaymentTo(e.target.value)} - autoComplete="off" - variant="outlined" - size="small" - fullWidth - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 2, - bgcolor: theme.palette.background.default, - }, - }} - /> - - - {/* Amount */} - + - - {t('core:amount', { postProcess: 'capitalizeFirstChar' })} - - setPaymentAmount(+e)} - /> - + {balance?.toFixed(2)} QORT + + - {/* Password */} - + + {t('core:to', { postProcess: 'capitalizeFirstChar' })} + + setPaymentTo(e.target.value)} + autoComplete="off" + placeholder={t('group:dashboard.qortal_address_or_name')} + fullWidth + sx={textFieldSurfaceSx} + /> + + + + + {t('core:amount', { postProcess: 'capitalizeFirstChar' })} + + setPaymentAmount(+e)} sx={{ - borderRadius: 2, - border: 1, - borderColor: alpha(theme.palette.divider, 0.4), - bgcolor: alpha(theme.palette.background.default, 0.5), - mb: 2, - px: 2, - py: 1.5, + width: '100%', + '& .MuiOutlinedInput-root': { + backgroundColor: inputSurface, + borderRadius: '10px', + minHeight: compact ? '42px' : '44px', + '& fieldset': { + borderColor: inputBorder, + }, + '&:hover fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.28), + }, + '&.Mui-focused fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.42), + }, + }, + '& input': { + fontSize: compact ? '0.92rem' : '0.94rem', + padding: compact ? '9px 12px' : '10px 13px', + }, }} - > - - {t('auth:wallet.password_confirmation', { - postProcess: 'capitalizeFirstChar', - })} - - setPaymentPassword(e.target.value)} - autoComplete="off" - onKeyDown={(e) => { - if (e.key === 'Enter') { - if (isLoadingSendCoin) return; - sendCoinFunc(); - } - }} - /> - - - {sendPaymentError} - - + /> + - - + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (isLoadingSendCoin) return; + sendCoinFunc(); + } + }} + InputProps={{ + readOnly: !isPaymentPasswordEditable, + endAdornment: ( + + setShowPassword((prev) => !prev)} + edge="end" + sx={{ color: theme.palette.text.secondary }} + > + {showPassword ? ( + + ) : ( + + )} + + + ), + }} + inputProps={{ + autoComplete: 'new-password', + 'data-1p-ignore': 'true', + 'data-lpignore': 'true', + spellCheck: 'false', + }} + sx={textFieldSurfaceSx} + /> + + + {sendPaymentError} + + + ); }; diff --git a/src/components/RegisterName.tsx b/src/components/RegisterName.tsx index 23ebf9e3..13b28607 100644 --- a/src/components/RegisterName.tsx +++ b/src/components/RegisterName.tsx @@ -1,18 +1,16 @@ import { useCallback, useEffect, useState } from 'react'; import { - Alert, Box, - Button, + ButtonBase, Dialog, - DialogActions, - DialogContent, - DialogTitle, TextField, Typography, useTheme, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CloseIcon from '@mui/icons-material/Close'; import { getBaseApiReact } from '../App'; import { getFee } from '../background/background.ts'; import { subscribeToEvent, unsubscribeFromEvent } from '../utils/events'; @@ -22,6 +20,7 @@ import ErrorIcon from '@mui/icons-material/Error'; import { useSetAtom } from 'jotai'; import { txListAtom } from '../atoms/global'; import { useTranslation } from 'react-i18next'; +import { getBlueTier1ButtonSx } from '../styles/blueMaterial'; enum NameAvailability { NULL = 'null', @@ -47,6 +46,7 @@ export const RegisterName = ({ ); const [nameFee, setNameFee] = useState(null); const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; const { t } = useTranslation([ 'auth', 'core', @@ -54,6 +54,35 @@ export const RegisterName = ({ 'question', 'tutorial', ]); + const modalSurface = isDarkMode + ? 'linear-gradient(145deg, rgba(49,54,64,0.985) 0%, rgba(35,39,47,0.992) 48%, rgba(24,27,33,0.996) 100%)' + : 'linear-gradient(180deg, rgba(251,253,255,0.985) 0%, rgba(244,247,251,0.99) 100%)'; + const surfaceBorder = isDarkMode + ? 'rgba(255,255,255,0.08)' + : alpha(theme.palette.divider, 0.32); + const shellDivider = isDarkMode + ? 'rgba(255,255,255,0.052)' + : alpha(theme.palette.divider, 0.24); + const sectionDivider = isDarkMode + ? 'rgba(255,255,255,0.052)' + : alpha(theme.palette.divider, 0.18); + const softSectionSurface = isDarkMode + ? 'linear-gradient(145deg, rgba(94,101,114,0.34) 0%, rgba(72,78,89,0.3) 100%)' + : alpha(theme.palette.text.primary, 0.035); + const fieldBorder = + theme.palette.border?.subtle ?? + (isDarkMode ? 'rgba(255,255,255,0.085)' : 'rgba(24,29,36,0.12)'); + const fieldSurface = isDarkMode + ? 'linear-gradient(145deg, rgba(88,95,108,0.2) 0%, rgba(56,62,73,0.28) 44%, rgba(37,41,49,0.42) 100%)' + : 'linear-gradient(180deg, rgba(17,23,34,0.042) 0%, rgba(17,23,34,0.024) 100%)'; + const fieldSurfaceHover = isDarkMode + ? 'linear-gradient(145deg, rgba(98,106,120,0.24) 0%, rgba(63,70,82,0.34) 46%, rgba(43,48,57,0.48) 100%)' + : 'linear-gradient(180deg, rgba(17,23,34,0.06) 0%, rgba(17,23,34,0.034) 100%)'; + const fieldInsetShadow = isDarkMode + ? '0 8px 20px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.035)' + : '0 4px 10px rgba(24,32,44,0.06), inset 0 1px 0 rgba(255,255,255,0.5)'; + const contentColumnMaxWidth = 428; + const checkIfNameExisits = async (name) => { if (!name?.trim()) { setIsNameAvailable(NameAvailability.NULL); @@ -112,6 +141,13 @@ export const RegisterName = ({ nameRegistrationFee(); }, []); + const closeRegisterName = useCallback(() => { + if (isLoadingRegisterName) return; + setIsOpen(false); + setRegisterNameValue(''); + setIsNameAvailable(NameAvailability.NULL); + }, [isLoadingRegisterName]); + const registerName = async () => { try { if (!userInfo?.address) @@ -134,64 +170,46 @@ export const RegisterName = ({ }), publishFee: fee.fee + ' QORT', }); + const nameToRegister = registerNameValue.trim(); setIsLoadingRegisterName(true); - new Promise((res, rej) => { - window - .sendMessage('registerName', { - name: registerNameValue, - }) - .then((response) => { - if (!response?.error) { - res(response); - setIsLoadingRegisterName(false); - setInfoSnack({ - type: 'success', - message: t('group:message.success.registered_name', { - postProcess: 'capitalizeFirstChar', - }), - }); - setIsOpen(false); - setRegisterNameValue(''); - setOpenSnack(true); - setTxList((prev) => [ - { - ...response, - type: 'register-name', - label: t('group:message.success.registered_name_label', { - postProcess: 'capitalizeFirstChar', - }), - labelDone: t( - 'group:message.success.registered_name_success', - { - postProcess: 'capitalizeFirstChar', - } - ), - done: false, - }, - ...prev.filter((item) => !item.done), - ]); - return; - } - setInfoSnack({ - type: 'error', - message: response?.error, - }); - setOpenSnack(true); - rej(response.error); - }) - .catch((error) => { - setInfoSnack({ - type: 'error', - message: - error.message || - t('core:message.error.generic', { - postProcess: 'capitalizeFirstChar', - }), - }); - setOpenSnack(true); - rej(error); - }); + setIsOpen(false); + setRegisterNameValue(''); + setIsNameAvailable(NameAvailability.NULL); + + const response = await window.sendMessage('registerName', { + name: nameToRegister, + }); + + if (response?.error) { + setInfoSnack({ + type: 'error', + message: response.error, + }); + setOpenSnack(true); + return; + } + + setInfoSnack({ + type: 'success', + message: t('group:message.success.registered_name', { + postProcess: 'capitalizeFirstChar', + }), }); + setOpenSnack(true); + setTxList((prev) => [ + { + ...response, + type: 'register-name', + label: t('group:message.success.registered_name_label', { + postProcess: 'capitalizeFirstChar', + }), + labelDone: t('group:message.success.registered_name_success', { + postProcess: 'capitalizeFirstChar', + }), + done: false, + }, + ...prev.filter((item) => !item.done), + ]); } catch (error) { if (error?.message) { setOpenSnack(true); @@ -217,272 +235,512 @@ export const RegisterName = ({ return ( - - {t('core:action.register_name', { - postProcess: 'capitalizeFirstChar', - })} - - - + - + - {t('core:action.choose_name', { - postProcess: 'capitalizeFirstChar', - })} - - setRegisterNameValue(e.target.value)} - value={registerNameValue} - placeholder={t('core:action.choose_name', { - postProcess: 'capitalizeFirstChar', - })} + id="register-name-dialog-title" sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: '12px', - bgcolor: - theme.palette.background?.default ?? - 'rgba(255,255,255,0.04)', - }, - }} - /> - - - {hasInsufficientBalance && ( - } - sx={{ - borderRadius: '10px', - '& .MuiAlert-message': { fontSize: '0.8125rem' }, + color: theme.palette.text.primary, + fontSize: '0.98rem', + fontWeight: 700, + letterSpacing: '-0.02em', }} > - {t('core:message.generic.name_registration', { - balance: balance ?? 0, - fee: nameFee != null ? Number(nameFee).toFixed(2) : nameFee, + {t('core:action.register_name', { postProcess: 'capitalizeFirstChar', })} - - )} - - {isNameAvailable === NameAvailability.AVAILABLE && ( - + - + {t('tutorial:home.register_name_workspace_hint')} + + + + + + + + + + - {t('core:message.generic.name_available', { - name: registerNameValue, + {t('core:name', { postProcess: 'capitalizeFirstChar', })} - - )} - - {isNameAvailable === NameAvailability.NOT_AVAILABLE && ( - - - - {t('core:message.generic.name_unavailable', { - name: registerNameValue, + setRegisterNameValue(e.target.value)} + value={registerNameValue} + placeholder={t('core:name', { postProcess: 'capitalizeFirstChar', })} - + sx={{ + '& .MuiOutlinedInput-root': { + background: fieldSurface, + borderRadius: '10px', + boxShadow: fieldInsetShadow, + color: theme.palette.text.primary, + '& fieldset': { + borderColor: fieldBorder, + }, + '&:hover fieldset': { + borderColor: isDarkMode + ? 'rgba(255,255,255,0.12)' + : alpha(theme.palette.primary.main, 0.42), + }, + '&.Mui-focused fieldset': { + borderColor: alpha(theme.palette.primary.main, 0.9), + borderWidth: 1.5, + }, + '&:hover': { + background: fieldSurfaceHover, + }, + }, + '& .MuiOutlinedInput-input': { + fontSize: '0.92rem', + px: 1.25, + py: 1.18, + }, + }} + /> - )} - {isNameAvailable === NameAvailability.LOADING && ( - - - - {t('core:message.generic.name_checking', { - postProcess: 'capitalizeFirstChar', - })} - - - )} + + + {t('core:message.generic.name_registration', { + balance: balance ?? 0, + fee: nameFee != null ? Number(nameFee).toFixed(2) : nameFee, + postProcess: 'capitalizeFirstChar', + })} + + + )} - - - {t('core:message.generic.name_benefits', { - postProcess: 'capitalizeFirstChar', - })} - - + {isNameAvailable === NameAvailability.AVAILABLE && ( - - - {t('core:message.generic.publish_data', { + + {t('core:message.generic.name_available', { + name: registerNameValue, postProcess: 'capitalizeFirstChar', })} + )} + + {isNameAvailable === NameAvailability.NOT_AVAILABLE && ( - - - {t('core:message.generic.secure_ownership', { + + {t('core:message.generic.name_unavailable', { + name: registerNameValue, postProcess: 'capitalizeFirstChar', })} + )} + + {isNameAvailable === NameAvailability.LOADING && ( + + + + {t('core:message.generic.name_checking', { + postProcess: 'capitalizeFirstChar', + })} + + + )} + + + + {t('core:message.generic.name_benefits', { + postProcess: 'capitalizeFirstChar', + })} + + + + + + {t('core:message.generic.publish_data', { + postProcess: 'capitalizeFirstChar', + })} + + + + + + {t('core:message.generic.secure_ownership', { + postProcess: 'capitalizeFirstChar', + })} + + + - - - - - + + + + {t('core:action.register_name', { + postProcess: 'capitalizeFirstChar', + })} + + + + + {t('core:action.close', { postProcess: 'capitalizeFirstChar' })} + + + + + ); }; diff --git a/src/components/Save/Save.tsx b/src/components/Save/Save.tsx index f8ad33b1..22372c26 100644 --- a/src/components/Save/Save.tsx +++ b/src/components/Save/Save.tsx @@ -21,6 +21,7 @@ import { QORTAL_APP_CONTEXT } from '../../App'; import { getFee } from '../../background/background.ts'; import { CustomizedSnackbars } from '../Snackbar/Snackbar'; import { SaveIcon } from '../../assets/Icons/SaveIcon'; +import SaveRoundedIcon from '@mui/icons-material/SaveRounded'; import { IconWrapper } from '../Desktop/DesktopFooter'; import { Spacer } from '../../common/Spacer'; import { LoadingButton } from '@mui/lab'; @@ -34,6 +35,12 @@ import { import { useTranslation } from 'react-i18next'; import { useAtom, useSetAtom } from 'jotai'; import { TIME_MINUTES_1_IN_MILLISECONDS } from '../../constants/constants.ts'; +import { + dialogInfoCardSx, + getDialogPaperSx, + getDialogPrimaryButtonSx, + getDialogSecondaryButtonSx, +} from '../App/dialogSurface'; export const handleImportClick = async () => { const fileInput = document.createElement('input'); @@ -65,7 +72,14 @@ export const handleImportClick = async () => { }); }; -export const Save = ({ isDesktop, disableWidth, myName }) => { +export const Save = ({ + isDesktop, + disableWidth, + myName, + toolbarModule = false, + buttonSx = undefined, + iconSx = undefined, +}) => { const [pinnedApps, setPinnedApps] = useAtom(sortablePinnedAppsAtom); const [settingsQdnLastUpdated, setSettingsQdnLastUpdated] = useAtom( settingsQDNLastUpdatedAtom @@ -233,9 +247,23 @@ export const Save = ({ isDesktop, disableWidth, myName }) => { - {isDesktop ? ( + {toolbarModule ? ( + + ) : isDesktop ? ( disableWidth ? ( { onClose={() => setOpenDialog(false)} maxWidth="sm" fullWidth + PaperProps={{ + sx: getDialogPaperSx(theme, { maxWidth: 540 }), + }} > {isUsingImportExportSettings && ( - + {t('core:message.generic.settings', { postProcess: 'capitalizeFirstChar', })} - - - + + - { - try { - const data64 = await objectToBase64(pinnedApps); + ({ - width: '100%', - maxWidth: '420px', - fontFamily: 'Inter', - fontSize: '15px', - fontWeight: 500, - borderRadius: '14px', - boxShadow: theme.shadows[8], - padding: '14px 18px', - alignItems: 'center', - '& .MuiAlert-icon': { - alignItems: 'center', - fontSize: '22px', - }, - '& .MuiAlert-message': { - padding: '0 4px', - lineHeight: 1.4, - }, -}); +import { useEffect, useRef } from 'react'; +import { executeEvent } from '../../utils/events'; export const LoadingSnackbar = ({ open, info }) => { - const theme = useTheme(); - return ( - - - {info?.message} - - - ); + const sourceIdRef = useRef(`loading-snackbar-${Math.random().toString(36).slice(2)}`); + const lastMessageRef = useRef(null); + + useEffect(() => { + if (open && info?.message) { + if (lastMessageRef.current !== info.message) { + lastMessageRef.current = info.message; + executeEvent('openGlobalSnackBar', { + duration: null, + message: info.message, + sourceId: sourceIdRef.current, + type: 'info', + }); + } + return; + } + + if (lastMessageRef.current != null) { + executeEvent('closeGlobalSnackBar', { + sourceId: sourceIdRef.current, + }); + lastMessageRef.current = null; + } + }, [info?.message, open]); + + useEffect(() => { + return () => { + if (lastMessageRef.current != null) { + executeEvent('closeGlobalSnackBar', { + sourceId: sourceIdRef.current, + }); + } + }; + }, []); + + return null; }; diff --git a/src/components/Snackbar/QortinoNotificationHost.tsx b/src/components/Snackbar/QortinoNotificationHost.tsx new file mode 100644 index 00000000..88e0b249 --- /dev/null +++ b/src/components/Snackbar/QortinoNotificationHost.tsx @@ -0,0 +1,735 @@ +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import { Box, IconButton, Typography, useTheme } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DEFAULT_QORTINO_LOOK_DEBUG_SETTINGS } from '../Group/qortinoLookDebug'; +import { + DEFAULT_QORTINO_INLET_DEBUG_SETTINGS, + type QortinoInletDebugSettings, +} from './qortinoInletDebug'; +import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; + +type NotificationType = 'error' | 'info' | 'success' | 'warning'; + +type NotificationInfo = { + compact?: boolean; + dismissible?: boolean; + duration?: number | null; + message?: string; + sourceId?: string; + type?: string; +} | null; + +type QortinoNotificationHostProps = { + info: NotificationInfo; + open: boolean; + setInfo: (nextInfo: NotificationInfo) => void; + setOpen: (nextOpen: boolean) => void; +}; + +type NotificationPayload = { + compact?: boolean; + dismissible?: boolean; + duration?: number | null; + message?: string; + sourceId?: string; + type?: string; +}; + +const DEFAULT_NOTIFICATION_DURATION_MS = 5200; +const ERROR_NOTIFICATION_DURATION_MS = 7200; + +const QORTINO_INLET_LOOK = DEFAULT_QORTINO_LOOK_DEBUG_SETTINGS; +const QORTINO_INLET_BAR_START_PX = 12; + +type QortinoInletHeadGeometry = { + faceHeight: number; + faceLeft: number; + faceTop: number; + faceWidth: number; + headHeight: number; + headOffsetX: number; + headOffsetY: number; + headWidth: number; + shellBorderRadius: string; + stageHeight: number; + stageWidth: number; +}; + +const getQortinoInletShellBorderRadius = (roundness: number) => { + const clampPercent = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, Math.round(value))); + + const topX = clampPercent(46 * roundness, 34, 56); + const topY = clampPercent(48 * roundness, 36, 58); + const bottomX = clampPercent(42 * roundness, 30, 52); + const bottomY = clampPercent(40 * roundness, 28, 50); + + return `${topX}% ${topX}% ${bottomX}% ${bottomX}% / ${topY}% ${topY}% ${bottomY}% ${bottomY}%`; +}; + +const getQortinoInletHeadGeometry = ( + debugSettings: QortinoInletDebugSettings +): QortinoInletHeadGeometry => { + const headScale = 0.92 + (QORTINO_INLET_LOOK.bodyScale - 0.95) * 0.08; + const headWidth = Math.round(64 * headScale * debugSettings.headWidthScale); + const headHeight = Math.round(61 * headScale * debugSettings.headHeightScale); + const faceWidth = Math.round(45 * headScale * debugSettings.faceWidthScale); + const faceHeight = Math.round(31 * headScale * debugSettings.faceHeightScale); + const faceLeft = Math.round((headWidth - faceWidth) / 2); + const faceTop = Math.round((headHeight - faceHeight) / 2 + 2); + + return { + faceHeight, + faceLeft, + faceTop, + faceWidth, + headHeight, + headOffsetX: debugSettings.offsetX, + headOffsetY: debugSettings.offsetY, + headWidth, + shellBorderRadius: getQortinoInletShellBorderRadius( + debugSettings.shellRoundness + ), + stageHeight: headHeight + 5, + stageWidth: headWidth + 8, + }; +}; + +const normalizeNotificationType = (value?: string): NotificationType => { + if (value === 'success' || value === 'warning' || value === 'error') { + return value; + } + + return 'info'; +}; + +const normalizeNotificationMessage = (value?: string) => { + const trimmed = value?.trim(); + + if (!trimmed) return ''; + + if (/^saving file success!?$/i.test(trimmed)) return 'File saved'; + if (/^successfully sent notification\.?$/i.test(trimmed)) { + return 'Notification sent'; + } + if (/^successfully requested to join group\./i.test(trimmed)) { + return 'Join request sent'; + } + if (/^failed to join the group$/i.test(trimmed)) + return 'Unable to join group'; + if (/^opened$/i.test(trimmed)) return 'App opened'; + if (/^loading announcements$/i.test(trimmed)) + return 'Loading announcements...'; + if (/^loading chat\.\.\. please wait\.?$/i.test(trimmed)) { + return 'Loading chat...'; + } + if (/^setting up group\.\.\. please wait\.?$/i.test(trimmed)) { + return 'Setting up group...'; + } + + return trimmed; +}; + +const getNotificationPalette = (type: NotificationType) => { + switch (type) { + case 'success': + return { + accent: '#8EBFA3', + tint: 'rgba(98, 145, 118, 0.22)', + }; + case 'warning': + return { + accent: '#D4A574', + tint: 'rgba(140, 106, 62, 0.22)', + }; + case 'error': + return { + accent: '#E8A8AD', + tint: 'rgba(132, 74, 78, 0.26)', + }; + default: + return { + accent: '#8EB0E8', + tint: 'rgba(81, 111, 156, 0.22)', + }; + } +}; + +/** Visible frame + outer ring — especially for success/error in dark and light UI */ +const getNotificationFrameChrome = ( + type: NotificationType, + isDarkMode: boolean +) => { + switch (type) { + case 'success': + return { + borderAlpha: isDarkMode ? 0.52 : 0.5, + glowAlpha: isDarkMode ? 0.2 : 0.22, + leftBarPx: 5, + outerRingAlpha: isDarkMode ? 0.38 : 0.42, + outerRingWidth: 1, + }; + case 'error': + return { + borderAlpha: isDarkMode ? 0.55 : 0.52, + glowAlpha: isDarkMode ? 0.22 : 0.24, + leftBarPx: 5, + outerRingAlpha: isDarkMode ? 0.42 : 0.46, + outerRingWidth: 1, + }; + case 'warning': + return { + borderAlpha: isDarkMode ? 0.46 : 0.48, + glowAlpha: isDarkMode ? 0.16 : 0.18, + leftBarPx: 4, + outerRingAlpha: isDarkMode ? 0.3 : 0.34, + outerRingWidth: 1, + }; + default: + return { + borderAlpha: isDarkMode ? 0.22 : 0.42, + glowAlpha: isDarkMode ? 0.14 : 0.2, + leftBarPx: 4, + outerRingAlpha: isDarkMode ? 0.18 : 0.22, + outerRingWidth: 1, + }; + } +}; + +const QortinoNotificationHead = ({ + accent, + geometry, + isDarkMode, +}: { + accent: string; + geometry: QortinoInletHeadGeometry; + isDarkMode: boolean; +}) => { + const eyeSize = 5; + const eyeOffset = 9; + const eyeGlow = alpha(accent, isDarkMode ? 0.42 : 0.38); + const mouthColor = alpha(accent, isDarkMode ? 0.82 : 0.9); + + return ( + + + + + + + + + + + ); +}; + +export const QortinoNotificationHost = ({ + info, + open, + setInfo, + setOpen, +}: QortinoNotificationHostProps) => { + const theme = useTheme(); + const infoRef = useRef(info); + const timeoutRef = useRef | null>(null); + const inletDebugSettings: QortinoInletDebugSettings = + DEFAULT_QORTINO_INLET_DEBUG_SETTINGS; + + useEffect(() => { + infoRef.current = info; + }, [info]); + + const closeNotification = useCallback(() => { + setOpen(false); + setInfo(null); + }, [setInfo, setOpen]); + + useEffect(() => { + if (!open) { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + return; + } + + const duration = info?.duration; + if (duration === null) return; + + const resolvedDuration = + typeof duration === 'number' + ? duration + : normalizeNotificationType(info?.type) === 'error' + ? ERROR_NOTIFICATION_DURATION_MS + : DEFAULT_NOTIFICATION_DURATION_MS; + + timeoutRef.current = window.setTimeout(() => { + closeNotification(); + }, resolvedDuration); + + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [closeNotification, info?.duration, info?.type, open]); + + useEffect(() => { + const handleOpen = ( + event: CustomEvent<{ + compact?: boolean; + data?: NotificationPayload; + dismissible?: boolean; + duration?: number | null; + message?: string; + sourceId?: string; + type?: string; + }> + ) => { + const payload = event.detail?.data ?? event.detail ?? {}; + const message = normalizeNotificationMessage(payload.message); + + if (!message) return; + + setInfo({ + compact: payload.compact === true, + dismissible: payload.dismissible !== false, + duration: payload.duration === undefined ? undefined : payload.duration, + message, + sourceId: payload.sourceId, + type: normalizeNotificationType(payload.type), + }); + setOpen(true); + }; + + const handleClose = ( + event: CustomEvent<{ + data?: { sourceId?: string }; + sourceId?: string; + }> + ) => { + const payload = event.detail?.data ?? event.detail ?? {}; + const sourceId = payload.sourceId; + + if ( + sourceId && + (infoRef.current?.sourceId == null || + infoRef.current?.sourceId !== sourceId) + ) { + return; + } + + closeNotification(); + }; + + subscribeToEvent('openGlobalSnackBar', handleOpen); + subscribeToEvent('closeGlobalSnackBar', handleClose); + + return () => { + unsubscribeFromEvent('openGlobalSnackBar', handleOpen); + unsubscribeFromEvent('closeGlobalSnackBar', handleClose); + }; + }, [closeNotification, setInfo, setOpen]); + + const message = useMemo( + () => normalizeNotificationMessage(info?.message), + [info?.message] + ); + const notificationType = normalizeNotificationType(info?.type); + const palette = getNotificationPalette(notificationType); + const isDismissible = info?.dismissible !== false; + const isDarkMode = theme.palette.mode === 'dark'; + const frameChrome = useMemo( + () => getNotificationFrameChrome(notificationType, isDarkMode), + [notificationType, isDarkMode] + ); + const inletGeometry = useMemo( + () => getQortinoInletHeadGeometry(inletDebugSettings), + [inletDebugSettings] + ); + const maxTextWidthCh = useMemo(() => { + const ceiling = info?.compact === true ? 26 : 40; + const floor = info?.compact === true ? 13 : 16; + const dynamic = Math.ceil(message.length * 0.74); + return `${Math.min(ceiling, Math.max(floor, dynamic))}ch`; + }, [info?.compact, message.length]); + + if (!open || !message) return null; + + return ( + + + + + + + + + + + + {message} + + {isDismissible ? ( + + + + ) : null} + + + + + + + ); +}; diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx index 91794c3b..066b27dd 100644 --- a/src/components/Snackbar/Snackbar.tsx +++ b/src/components/Snackbar/Snackbar.tsx @@ -1,77 +1,48 @@ -import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; -import Alert from '@mui/material/Alert'; -import { useTheme } from '@mui/material/styles'; - -const snackbarAlertSx = (theme) => ({ - width: '100%', - maxWidth: '420px', - fontFamily: 'Inter', - fontSize: '15px', - fontWeight: 500, - borderRadius: '14px', - boxShadow: theme.shadows[8], - padding: '14px 18px', - alignItems: 'center', - '& .MuiAlert-icon': { - alignItems: 'center', - fontSize: '22px', - }, - '& .MuiAlert-message': { - padding: '0 4px', - lineHeight: 1.4, - }, - '& .MuiAlert-action': { - alignItems: 'center', - paddingLeft: 1, - }, -}); +import { useEffect, useRef } from 'react'; +import { executeEvent } from '../../utils/events'; export const CustomizedSnackbars = ({ open, setOpen, info, setInfo, - duration = 6000, }) => { - const theme = useTheme(); - const handleClose = ( - event?: React.SyntheticEvent | Event, - reason?: SnackbarCloseReason - ) => { - if (reason === 'clickaway') { + const lastSignatureRef = useRef(null); + + useEffect(() => { + if (!open || !info?.message) { + lastSignatureRef.current = null; return; } + + const signature = JSON.stringify({ + compact: info?.compact ?? false, + duration: info?.duration ?? undefined, + message: info?.message, + type: info?.type ?? 'info', + }); + + if (lastSignatureRef.current === signature) return; + lastSignatureRef.current = signature; + + executeEvent('openGlobalSnackBar', { + compact: info?.compact, + duration: info?.duration, + message: info?.message, + type: info?.type, + }); + setOpen(false); setInfo(null); - }; - - if (!open) return null; + }, [ + info?.compact, + info?.duration, + info?.message, + info?.type, + open, + setInfo, + setOpen, + ]); - return ( - - - {info?.message} - - - ); + return null; }; diff --git a/src/components/Snackbar/qortinoInletDebug.ts b/src/components/Snackbar/qortinoInletDebug.ts new file mode 100644 index 00000000..959c8523 --- /dev/null +++ b/src/components/Snackbar/qortinoInletDebug.ts @@ -0,0 +1,19 @@ +export type QortinoInletDebugSettings = { + faceHeightScale: number; + faceWidthScale: number; + headHeightScale: number; + headWidthScale: number; + offsetX: number; + offsetY: number; + shellRoundness: number; +}; + +export const DEFAULT_QORTINO_INLET_DEBUG_SETTINGS: QortinoInletDebugSettings = { + faceHeightScale: 1.3, + faceWidthScale: 1.15, + headHeightScale: 1.1, + headWidthScale: 1.05, + offsetX: -4, + offsetY: -5, + shellRoundness: 1.2, +}; diff --git a/src/components/TaskManager/TaskManager.tsx b/src/components/TaskManager/TaskManager.tsx index 75c779c9..5108338a 100644 --- a/src/components/TaskManager/TaskManager.tsx +++ b/src/components/TaskManager/TaskManager.tsx @@ -1,30 +1,50 @@ import { + Box, List, ListItemButton, - ListItemIcon, ListItemText, - Collapse, IconButton, + Popover, + Tooltip, + Typography, useTheme, + type TooltipProps, } from '@mui/material'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from 'react'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import PendingIcon from '@mui/icons-material/Pending'; import TaskAltIcon from '@mui/icons-material/TaskAlt'; -import ExpandLess from '@mui/icons-material/ExpandLess'; -import ExpandMore from '@mui/icons-material/ExpandMore'; import { getBaseApiReact } from '../../App'; import { executeEvent } from '../../utils/events'; import { useAtom } from 'jotai'; import { memberGroupsAtom, txListAtom } from '../../atoms/global'; import { useTranslation } from 'react-i18next'; import { TIME_MINUTES_1_IN_MILLISECONDS } from '../../constants/constants'; -import { titleBarIconButtonProps } from '../Desktop/CustomTitleBar'; -export const TaskManager = ({ getUserInfo }) => { +export const TaskManager = ({ + getUserInfo, + buttonSx = undefined, + iconSx = undefined, + tooltipSlotProps, + tooltipTitle, +}: { + getUserInfo: (useTimer?: boolean) => Promise; + buttonSx?: any; + iconSx?: any; + tooltipSlotProps?: TooltipProps['slotProps']; + tooltipTitle?: ReactNode; +}) => { const [memberGroups] = useAtom(memberGroupsAtom); const [txList, setTxList] = useAtom(txListAtom); - const [open, setOpen] = useState(false); - const intervals = useRef({}); + const [anchorEl, setAnchorEl] = useState(null); + const intervals = useRef>>({}); const theme = useTheme(); const { t } = useTranslation([ 'auth', @@ -34,11 +54,22 @@ export const TaskManager = ({ getUserInfo }) => { 'tutorial', ]); - const handleClick = () => { - setOpen((prev) => !prev); + const popoverOpen = Boolean(anchorEl); + + const handleIconClick = (event: MouseEvent) => { + setAnchorEl((prev) => + prev === event.currentTarget ? null : event.currentTarget + ); + }; + + const handleClosePopover = () => { + setAnchorEl(null); }; - const getStatus = ({ signature }, callback) => { + const getStatus = ( + { signature }: { signature: string }, + callback?: (ok: boolean) => void + ) => { let stop = false; const getAnswer = async () => { const getTx = async () => { @@ -58,7 +89,7 @@ export const TaskManager = ({ getUserInfo }) => { }, TIME_MINUTES_1_IN_MILLISECONDS) ); setTxList((prev) => { - let previousData = [...prev]; + const previousData = [...prev]; const findTxWithSignature = previousData.findIndex( (tx) => tx.signature === signature ); @@ -88,7 +119,7 @@ export const TaskManager = ({ getUserInfo }) => { useEffect(() => { setTxList((prev) => { - let previousData = [...prev]; + const previousData = [...prev]; memberGroups.forEach((group) => { const findGroup = txList.findIndex( (tx) => tx?.type === 'joined-group' && tx?.groupId === group.groupId @@ -196,86 +227,160 @@ export const TaskManager = ({ getUserInfo }) => { if (txList?.length === 0 || txList.every((item) => item?.done)) return null; + const triggerButton = ( + !item.done) + ? theme.palette.primary.light + : theme.palette.text.secondary, + '&.MuiIconButton-root': { + width: 26, + height: 26, + }, + ...(buttonSx || {}), + }} + > + {txList.some((item) => !item.done) ? ( + + ) : ( + + )} + + ); + return ( <> - {!open && ( - - {txList.some((item) => !item.done) ? ( - - ) : ( - - )} - + {triggerButton} + + ) : ( + triggerButton )} - {open && ( - + - - + + {txList.some((item) => !item.done) ? ( - + ) : ( - + )} - - - + + {t('core:message.generic.ongoing_transactions', { postProcess: 'capitalizeFirstChar', })} - /> - {open ? : } - + + { + event.stopPropagation(); + handleClosePopover(); + }} + > + + + - - - {txList.map((item) => ( - - - - ))} - - - - )} + + {txList.map((item) => ( + + + + ))} + + + ); }; diff --git a/src/components/Theme/ThemeContext.tsx b/src/components/Theme/ThemeContext.tsx index 30440455..f3c5bc63 100644 --- a/src/components/Theme/ThemeContext.tsx +++ b/src/components/Theme/ThemeContext.tsx @@ -14,6 +14,10 @@ import { lightThemeOptions } from '../../styles/theme-light'; import { darkThemeOptions } from '../../styles/theme-dark'; import i18n from '../../i18n/i18n'; +export const ENABLE_CUSTOM_THEMES = false; +const SAVED_UI_THEME_KEY = 'saved_ui_theme'; +const DEFAULT_THEME_ID = 'default'; + const defaultTheme = { id: 'default', name: i18n.t('core:theme.default', { @@ -29,13 +33,13 @@ const ThemeContext = createContext({ userThemes: [defaultTheme], addUserTheme: (themes) => {}, setUserTheme: (theme, themes) => {}, - currentThemeId: 'default', + currentThemeId: DEFAULT_THEME_ID, }); export const ThemeProvider = ({ children }) => { const [themeMode, setThemeMode] = useState('dark'); const [userThemes, setUserThemes] = useState([defaultTheme]); - const [currentThemeId, setCurrentThemeId] = useState('default'); + const [currentThemeId, setCurrentThemeId] = useState(DEFAULT_THEME_ID); const currentTheme = userThemes.find((theme) => theme.id === currentThemeId) || defaultTheme; @@ -44,8 +48,9 @@ export const ThemeProvider = ({ children }) => { const baseThemeOptions = themeMode === 'light' ? lightThemeOptions : darkThemeOptions; + const activeTheme = ENABLE_CUSTOM_THEMES ? currentTheme : defaultTheme; const palette = - themeMode === 'light' ? currentTheme.light : currentTheme.dark; + themeMode === 'light' ? activeTheme.light : activeTheme.dark; return createTheme({ ...baseThemeOptions, @@ -58,8 +63,20 @@ export const ThemeProvider = ({ children }) => { mode = themeMode, themeId = currentThemeId ) => { + if (!ENABLE_CUSTOM_THEMES) { + localStorage.setItem( + SAVED_UI_THEME_KEY, + JSON.stringify({ + mode, + currentThemeId: DEFAULT_THEME_ID, + }) + ); + + return; + } + localStorage.setItem( - 'saved_ui_theme', + SAVED_UI_THEME_KEY, JSON.stringify({ mode, userThemes: themes, @@ -77,11 +94,13 @@ export const ThemeProvider = ({ children }) => { }; const addUserTheme = (themes) => { + if (!ENABLE_CUSTOM_THEMES) return; setUserThemes(themes); saveSettings(themes); }; const setUserTheme = (theme, themes) => { + if (!ENABLE_CUSTOM_THEMES) return; if (theme.id === 'default') { setCurrentThemeId('default'); saveSettings(themes || userThemes, themeMode, 'default'); @@ -92,12 +111,27 @@ export const ThemeProvider = ({ children }) => { }; const loadSettings = useCallback(() => { - const saved = localStorage.getItem('saved_ui_theme'); + const saved = localStorage.getItem(SAVED_UI_THEME_KEY); if (saved) { try { const parsed = JSON.parse(saved); if (parsed.mode === 'light' || parsed.mode === 'dark') setThemeMode(parsed.mode); + if (!ENABLE_CUSTOM_THEMES) { + setUserThemes([defaultTheme]); + setCurrentThemeId(DEFAULT_THEME_ID); + localStorage.setItem( + SAVED_UI_THEME_KEY, + JSON.stringify({ + mode: + parsed.mode === 'light' || parsed.mode === 'dark' + ? parsed.mode + : themeMode, + currentThemeId: DEFAULT_THEME_ID, + }) + ); + return; + } if (Array.isArray(parsed.userThemes)) { const filteredThemes = parsed.userThemes.filter( (theme) => theme.id !== 'default' @@ -107,9 +141,23 @@ export const ThemeProvider = ({ children }) => { if (parsed.currentThemeId) setCurrentThemeId(parsed.currentThemeId); } catch (error) { console.error('Failed to parse saved_ui_theme:', error); + if (!ENABLE_CUSTOM_THEMES) { + setUserThemes([defaultTheme]); + setCurrentThemeId(DEFAULT_THEME_ID); + localStorage.setItem( + SAVED_UI_THEME_KEY, + JSON.stringify({ + mode: themeMode, + currentThemeId: DEFAULT_THEME_ID, + }) + ); + } } + } else if (!ENABLE_CUSTOM_THEMES) { + setUserThemes([defaultTheme]); + setCurrentThemeId(DEFAULT_THEME_ID); } - }, []); + }, [themeMode]); useEffect(() => { loadSettings(); diff --git a/src/components/Theme/ThemeManager.tsx b/src/components/Theme/ThemeManager.tsx index 47e13d3a..edcae291 100644 --- a/src/components/Theme/ThemeManager.tsx +++ b/src/components/Theme/ThemeManager.tsx @@ -22,7 +22,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import AddIcon from '@mui/icons-material/Add'; import CheckIcon from '@mui/icons-material/Check'; -import { useThemeContext } from './ThemeContext'; +import { ENABLE_CUSTOM_THEMES, useThemeContext } from './ThemeContext'; import { darkThemeOptions } from '../../styles/theme-dark'; import { lightThemeOptions } from '../../styles/theme-light'; import ShortUniqueId from 'short-unique-id'; @@ -98,6 +98,19 @@ export default function ThemeManager() { } }, [openEditor]); + if (!ENABLE_CUSTOM_THEMES) { + return ( + + + {t('core:theme.manager', { postProcess: 'capitalizeFirstChar' })} + + + Custom themes are currently disabled. + + + ); + } + const handleAddTheme = () => { setThemeDraft({ id: '', diff --git a/src/components/Theme/ThemeSelector.tsx b/src/components/Theme/ThemeSelector.tsx index d7b7c396..06757f82 100644 --- a/src/components/Theme/ThemeSelector.tsx +++ b/src/components/Theme/ThemeSelector.tsx @@ -1,11 +1,25 @@ import { useThemeContext } from './ThemeContext'; -import { Box, IconButton, Tooltip, useTheme } from '@mui/material'; +import { + Box, + ButtonBase, + IconButton, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; import LightModeIcon from '@mui/icons-material/LightMode'; import DarkModeIcon from '@mui/icons-material/DarkMode'; import { useTranslation } from 'react-i18next'; import { useRef } from 'react'; -const ThemeSelector = () => { +type ThemeSelectorProps = { + sidebar?: boolean; + /** Compact icon for footers (e.g. login screen chrome next to language). */ + footer?: boolean; +}; + +const ThemeSelector = ({ sidebar = false, footer = false }: ThemeSelectorProps) => { const { t } = useTranslation([ 'auth', 'core', @@ -16,6 +30,108 @@ const ThemeSelector = () => { const { themeMode, toggleTheme } = useThemeContext(); const selectorRef = useRef(null); const theme = useTheme(); + const switchThemeLabel = themeMode === 'dark' ? 'Light' : 'Dark'; + const sidebarButtonSx = { + alignItems: 'center', + borderRadius: '14px', + color: theme.palette.text.secondary, + display: 'flex', + flexDirection: 'column', + gap: 1, + justifyContent: 'flex-start', + minHeight: 58, + py: 1, + transition: 'background-color 180ms ease, color 180ms ease, box-shadow 140ms ease', + width: 56, + '& .sidebarSelectorIconWrap': { + transition: 'transform 150ms ease, color 180ms ease', + }, + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.18)}, inset 0 1px 0 ${alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.03 : 0.12 + )}`, + '& .sidebarSelectorIconWrap': { + transform: 'translateY(-1px)', + }, + }, + '&:focus-visible': { + backgroundColor: alpha( + theme.palette.action.hover, + theme.palette.mode === 'dark' ? 0.72 : 0.82 + ), + boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.border.main, 0.22)}, inset 0 1px 0 ${alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.03 : 0.12 + )}`, + color: theme.palette.text.primary, + '& .sidebarSelectorIconWrap': { + transform: 'translateY(-1px)', + }, + }, + } as const; + + if (footer) { + const ariaLabel = + themeMode === 'dark' + ? t('core:aria.switch_theme_light') + : t('core:aria.switch_theme_dark'); + return ( + + + + {themeMode === 'dark' ? ( + + ) : ( + + )} + + + + ); + } + + if (sidebar) { + return ( + + + + {themeMode === 'dark' ? ( + + ) : ( + + )} + + + {switchThemeLabel} + + + + ); + } return ( diff --git a/src/components/UserLookup.tsx/UserLookup.tsx b/src/components/UserLookup.tsx/UserLookup.tsx index bbf9bcf3..b79d9a51 100644 --- a/src/components/UserLookup.tsx/UserLookup.tsx +++ b/src/components/UserLookup.tsx/UserLookup.tsx @@ -1,73 +1,338 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Autocomplete, Avatar, Box, Button, ButtonBase, - Card, - Divider, + Chip, + CircularProgress, + Dialog, IconButton, LinearProgress, + Table, TableBody, TableCell, + TableContainer, TableHead, + TablePagination, TableRow, TextField, Tooltip, Typography, - Table, - TablePagination, - CircularProgress, useTheme, - alpha, - Autocomplete, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import AccountCircleRoundedIcon from '@mui/icons-material/AccountCircleRounded'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded'; +import NorthEastRoundedIcon from '@mui/icons-material/NorthEastRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; +import PersonOffRoundedIcon from '@mui/icons-material/PersonOffRounded'; +import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; +import ShieldRoundedIcon from '@mui/icons-material/ShieldRounded'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { + infoSnackGlobalAtom, + isRunningPublicNodeAtom, + openSnackGlobalAtom, + userInfoAtom, +} from '../../atoms/global'; import { getAddressInfo, getNameOrAddress, } from '../../background/background.ts'; import { getBaseApiReact } from '../../App'; import { getNameInfo } from '../Group/groupApi'; -import AccountCircleIcon from '@mui/icons-material/AccountCircle'; -import { Spacer } from '../../common/Spacer'; -import { formatTimestamp } from '../../utils/time'; -import CloseIcon from '@mui/icons-material/Close'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { - executeEvent, - subscribeToEvent, - unsubscribeFromEvent, -} from '../../utils/events'; -import { useNameSearch } from '../../hooks/useNameSearch'; -import { useTranslation } from 'react-i18next'; -import { validateAddress } from '../../utils/validateAddress.ts'; -import { appHeighOffsetPx } from '../Desktop/CustomTitleBar'; import { accountTargetBlocks, levelUpBlocks, levelUpDays, nextLevel, } from '../Minting/MintingStats.tsx'; +import { useBlockedAddresses } from '../../hooks/useBlockUsers'; +import { useNameSearch } from '../../hooks/useNameSearch'; +import { validateAddress } from '../../utils/validateAddress.ts'; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from '../../utils/events'; +import { formatTimestamp } from '../../utils/time'; +import magnifierSvg from '../../assets/user-search/magnifier.svg?raw'; +import qortalLogo512 from '../../assets/user-search/qortal-logo-512.png'; + +type UserLookupProps = { + isOpenDrawerLookup: boolean; + setIsOpenDrawerLookup: (open: boolean) => void; +}; + +type LookupHistoryState = { + history: string[]; + index: number; +}; -function formatAddress(str: string) { - if (!str || str.length <= 12) return str || ''; - const first6 = str.slice(0, 6); - const last6 = str.slice(-6); - return `${first6}....${last6}`; +type AddressNameEntry = { + loading: boolean; + name: string | null; +}; + +type AddressInfoResult = { + address: string; + balance?: number | string; + blocksMinted?: number; + blocksMintedAdjustment?: number; + level?: number; + name?: string; + publicKey?: string; +}; + +type UserSearchIllustrationConfig = { + magnifierX: number; + magnifierY: number; + lensOffsetX: number; + lensOffsetY: number; + lensAngleOffset: number; +}; + +const defaultUserSearchIllustrationConfig: UserSearchIllustrationConfig = + { + magnifierX: -9, + magnifierY: -28, + lensOffsetX: 5, + lensOffsetY: -23, + lensAngleOffset: 18, + }; + +function formatAddress(value: string) { + if (!value || value.length <= 12) return value || ''; + return `${value.slice(0, 6)}....${value.slice(-6)}`; } function formatBalance(value: number | string | undefined): string { if (value == null || value === '') return '0'; - const n = typeof value === 'string' ? parseFloat(value) : value; - if (Number.isNaN(n)) return '0'; - return n.toLocaleString('en-US', { - minimumFractionDigits: 2, + const numericValue = + typeof value === 'string' ? parseFloat(value) : Number(value); + if (Number.isNaN(numericValue)) return '0'; + return numericValue.toLocaleString('en-US', { maximumFractionDigits: 4, + minimumFractionDigits: 2, + }); +} + +function formatStatBalance(value: number | string | undefined): string { + if (value == null || value === '') return '0'; + const numericValue = + typeof value === 'string' ? parseFloat(value) : Number(value); + if (Number.isNaN(numericValue)) return '0'; + return numericValue.toLocaleString(undefined, { + maximumFractionDigits: 0, }); } -export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { +function UserSearchIllustration({ + glowColor, + logoSrc, + magnifierMarkup, +}: { + glowColor: string; + logoSrc: string; + magnifierMarkup: string; +}) { + const illustrationConfig = defaultUserSearchIllustrationConfig; + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +export const UserLookup = ({ + isOpenDrawerLookup, + setIsOpenDrawerLookup, +}: UserLookupProps) => { const theme = useTheme(); const { t } = useTranslation([ 'auth', @@ -76,58 +341,115 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { 'question', 'tutorial', ]); + const currentUser = useAtomValue(userInfoAtom); + const isRunningPublicNode = useAtomValue(isRunningPublicNodeAtom); + const setInfoSnack = useSetAtom(infoSnackGlobalAtom); + const setOpenSnack = useSetAtom(openSnackGlobalAtom); + const { addToBlockList, isUserBlocked, removeBlockFromList } = + useBlockedAddresses(true); + const [nameOrAddress, setNameOrAddress] = useState(''); const [inputValue, setInputValue] = useState(''); const { results, isLoading } = useNameSearch(inputValue); - const options = useMemo(() => { - const isAddress = validateAddress(inputValue); - if (isAddress) return [inputValue]; - return results?.map((item) => item.name); - }, [results, inputValue]); const [errorMessage, setErrorMessage] = useState(''); - const [addressInfo, setAddressInfo] = useState(null); + const [addressInfo, setAddressInfo] = useState(null); const [nodeStatus, setNodeStatus] = useState(null); const [adminInfo, setAdminInfo] = useState(null); const [nodeHeightBlock, setNodeHeightBlock] = useState(null); const [isLoadingUser, setIsLoadingUser] = useState(false); const [isLoadingPayments, setIsLoadingPayments] = useState(false); const [payments, setPayments] = useState([]); - const [totalPaymentsCount, setTotalPaymentsCount] = useState(0); + const [totalPaymentsCount, setTotalPaymentsCount] = useState(0); const [paymentsPage, setPaymentsPage] = useState(0); const [paymentsRowsPerPage, setPaymentsRowsPerPage] = useState(5); const [addressNamesMap, setAddressNamesMap] = useState< - Record + Record >({}); - const [lookupHistory, setLookupHistory] = useState<{ - history: string[]; - index: number; - }>({ history: [], index: -1 }); - - const tRef = useRef(t); - tRef.current = t; - const lookupInProgressRef = useRef(false); - const lastFetchedOwnerRef = useRef(null); - const hoverNameTimeoutRef = useRef | null>( - null + const [lookupHistory, setLookupHistory] = useState({ + history: [], + index: -1, + }); + const [isBlockActionPending, setIsBlockActionPending] = useState(false); + + const tRef = useRef(t); + tRef.current = t; + const lookupInProgressRef = useRef(false); + const lastFetchedOwnerRef = useRef(null); + + const currentUserName = + typeof currentUser?.name === 'string' && currentUser.name.trim().length > 0 + ? currentUser.name.trim() + : ''; + const currentUserAddress = + typeof currentUser?.address === 'string' ? currentUser.address : ''; + const targetUserName = + typeof addressInfo?.name === 'string' ? addressInfo.name.trim() : ''; + const isCurrentUserProfile = + !!addressInfo?.address && + (addressInfo.address === currentUserAddress || + (targetUserName && targetUserName === currentUserName)); + const isBlocked = + !!addressInfo?.address && isUserBlocked(addressInfo.address); + + const lookupOptions = useMemo(() => { + if (!inputValue.trim()) { + return results?.map((item) => item.name) ?? []; + } + + if (validateAddress(inputValue)) { + return [inputValue]; + } + + return results?.map((item) => item.name) ?? []; + }, [inputValue, results]); + + const pushSnack = useCallback( + (type: 'error' | 'info' | 'success', message: string) => { + setInfoSnack({ compact: true, duration: 3200, message, type }); + setOpenSnack(true); + }, + [setInfoSnack, setOpenSnack] ); - const HOVER_DELAY_MS = 750; + const resetLookupState = useCallback(() => { + setNameOrAddress(''); + setInputValue(''); + setErrorMessage(''); + setPayments([]); + setTotalPaymentsCount(0); + setNodeStatus(null); + setAdminInfo(null); + setNodeHeightBlock(null); + setPaymentsPage(0); + setIsLoadingUser(false); + setIsLoadingPayments(false); + setAddressInfo(null); + setAddressNamesMap({}); + setLookupHistory({ history: [], index: -1 }); + setIsBlockActionPending(false); + lastFetchedOwnerRef.current = null; + }, []); + + const closeLookup = useCallback(() => { + setIsOpenDrawerLookup(false); + resetLookupState(); + }, [resetLookupState, setIsOpenDrawerLookup]); const lookupFunc = useCallback( async ( - messageAddressOrName: string, + requestedAddressOrName: string, options?: { skipHistoryPush?: boolean } ) => { - const inputAddressOrName = ( - messageAddressOrName ?? nameOrAddress - )?.trim(); - if (!inputAddressOrName) { + const lookupInput = requestedAddressOrName.trim(); + if (!lookupInput || lookupInProgressRef.current) { return; } - if (lookupInProgressRef.current) return; + lookupInProgressRef.current = true; + try { - const owner = await getNameOrAddress(inputAddressOrName); + const owner = await getNameOrAddress(lookupInput); + if (!owner) { throw new Error( tRef.current('auth:message.error.name_not_existing', { @@ -135,17 +457,17 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { }) ); } - if ( - !options?.skipHistoryPush && - lastFetchedOwnerRef.current === owner - ) { + + if (!options?.skipHistoryPush && lastFetchedOwnerRef.current === owner) { lookupInProgressRef.current = false; return; } + lastFetchedOwnerRef.current = owner; setErrorMessage(''); setIsLoadingUser(true); + setIsLoadingPayments(true); setPayments([]); setTotalPaymentsCount(0); setAddressInfo(null); @@ -154,139 +476,165 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { setNodeHeightBlock(null); setPaymentsPage(0); - const addressInfoRes = await getAddressInfo(owner); - if (!addressInfoRes?.publicKey) { + const addressInfoResponse = await getAddressInfo(owner); + + if (!addressInfoResponse?.publicKey) { throw new Error( tRef.current('auth:message.error.address_not_existing', { postProcess: 'capitalizeFirstChar', }) ); } - const isAddress = validateAddress(messageAddressOrName); - const name = !isAddress - ? messageAddressOrName + + const isAddressSearch = validateAddress(lookupInput); + const registeredName = !isAddressSearch + ? lookupInput : await getNameInfo(owner); const baseUrl = getBaseApiReact(); - const balanceRes = await fetch(`${baseUrl}/addresses/balance/${owner}`); - const balanceData = await balanceRes.json(); + + const balanceResponse = await fetch(`${baseUrl}/addresses/balance/${owner}`); + const balance = await balanceResponse.json(); + setAddressInfo({ - ...addressInfoRes, - balance: balanceData, - name, + ...addressInfoResponse, + address: owner, + balance, + name: registeredName, }); - setIsLoadingUser(false); if (!options?.skipHistoryPush) { - setLookupHistory((prev) => { - const truncated = prev.history.slice(0, prev.index + 1); - truncated.push(owner); - const maxLen = 50; - const history = - truncated.length > maxLen ? truncated.slice(-maxLen) : truncated; - return { history, index: history.length - 1 }; + setLookupHistory((previous) => { + const nextHistory = previous.history.slice(0, previous.index + 1); + nextHistory.push(owner); + const boundedHistory = + nextHistory.length > 50 ? nextHistory.slice(-50) : nextHistory; + return { + history: boundedHistory, + index: boundedHistory.length - 1, + }; }); } - // Node status for level progress (remaining blocks / days) try { - const statusRes = await fetch(`${baseUrl}/admin/status`); - if (statusRes.ok) { - const statusData = await statusRes.json(); - setNodeStatus(statusData); - if (statusData?.height != null) { - const blockHeight = statusData.height - 1440; - const blockRes = await fetch( + const statusResponse = await fetch(`${baseUrl}/admin/status`); + if (statusResponse.ok) { + const nextNodeStatus = await statusResponse.json(); + setNodeStatus(nextNodeStatus); + + if (nextNodeStatus?.height != null) { + const blockHeight = nextNodeStatus.height - 1440; + const blockResponse = await fetch( `${baseUrl}/blocks/byheight/${blockHeight}` ); - if (blockRes.ok) { - setNodeHeightBlock(await blockRes.json()); + if (blockResponse.ok) { + setNodeHeightBlock(await blockResponse.json()); } - const adminRes = await fetch(`${baseUrl}/admin/info`); - if (adminRes.ok) { - setAdminInfo(await adminRes.json()); + + const adminResponse = await fetch(`${baseUrl}/admin/info`); + if (adminResponse.ok) { + setAdminInfo(await adminResponse.json()); } } } - } catch (_) { + } catch { // non-fatal } - setIsLoadingPayments(true); - try { - const paymentsRes = await fetch( - `${baseUrl}/transactions/search?txType=PAYMENT&address=${owner}&confirmationStatus=CONFIRMED&limit=500&reverse=true` - ); - const paymentsData = await paymentsRes.json(); - const list = Array.isArray(paymentsData) ? paymentsData : []; - setPayments(list); - setTotalPaymentsCount(list.length); - } finally { - setIsLoadingPayments(false); - } - } catch (error) { - setErrorMessage(error?.message); - console.error(error); + const paymentsResponse = await fetch( + `${baseUrl}/transactions/search?txType=PAYMENT&address=${owner}&confirmationStatus=CONFIRMED&limit=500&reverse=true` + ); + const paymentsData = await paymentsResponse.json(); + const nextPayments = Array.isArray(paymentsData) ? paymentsData : []; + setPayments(nextPayments); + setTotalPaymentsCount(nextPayments.length); + } catch (error: any) { + setErrorMessage( + error?.message || + tRef.current('core:account_lookup.error_lookup_failed', { + postProcess: 'capitalizeFirstChar', + }), + ); } finally { + lookupInProgressRef.current = false; setIsLoadingUser(false); setIsLoadingPayments(false); - lookupInProgressRef.current = false; } }, - [nameOrAddress] + [] ); const fetchNameForAddress = useCallback((address: string) => { if (!address) return; - setAddressNamesMap((prev) => { - const existing = prev[address]; - if (existing) return prev; - return { ...prev, [address]: { name: null, loading: true } }; + + setAddressNamesMap((previous) => { + const existing = previous[address]; + if (existing) { + return previous; + } + return { + ...previous, + [address]: { loading: true, name: null }, + }; }); + getNameInfo(address) .then((name) => { - setAddressNamesMap((prev) => ({ - ...prev, - [address]: { name: name || null, loading: false }, + setAddressNamesMap((previous) => ({ + ...previous, + [address]: { loading: false, name: name || null }, })); }) .catch(() => { - setAddressNamesMap((prev) => ({ - ...prev, - [address]: { name: null, loading: false }, + setAddressNamesMap((previous) => ({ + ...previous, + [address]: { loading: false, name: null }, })); }); }, []); - const scheduleFetchNameForAddress = useCallback( - (address: string) => { - if (hoverNameTimeoutRef.current) { - clearTimeout(hoverNameTimeoutRef.current); - hoverNameTimeoutRef.current = null; + useEffect(() => { + const addressesToResolve = new Set(); + const currentProfileAddress = addressInfo?.address; + + for (const payment of payments.slice( + paymentsPage * paymentsRowsPerPage, + paymentsPage * paymentsRowsPerPage + paymentsRowsPerPage + )) { + if ( + payment?.creatorAddress && + payment.creatorAddress !== currentProfileAddress && + !addressNamesMap[payment.creatorAddress] + ) { + addressesToResolve.add(payment.creatorAddress); } - if (!address) return; - hoverNameTimeoutRef.current = setTimeout(() => { - fetchNameForAddress(address); - hoverNameTimeoutRef.current = null; - }, HOVER_DELAY_MS); - }, - [fetchNameForAddress] - ); - const cancelFetchNameForAddress = useCallback(() => { - if (hoverNameTimeoutRef.current) { - clearTimeout(hoverNameTimeoutRef.current); - hoverNameTimeoutRef.current = null; + if ( + payment?.recipient && + payment.recipient !== currentProfileAddress && + !addressNamesMap[payment.recipient] + ) { + addressesToResolve.add(payment.recipient); + } } - }, []); + + addressesToResolve.forEach(fetchNameForAddress); + }, [ + addressInfo?.address, + addressNamesMap, + fetchNameForAddress, + payments, + paymentsPage, + paymentsRowsPerPage, + ]); const openUserLookupDrawerFunc = useCallback( - (e) => { + (event: CustomEvent) => { setIsOpenDrawerLookup(true); - const message = e.detail?.addressOrName; - if (message) { - setNameOrAddress(message); - setInputValue(message); - lookupFunc(message); + const requestedAddressOrName = event.detail?.addressOrName; + if (requestedAddressOrName) { + setNameOrAddress(requestedAddressOrName); + setInputValue(requestedAddressOrName); + void lookupFunc(requestedAddressOrName); } }, [lookupFunc, setIsOpenDrawerLookup] @@ -301,54 +649,118 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { }, [openUserLookupDrawerFunc]); const goBack = useCallback(() => { - const { history, index } = lookupHistory; - if (index <= 0) return; - const prevAddress = history[index - 1]; + const previousIndex = lookupHistory.index - 1; + if (previousIndex < 0) { + return; + } + + const previousAddress = lookupHistory.history[previousIndex]; lastFetchedOwnerRef.current = null; - setNameOrAddress(prevAddress); - setInputValue(prevAddress); - setLookupHistory((prev) => ({ ...prev, index: prev.index - 1 })); - lookupFunc(prevAddress, { skipHistoryPush: true }); - }, [lookupHistory, lookupFunc]); + setNameOrAddress(previousAddress); + setInputValue(previousAddress); + setLookupHistory((previous) => ({ ...previous, index: previousIndex })); + void lookupFunc(previousAddress, { skipHistoryPush: true }); + }, [lookupFunc, lookupHistory.history, lookupHistory.index]); const goForward = useCallback(() => { - const { history, index } = lookupHistory; - if (index >= history.length - 1 || index < 0) return; - const nextAddress = history[index + 1]; + const nextIndex = lookupHistory.index + 1; + if (nextIndex >= lookupHistory.history.length) { + return; + } + + const nextAddress = lookupHistory.history[nextIndex]; lastFetchedOwnerRef.current = null; setNameOrAddress(nextAddress); setInputValue(nextAddress); - setLookupHistory((prev) => ({ ...prev, index: prev.index + 1 })); - lookupFunc(nextAddress, { skipHistoryPush: true }); - }, [lookupHistory, lookupFunc]); + setLookupHistory((previous) => ({ ...previous, index: nextIndex })); + void lookupFunc(nextAddress, { skipHistoryPush: true }); + }, [lookupFunc, lookupHistory.history, lookupHistory.index]); - const canGoBack = lookupHistory.history.length > 0 && lookupHistory.index > 0; - const canGoForward = - lookupHistory.history.length > 0 && - lookupHistory.index >= 0 && - lookupHistory.index < lookupHistory.history.length - 1; + const handleSearchSubmit = useCallback(() => { + if (!inputValue.trim()) { + return; + } - const onClose = () => { - setIsOpenDrawerLookup(false); - setNameOrAddress(''); - setInputValue(''); - setErrorMessage(''); - setPayments([]); - setTotalPaymentsCount(0); - setNodeStatus(null); - setAdminInfo(null); - setNodeHeightBlock(null); - setPaymentsPage(0); - setIsLoadingUser(false); - setIsLoadingPayments(false); - setAddressInfo(null); - lastFetchedOwnerRef.current = null; - setLookupHistory({ history: [], index: -1 }); - }; + setNameOrAddress(inputValue.trim()); + void lookupFunc(inputValue.trim()); + }, [inputValue, lookupFunc]); + + const handleCopyAddress = useCallback(() => { + if (!addressInfo?.address) { + return; + } + + navigator.clipboard.writeText(addressInfo.address); + pushSnack( + 'success', + t('tutorial:home.address_copied', { + postProcess: 'capitalizeFirstChar', + }), + ); + }, [addressInfo?.address, pushSnack, t]); + + const handleToggleBlock = useCallback(async () => { + if (!addressInfo?.address || isCurrentUserProfile || isRunningPublicNode) { + return; + } + + setIsBlockActionPending(true); + + try { + if (isBlocked) { + await removeBlockFromList(addressInfo.address, targetUserName); + pushSnack( + 'success', + t('auth:action.unblock_name', { + postProcess: 'capitalizeFirstChar', + }) + ); + } else { + await addToBlockList(addressInfo.address, targetUserName); + pushSnack( + 'success', + t('auth:action.block_name', { + postProcess: 'capitalizeFirstChar', + }) + ); + } + } catch (error: any) { + pushSnack( + 'error', + error?.message || + t('auth:message.error.block_user', { + postProcess: 'capitalizeFirstChar', + }) + ); + } finally { + setIsBlockActionPending(false); + } + }, [ + addToBlockList, + addressInfo?.address, + isBlocked, + isCurrentUserProfile, + isRunningPublicNode, + pushSnack, + removeBlockFromList, + t, + targetUserName, + ]); + + const handleSendQort = useCallback(() => { + if (!addressInfo?.address) { + return; + } + + executeEvent('openPaymentInternal', { + address: addressInfo.address, + name: addressInfo.name, + }); + closeLookup(); + }, [addressInfo?.address, addressInfo?.name, closeLookup]); const currentBlocks = - (addressInfo?.blocksMinted ?? 0) + - (addressInfo?.blocksMintedAdjustment ?? 0); + (addressInfo?.blocksMinted ?? 0) + (addressInfo?.blocksMintedAdjustment ?? 0); const targetBlocks = addressInfo?.level != null ? accountTargetBlocks(addressInfo.level) @@ -367,971 +779,1299 @@ export const UserLookup = ({ isOpenDrawerLookup, setIsOpenDrawerLookup }) => { addressInfo && nodeStatus && adminInfo && nodeHeightBlock ? levelUpDays(addressInfo, adminInfo, nodeHeightBlock, nodeStatus) : undefined; - const nextLevelNum = + const nextLevelNumber = addressInfo?.level != null ? nextLevel(addressInfo.level) : undefined; - const paginatedPayments = useMemo(() => { - const start = paymentsPage * paymentsRowsPerPage; - return payments.slice(start, start + paymentsRowsPerPage); - }, [payments, paymentsPage, paymentsRowsPerPage]); - const { totalReceived, totalSent } = useMemo(() => { - const owner = addressInfo?.address; - if (!owner || !payments.length) { - return { totalReceived: undefined, totalSent: undefined }; + const ownerAddress = addressInfo?.address; + + if (!ownerAddress || payments.length === 0) { + return { + totalReceived: undefined, + totalSent: undefined, + }; } + let received = 0; let sent = 0; - for (const tx of payments) { - const amount = parseFloat(tx?.amount ?? '0') || 0; - if (tx?.recipient === owner) received += amount; - if (tx?.creatorAddress === owner) sent += amount; + + for (const payment of payments) { + const amount = parseFloat(payment?.amount ?? '0') || 0; + if (payment?.recipient === ownerAddress) { + received += amount; + } + if (payment?.creatorAddress === ownerAddress) { + sent += amount; + } } - return { totalReceived: received, totalSent: sent }; + + return { + totalReceived: received, + totalSent: sent, + }; }, [addressInfo?.address, payments]); - if (!isOpenDrawerLookup) return null; + const paginatedPayments = useMemo(() => { + const startIndex = paymentsPage * paymentsRowsPerPage; + return payments.slice(startIndex, startIndex + paymentsRowsPerPage); + }, [payments, paymentsPage, paymentsRowsPerPage]); + + const canGoBack = lookupHistory.index > 0; + const canGoForward = + lookupHistory.index >= 0 && lookupHistory.index < lookupHistory.history.length - 1; + const isEmptyLookupState = !errorMessage && !isLoadingUser && !addressInfo; + + const surfaceBorder = alpha(theme.palette.divider, 0.42); + const dividerColor = alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.04 : 0.07 + ); + const summarySurface = theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(23,27,35,0.98) 0%, rgba(17,20,27,0.985) 100%)' + : 'linear-gradient(180deg, rgba(248,250,253,0.985) 0%, rgba(241,245,250,0.99) 100%)'; + const sectionSurface = alpha( + theme.palette.mode === 'dark' + ? theme.palette.common.white + : theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.026 : 0.036 + ); + const softSectionSurface = alpha( + theme.palette.mode === 'dark' + ? theme.palette.common.white + : theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.018 : 0.028 + ); + const sectionLabelSx = { + color: alpha(theme.palette.text.secondary, 0.64), + display: 'block', + fontSize: '0.68rem', + fontWeight: 700, + letterSpacing: '0.11em', + lineHeight: 1.1, + textTransform: 'uppercase', + } as const; + const helperNoteSx = { + color: alpha(theme.palette.text.secondary, 0.8), + fontSize: '0.84rem', + lineHeight: 1.55, + } as const; + const silkyActionBackground = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(144,184,244,0.985) 0%, rgba(118,160,227,0.985) 100%)' + : 'linear-gradient(180deg, rgba(125,168,235,0.985) 0%, rgba(98,145,220,0.985) 100%)'; + const silkyDangerBackground = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(244,143,143,0.985) 0%, rgba(221,92,92,0.985) 100%)' + : 'linear-gradient(180deg, rgba(230,120,120,0.985) 0%, rgba(208,82,82,0.985) 100%)'; + const neutralActionBackground = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(76,84,100,0.96) 0%, rgba(58,64,77,0.96) 100%)' + : 'linear-gradient(180deg, rgba(197,204,216,0.98) 0%, rgba(176,184,198,0.98) 100%)'; + + const createSilkyActionButtonSx = ( + background: string, + textColor: string, + shadow: string, + disabledBackground = neutralActionBackground, + disabledTextColor = alpha( + theme.palette.mode === 'dark' ? '#E7ECF7' : '#243247', + 0.7 + ) + ) => ({ + background, + border: `1px solid ${alpha(theme.palette.common.white, 0.18)}`, + borderRadius: '10px', + boxShadow: shadow, + color: textColor, + fontSize: '0.78rem', + fontWeight: 700, + gap: 0.7, + justifyContent: 'center', + letterSpacing: '0.01em', + minHeight: 40, + px: 1.35, + textAlign: 'center', + textTransform: 'none', + whiteSpace: 'nowrap', + '& .MuiButton-startIcon': { + marginLeft: 0, + marginRight: 0, + }, + '& .MuiSvgIcon-root': { + fontSize: '1rem', + }, + '&.Mui-disabled': { + background: disabledBackground, + borderColor: alpha(theme.palette.common.white, 0.12), + color: disabledTextColor, + boxShadow: + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.08), 0 6px 18px rgba(18,22,31,0.16)' + : 'inset 0 1px 0 rgba(255,255,255,0.2), 0 6px 18px rgba(117,127,144,0.12)', + opacity: 1, + }, + }); + + const sendActionButtonSx = createSilkyActionButtonSx( + silkyActionBackground, + theme.palette.mode === 'dark' ? '#0F1725' : '#F8FBFF', + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.16), 0 10px 24px rgba(59,98,168,0.18)' + : 'inset 0 1px 0 rgba(255,255,255,0.3), 0 10px 22px rgba(82,126,201,0.16)', + neutralActionBackground + ); + + const blockActionButtonSx = createSilkyActionButtonSx( + silkyDangerBackground, + '#FFF6F6', + theme.palette.mode === 'dark' + ? 'inset 0 1px 0 rgba(255,255,255,0.14), 0 10px 24px rgba(135,40,40,0.22)' + : 'inset 0 1px 0 rgba(255,255,255,0.28), 0 10px 22px rgba(170,56,56,0.18)', + neutralActionBackground, + alpha(theme.palette.mode === 'dark' ? '#D9DFEA' : '#314055', 0.78) + ); + + const displayStatRows = [ + { + label: t('core:balance', { postProcess: 'capitalizeFirstChar' }), + value: `${formatStatBalance(addressInfo?.balance)} QORT`, + }, + { + label: t('core:total_received', { postProcess: 'capitalizeFirstChar' }), + value: + totalReceived != null + ? `${formatStatBalance(totalReceived)} QORT` + : '\u2014', + }, + { + label: t('core:total_sent', { postProcess: 'capitalizeFirstChar' }), + value: + totalSent != null + ? `${formatStatBalance(totalSent)} QORT` + : '\u2014', + }, + { + label: t('core:total_blocks_minted', { + postProcess: 'capitalizeFirstChar', + }), + value: currentBlocks.toLocaleString(), + }, + ]; + + const renderActionButton = ( + button: React.ReactNode, + tooltip?: string, + disabled?: boolean + ) => { + if (!tooltip) { + return button; + } + + return ( + + + {button} + + + ); + }; + + const profileActionButtons = ( + <> + {renderActionButton( + , + isCurrentUserProfile + ? t('core:account_lookup.tooltip_send_disabled') + : undefined + )} + + {renderActionButton( + , + isRunningPublicNode + ? t('core:account_lookup.tooltip_block_public_node') + : isCurrentUserProfile + ? t('core:account_lookup.tooltip_block_self') + : undefined, + isRunningPublicNode || isCurrentUserProfile + )} + + ); return ( - - + - + - + - - {t('core:user_lookup', { - postProcess: 'capitalizeFirstChar', - })} - - - - - - - + - {t('auth:address_name', { + {t('core:account_lookup.dialog_title', { postProcess: 'capitalizeFirstChar', })} - { - if (!newValue) { - setNameOrAddress(''); - return; - } - if ( - addressInfo && - (addressInfo.address === newValue || - addressInfo.name === newValue) - ) { - setNameOrAddress(newValue); - return; - } - setNameOrAddress(newValue); - lookupFunc(newValue); - }} - inputValue={inputValue} - onInputChange={(event, newInputValue) => { - setInputValue(newInputValue); + + + + ( - { - if (e.key === 'Enter' && inputValue) { - lookupFunc(inputValue); - } - }} - variant="outlined" - size="small" - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 2, - backgroundColor: theme.palette.background.default, - }, - }} - /> - )} - /> + > + + + - - {!isLoadingUser && errorMessage && ( - + { + const nextValue = typeof newValue === 'string' ? newValue.trim() : ''; + if (!nextValue) { + setNameOrAddress(''); + return; + } + + setNameOrAddress(nextValue); + setInputValue(nextValue); + void lookupFunc(nextValue); + }} + inputValue={inputValue} + onInputChange={(_event, newInputValue) => { + setInputValue(newInputValue); + }} + loading={isLoading} + noOptionsText={t('core:option_no', { + postProcess: 'capitalizeFirstChar', + })} + options={lookupOptions} + renderInput={(params) => ( + { + if (event.key === 'Enter') { + event.preventDefault(); + handleSearchSubmit(); + } + }} + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {isLoading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + size="small" sx={{ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - minHeight: 120, - width: '100%', - p: 2, - borderRadius: 2, - bgcolor: alpha(theme.palette.error.main, 0.08), - border: 1, - borderColor: alpha(theme.palette.error.main, 0.2), + '& .MuiOutlinedInput-root': { + backgroundColor: alpha( + theme.palette.mode === 'dark' + ? theme.palette.common.white + : theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.03 : 0.028 + ), + borderRadius: '12px', + boxShadow: 'none', + height: 44, + pr: 0.75, + transition: + 'background-color 180ms ease, border-color 180ms ease, box-shadow 200ms ease', + '& fieldset': { + borderColor: alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.14 : 0.18 + ), + borderWidth: '0.75px', + }, + '&:hover fieldset': { + borderColor: alpha( + theme.palette.common.white, + theme.palette.mode === 'dark' ? 0.18 : 0.22 + ), + }, + '&.Mui-focused': { + boxShadow: `inset 0 0 0 1px ${alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.18 : 0.14 + )}`, + }, + '&.Mui-focused fieldset': { + borderColor: alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.2 : 0.16 + ), + borderWidth: '0.75px', + }, + }, }} - > - - {errorMessage} - - + /> )} + /> + - {isLoadingUser && ( - + {errorMessage && !isLoadingUser ? ( + + {errorMessage} + + ) : null} + + {!errorMessage && isLoadingUser ? ( + + + + {t('core:loading.generic', { + postProcess: 'capitalizeFirstChar', + })} + + + ) : null} + + {isEmptyLookupState ? ( + *': { + position: 'relative', + zIndex: 1, + }, }} - > - + - - {t('core:loading.generic', { - postProcess: 'capitalizeFirstChar', - })} - + + + {t('core:account_lookup.empty_title', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('core:account_lookup.empty_subtitle', { + postProcess: 'capitalizeFirstChar', + })} + + - )} + ) : null} - {!isLoadingUser && addressInfo && ( + {!errorMessage && !isLoadingUser && addressInfo ? ( + - {/* Left panel: name, avatar, level progress */} - - - {addressInfo?.name ?? - t('auth:message.error.name_not_registered', { - postProcess: 'capitalizeFirstChar', - })} - - - {addressInfo?.name ? ( - - - - ) : ( + {addressInfo.name ? ( + + + + ) : ( + + + + )} + + + + {addressInfo.name || + t('auth:message.error.name_not_registered', { + postProcess: 'capitalizeFirstChar', + })} + + + - - - )} - - - - {t('core:level', { postProcess: 'capitalizeFirstChar' })}{' '} - {addressInfo?.level ?? 0} - - - {targetBlocks != null && ( - <> - - } + label={t('core:account_lookup.chip_blocked', { + postProcess: 'capitalizeFirstChar', + })} + size="small" + sx={{ + backgroundColor: alpha(theme.palette.error.main, 0.12), + color: theme.palette.error.light, + fontWeight: 700, + }} + /> + ) : null} + {!addressInfo.name ? ( + - + ) : null} + + + + {targetBlocks != null ? ( + + + {t('core:account_lookup.section_minting_progress', { + postProcess: 'capitalizeFirstChar', + })} + + - {currentBlocks.toLocaleString()} / {targetBlocks.toLocaleString()} + {currentBlocks.toLocaleString()} /{' '} + {targetBlocks.toLocaleString()} - {nextLevelNum != null && ( - - {t('core:remaining_to_level', { - level: nextLevelNum, - })} - - )} - {remainingBlocks.toLocaleString()}{' '} - {t('core:blocks', { - postProcess: 'capitalizeFirstChar', - })} + {nextLevelNumber != null + ? t('core:account_lookup.minting_blocks_to_level', { + remaining: + remainingBlocks.toLocaleString(), + level: nextLevelNumber, + postProcess: 'capitalizeFirstChar', + }) + : t('core:account_lookup.minting_remaining', { + remaining: + remainingBlocks.toLocaleString(), + postProcess: 'capitalizeFirstChar', + })} - {daysToLevel != null && daysToLevel >= 0 && ( + {daysToLevel != null && daysToLevel >= 0 ? ( - ~{Math.round(daysToLevel)}{' '} - {t('core:days_of_minting', { + {t('core:account_lookup.minting_days_label')}{' '} + {t('core:account_lookup.minting_approx', { + count: Math.round(daysToLevel), postProcess: 'capitalizeFirstChar', })} - )} - - )} - + ) : null} + + ) : null} + - {/* Right panel: details for address */} + {profileActionButtons} + + + + + + {displayStatRows.map((row, index) => ( + 0 ? `1px solid ${dividerColor}` : 'none', + }, + borderTop: { + xs: index >= 2 ? `1px solid ${dividerColor}` : 'none', + md: 'none', + }, + minWidth: 0, + p: 1.25, + }} + > + + {row.label} + + + {row.value} + + + ))} + + + - - {t('core:details_for_address', { - postProcess: 'capitalizeFirstChar', - })} - + + {t('core:account_lookup.label_address', { + postProcess: 'capitalizeFirstChar', + })} + + + + {addressInfo.address} + + + + + + + {t('core:account_lookup.label_public_key', { + postProcess: 'capitalizeFirstChar', + })} + - {t('auth:address', { - postProcess: 'capitalizeFirstChar', - })} + {addressInfo.publicKey || '\u2014'} - - { - navigator.clipboard.writeText(addressInfo?.address); - }} - sx={{ display: 'block', textAlign: 'left', mt: 0.25 }} - > - - {addressInfo?.address} - - - + + + - {[ - { - label: t('core:balance', { - postProcess: 'capitalizeFirstChar', - }), - value: `${formatBalance(addressInfo?.balance)} QORT`, - }, - { - label: t('core:total_received', { - postProcess: 'capitalizeFirstChar', - }), - value: - totalReceived != null - ? `${formatBalance(totalReceived)} QORT` - : '—', - }, - { - label: t('core:total_sent', { - postProcess: 'capitalizeFirstChar', - }), - value: - totalSent != null - ? `${formatBalance(totalSent)} QORT` - : '—', - }, - { - label: t('core:total_blocks_minted', { + + + {t('core:account_lookup.payments_title', { postProcess: 'capitalizeFirstChar', - }), - value: ( - (addressInfo?.blocksMinted ?? 0) + - (addressInfo?.blocksMintedAdjustment ?? 0) - ).toLocaleString(), - }, - ].map((row, i) => ( - + - - {row.label} - - - {row.value} - - - ))} + {t('core:payments_count', { + count: totalPaymentsCount, + postProcess: 'capitalizeFirstChar', + })} + + - - - - {t('core:payments_count', { - count: totalPaymentsCount, - postProcess: 'capitalizeFirstChar', - })} - - - - - - {t('core:sender', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:receiver', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:amount', { - postProcess: 'capitalizeFirstChar', - })} - - - {t('core:time.time', { - postProcess: 'capitalizeFirstChar', - })} - - - - - {isLoadingPayments && ( + '& .MuiTableHead-root .MuiTableCell-head': { + backgroundColor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.025) + : '#EEF2F7', + color: alpha(theme.palette.text.secondary, 0.72), + fontSize: '0.66rem', + fontWeight: 700, + letterSpacing: '0.09em', + textTransform: 'uppercase', + }, + '& .MuiTableBody-root .MuiTableCell-root': { + fontSize: '0.78rem', + }, + '& .MuiTableRow-hover:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.04), + }, + }} + > + - - + + {t('core:sender', { + postProcess: 'capitalizeFirstChar', + })} - - )} - {!isLoadingPayments && paginatedPayments.length === 0 && ( - - - {t('core:message.generic.no_payments', { + + {t('core:receiver', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('core:amount', { + postProcess: 'capitalizeFirstChar', + })} + + + {t('core:time.time', { postProcess: 'capitalizeFirstChar', })} - )} - {paginatedPayments.map((payment) => { - const currentAddress = addressInfo?.address; - const isSenderCurrent = - currentAddress && - payment?.creatorAddress === currentAddress; - const isReceiverCurrent = - currentAddress && - payment?.recipient === currentAddress; - const handleAddressClick = (address: string) => { - if (!address || address === currentAddress) return; - setNameOrAddress(address); - setInputValue(address); - lookupFunc(address); - }; - return ( - - - {isSenderCurrent ? ( - - {addressInfo?.name?.trim() || - formatAddress(payment?.creatorAddress)} - - ) : ( - - scheduleFetchNameForAddress( - payment?.creatorAddress - ) - } - onClose={cancelFetchNameForAddress} - title={(() => { - const entry = - addressNamesMap[payment?.creatorAddress]; - if (!entry || entry.loading) - return ( - - - - ); - if (entry.name) - return ( + + + {isLoadingPayments ? ( + + + + + + ) : null} + + {!isLoadingPayments && paginatedPayments.length === 0 ? ( + + + {t('core:message.generic.no_payments', { + postProcess: 'capitalizeFirstChar', + })} + + + ) : null} + + {!isLoadingPayments + ? paginatedPayments.map((payment) => { + const ownerAddress = addressInfo.address; + const senderAddress = payment?.creatorAddress || ''; + const receiverAddress = payment?.recipient || ''; + const senderEntry = addressNamesMap[senderAddress]; + const receiverEntry = addressNamesMap[receiverAddress]; + const senderLabel = + senderAddress === ownerAddress + ? addressInfo.name?.trim() || formatAddress(senderAddress) + : senderEntry?.name || formatAddress(senderAddress); + const receiverLabel = + receiverAddress === ownerAddress + ? addressInfo.name?.trim() || formatAddress(receiverAddress) + : receiverEntry?.name || formatAddress(receiverAddress); + + const handleLookupAddress = (address: string) => { + if (!address || address === ownerAddress) { + return; + } + + setNameOrAddress(address); + setInputValue(address); + void lookupFunc(address); + }; + + return ( + + + {senderAddress === ownerAddress ? ( + + {senderLabel} + + ) : ( + handleLookupAddress(senderAddress)} + sx={{ textAlign: 'left' }} + > - {entry.name} + {senderLabel} - ); - return ( + + )} + + + {receiverAddress === ownerAddress ? ( - {payment?.creatorAddress ?? ''} + {receiverLabel} - ); - })()} - placement="top" - arrow - slotProps={{ - tooltip: { - sx: { - color: theme.palette.text.primary, - backgroundColor: - theme.palette.background.paper, - border: 1, - borderColor: 'divider', - borderRadius: 2, - boxShadow: theme.shadows[4], - padding: '12px 16px', - maxWidth: 360, - }, - }, - arrow: { - sx: { - color: theme.palette.background.paper, - }, - }, - }} - > - - handleAddressClick( - payment?.creatorAddress - ) - } - sx={{ textAlign: 'left' }} - > - - {formatAddress(payment?.creatorAddress)} - - - - )} - - - {isReceiverCurrent ? ( - - {addressInfo?.name?.trim() || - formatAddress(payment?.recipient)} - - ) : ( - - scheduleFetchNameForAddress( - payment?.recipient - ) - } - onClose={cancelFetchNameForAddress} - title={(() => { - const entry = - addressNamesMap[payment?.recipient]; - if (!entry || entry.loading) - return ( - - - - ); - if (entry.name) - return ( + ) : ( + + handleLookupAddress(receiverAddress) + } + sx={{ textAlign: 'left' }} + > - {entry.name} + {receiverLabel} - ); - return ( - - {payment?.recipient ?? ''} - - ); - })()} - placement="top" - arrow - slotProps={{ - tooltip: { - sx: { - color: theme.palette.text.primary, - backgroundColor: - theme.palette.background.paper, - border: 1, - borderColor: 'divider', - borderRadius: 2, - boxShadow: theme.shadows[4], - padding: '12px 16px', - maxWidth: 360, - }, - }, - arrow: { - sx: { - color: theme.palette.background.paper, - }, - }, - }} - > - - handleAddressClick(payment?.recipient) - } - sx={{ textAlign: 'left' }} + + )} + + - - {formatAddress(payment?.recipient)} - - - - )} - - {payment?.amount} QORT - - {formatTimestamp(payment?.timestamp)} - - - ); - })} - -
+ {formatBalance(payment?.amount)} QORT + + + {formatTimestamp(payment?.timestamp)} + + + ); + }) + : null} + + + + setPaymentsPage(p)} - rowsPerPage={paymentsRowsPerPage} - onRowsPerPageChange={(e) => { - setPaymentsRowsPerPage(parseInt(e.target.value, 10)); + onPageChange={(_event, page) => setPaymentsPage(page)} + onRowsPerPageChange={(event) => { + setPaymentsRowsPerPage(parseInt(event.target.value, 10)); setPaymentsPage(0); }} - rowsPerPageOptions={[5, 10, 25, 50]} + page={paymentsPage} + rowsPerPage={paymentsRowsPerPage} + rowsPerPageOptions={[5, 10, 20]} + labelRowsPerPage={t('core:account_lookup.pagination_rows_per_page')} labelDisplayedRows={({ from, to, count }) => - t('core:pagination_of', { - from: count === 0 ? 0 : from, - to: to, - total: count, + t('core:account_lookup.pagination_displayed_rows', { + from, + to, + count, }) } - labelRowsPerPage={t('core:items_per_page', { - postProcess: 'capitalizeFirstChar', - })} sx={{ - borderTop: 1, - borderColor: alpha(theme.palette.divider, 0.4), - '& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': - { fontSize: '0.875rem' }, + borderTop: `1px solid ${alpha(theme.palette.divider, 0.16)}`, + mt: 1, + '& .MuiTablePagination-toolbar': { + minHeight: 42, + px: 0.5, + }, + '& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': { + color: theme.palette.text.secondary, + fontSize: '0.74rem', + fontWeight: 500, + m: 0, + }, + '& .MuiTablePagination-select': { + fontSize: '0.76rem', + fontWeight: 600, + py: 0.25, + }, + '& .MuiTablePagination-actions .MuiIconButton-root': { + p: 0.5, + }, }} />
- )} -
+
+ ) : null}
-
+ ); }; diff --git a/src/components/Wallets.tsx b/src/components/Wallets.tsx index 443f174d..cddf8fb5 100644 --- a/src/components/Wallets.tsx +++ b/src/components/Wallets.tsx @@ -1,881 +1,1585 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; -import Avatar from '@mui/material/Avatar'; -import Typography from '@mui/material/Typography'; import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type DragEvent as ReactDragEvent, +} from 'react'; +import { + Avatar, + Badge, Box, - Button, ButtonBase, - Dialog, - DialogActions, - DialogContent, - DialogTitle, IconButton, Input, + InputAdornment, + TextField, + Tooltip, + Typography, useTheme, } from '@mui/material'; -import { CustomButton, Label } from '../styles/App-styles.ts'; +import { alpha } from '@mui/material/styles'; import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; import EditIcon from '@mui/icons-material/Edit'; import PersonIcon from '@mui/icons-material/Person'; -import { Spacer } from '../common/Spacer.tsx'; -import { - deleteAvatar, - loadAvatar, - resizeImageToAvatar, - saveAvatar, -} from '../utils/avatarStorage.ts'; -import { - getWallets, - storeWallets, - walletVersion, -} from '../background/background.ts'; -import { getPrimaryNameForAvatar } from './Group/groupApi'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; +import VpnKeyRoundedIcon from '@mui/icons-material/VpnKeyRounded'; +import LoginRoundedIcon from '@mui/icons-material/LoginRounded'; +import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import ClearRoundedIcon from '@mui/icons-material/ClearRounded'; +import ManageSearchRoundedIcon from '@mui/icons-material/ManageSearchRounded'; +import { getWallets, storeWallets, walletVersion } from '../background/background.ts'; +import { getPrimaryNamesForAddresses } from './Group/groupApi'; import { getBaseApiReactForAvatar } from '../App'; -import { useModal } from '../hooks/useModal.tsx'; import PhraseWallet from '../utils/generateWallet/phrase-wallet.ts'; import { decryptStoredWalletFromSeedPhrase } from '../utils/decryptWallet.ts'; import { crypto } from '../constants/decryptWallet.ts'; -import { LoadingButton } from '@mui/lab'; import { PasswordField } from './index.ts'; -import { HtmlTooltip } from './NotAuthenticated.tsx'; -import { useAtomValue } from 'jotai'; -import { hasSeenGettingStartedAtom } from '../atoms/global'; -import { useTranslation } from 'react-i18next'; +import { AuthButton, AuthSectionLabel } from './Auth/AuthShell'; +import type { AuthUnlockTransitionSnapshot } from '../types/authTransition'; const parsefilenameQortal = (filename) => { return filename.startsWith('qortal_backup_') ? filename.slice(14) : filename; }; -export const Wallets = ({ setExtState, setRawWallet, rawWallet }) => { - const [wallets, setWallets] = useState([]); +const shortenAddress = (address?: string) => { + if (!address) return ''; + if (address.length <= 18) return address; + return `${address.slice(0, 8)}...${address.slice(-8)}`; +}; + +/** Keeps Enter Qortal list height stable while filtering/scrolling */ +const ENTRY_WALLET_SCROLL_HEIGHT_PX = 292; + +type WalletsProps = { + setExtState: (state: any) => void; + setRawWallet: (wallet: any) => void; + rawWallet?: any; + mode?: 'entry' | 'import'; + onImportViewChange?: (view: ImportView) => void; + onReady?: () => void; + onWalletUnlockStart?: (snapshot: AuthUnlockTransitionSnapshot) => void; +}; + +type ImportView = 'choice' | 'backup' | 'seedphrase' | 'authenticate'; + +export const Wallets = ({ + setExtState, + setRawWallet, + mode = 'import', + onImportViewChange, + onReady, + onWalletUnlockStart, +}: WalletsProps) => { + const { t } = useTranslation(['auth']); + const [wallets, setWallets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [seedValue, setSeedValue] = useState(''); - const [seedName, setSeedName] = useState(''); const [seedError, setSeedError] = useState(''); - const hasSeenGettingStarted = useAtomValue(hasSeenGettingStartedAtom); const [password, setPassword] = useState(''); - const [isOpenSeedModal, setIsOpenSeedModal] = useState(false); const [isLoadingEncryptSeed, setIsLoadingEncryptSeed] = useState(false); + const [isSeedVisible, setIsSeedVisible] = useState(false); + const [importView, setImportView] = useState('choice'); + const [backupImportHint, setBackupImportHint] = useState(''); + /** Insertion slot index in the wallet list — line appears before wallets[idx] when idx < length */ + const [walletDropGapBeforeIndex, setWalletDropGapBeforeIndex] = useState< + number | null + >(null); + /** Row being dragged — dim original while reordering */ + const [walletReorderDragSourceIndex, setWalletReorderDragSourceIndex] = + useState(null); + const [editingWalletIndex, setEditingWalletIndex] = useState( + null + ); const [primaryNamesByAddress, setPrimaryNamesByAddress] = useState< Record >({}); - const fetchingAddressesRef = useRef>(new Set()); - const observerRef = useRef(null); + const [walletEntrySearchOpen, setWalletEntrySearchOpen] = useState(false); + const [walletEntryFilterQuery, setWalletEntryFilterQuery] = useState(''); + const accountsScrollRef = useRef(null); + /** True while reordering wallets; dragover hits header/footer/etc. unless we listen on document */ + const walletReorderDragActiveRef = useRef(false); + const entryModeRef = useRef(mode); + const editingWalletIndexRef = useRef(editingWalletIndex); + entryModeRef.current = mode; + editingWalletIndexRef.current = editingWalletIndex; const theme = useTheme(); - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); - const { isShow, onCancel, onOk, show } = useModal(); + /** HTML5 drag: scroll only when cursor is above/below the list clip (header/footer overlap), never from inside */ useEffect(() => { - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - const el = entry.target as HTMLElement; - const address = el.getAttribute('data-address'); - if (!address || fetchingAddressesRef.current.has(address)) return; - fetchingAddressesRef.current.add(address); - getPrimaryNameForAvatar(address) - .then((name) => { - if (name) { - setPrimaryNamesByAddress((prev) => - prev[address] === undefined - ? { ...prev, [address]: name } - : prev - ); - } - }) - .catch(() => {}) - .finally(() => { - fetchingAddressesRef.current.delete(address); - observerRef.current?.unobserve(el); - }); - }); - }, - { rootMargin: '100px', threshold: 0.01 } - ); - return () => { - observerRef.current?.disconnect(); - observerRef.current = null; + const onDocumentDragOver = (event: globalThis.DragEvent) => { + if (!walletReorderDragActiveRef.current) return; + if (entryModeRef.current !== 'entry' || editingWalletIndexRef.current !== null) + return; + const el = accountsScrollRef.current; + if (!el || el.scrollHeight <= el.clientHeight) return; + + const rect = el.getBoundingClientRect(); + const x = event.clientX; + const y = event.clientY; + const horizontalPad = 80; + if (x < rect.left - horizontalPad || x > rect.right + horizontalPad) return; + + /** Only past the clipped top/bottom — no scrolling from the interior of the list */ + const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight); + let delta = 0; + + if (el.scrollTop > 0 && y < rect.top) { + const depthPx = rect.top - y; + const t = Math.min(1, depthPx / 100); + delta -= Math.round((8 + 28) * (0.25 + 0.75 * t)); + } + if (el.scrollTop < maxScroll && y > rect.bottom) { + const depthPx = y - rect.bottom; + const t = Math.min(1, depthPx / 100); + delta += Math.round((8 + 28) * (0.25 + 0.75 * t)); + } + + if (delta !== 0) { + event.preventDefault(); + el.scrollTop = Math.min(maxScroll, Math.max(0, el.scrollTop + delta)); + } }; + + document.addEventListener('dragover', onDocumentDragOver); + return () => document.removeEventListener('dragover', onDocumentDragOver); + }, []); + + const registerReorderDragActive = useCallback( + (active: boolean) => { + walletReorderDragActiveRef.current = active; + }, + [] + ); + + const handleWalletReorderDragStart = useCallback((sourceIdx: number) => { + setWalletReorderDragSourceIndex(sourceIdx); + }, []); + + const handleWalletReorderDragEnd = useCallback(() => { + setWalletReorderDragSourceIndex(null); + setWalletDropGapBeforeIndex(null); }, []); + const handleWalletReorderHover = useCallback( + (rowIdx: number, event: ReactDragEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const gapBeforeIdx = + event.clientY < bounds.top + bounds.height / 2 ? rowIdx : rowIdx + 1; + setWalletDropGapBeforeIndex(gapBeforeIdx); + }, + [] + ); + + const handleWalletReorderHoverLeave = useCallback( + (event: ReactDragEvent) => { + const nextTarget = event.relatedTarget as Node | null; + if ( + nextTarget instanceof Node && + event.currentTarget.contains(nextTarget) + ) { + return; + } + setWalletDropGapBeforeIndex(null); + }, + [] + ); + + const changeImportView = useCallback( + (view: ImportView) => { + setImportView(view); + onImportViewChange?.(view); + }, + [onImportViewChange] + ); + + const walletAddressKey = useMemo(() => { + return wallets + .map((wallet) => wallet?.address0) + .filter(Boolean) + .join('|'); + }, [wallets]); + const walletAddresses = useMemo( + () => (walletAddressKey ? walletAddressKey.split('|') : []), + [walletAddressKey] + ); + const registerCardRef = useCallback((address: string) => { return (el: HTMLElement | null) => { if (!el) return; el.setAttribute('data-address', address); - observerRef.current?.observe(el); }; }, []); - const { getRootProps, getInputProps } = useDropzone({ - accept: { - 'application/json': ['.json'], // Only accept JSON files - }, - onDrop: async (acceptedFiles) => { - const files: any = acceptedFiles; - let importedWallets: any = []; - - for (const file of files) { - try { - const fileContents = await new Promise((resolve, reject) => { - const reader = new FileReader(); + useEffect(() => { + if (walletAddresses.length === 0) { + setPrimaryNamesByAddress({}); + return; + } - reader.onabort = () => reject('File reading was aborted'); // TODO translate - reader.onerror = () => reject('File reading has failed'); - reader.onload = () => { - // Resolve the promise with the reader result when reading completes - resolve(reader.result); - }; + let canceled = false; - // Read the file as text - reader.readAsText(file); - }); - if (typeof fileContents !== 'string') continue; - const parsedData = JSON.parse(fileContents); - importedWallets.push({ ...parsedData, filename: file?.name }); - } catch (error) { - console.error(error); + getPrimaryNamesForAddresses(walletAddresses) + .then((namesByAddress) => { + if (!canceled) { + setPrimaryNamesByAddress(namesByAddress); } - } + }) + .catch((error) => { + console.error('Unable to fetch wallet primary names:', error); + }); - const uniqueInitialMap = new Map(); + return () => { + canceled = true; + }; + }, [walletAddresses]); - // Only add a message if it doesn't already exist in the Map - importedWallets.forEach((wallet) => { - if (!wallet?.address0) return; - if (!uniqueInitialMap.has(wallet?.address0)) { - uniqueInitialMap.set(wallet?.address0, wallet); + const persistWallets = useCallback(async (nextWallets: any[]) => { + setWallets(nextWallets); + await storeWallets(nextWallets); + }, []); + + const getLatestWallets = useCallback(async () => { + // Import flows can outlive the initial wallet load/migration. Re-read + // storage before merging so a stale local list cannot overwrite accounts. + const latestWallets = await getWallets(); + return Array.isArray(latestWallets) ? latestWallets : wallets; + }, [wallets]); + + useEffect(() => { + setIsLoading(true); + getWallets() + .then((res) => { + if (res && Array.isArray(res)) { + setWallets(res); } + setIsLoading(false); + }) + .catch((error) => { + console.error(error); + setIsLoading(false); }); + }, []); - const data = Array.from(uniqueInitialMap.values()); + useEffect(() => { + if (!isLoading) { + onReady?.(); + } + }, [isLoading, onReady]); - if (data && data?.length > 0) { - const uniqueNewWallets = data.filter( - (newWallet) => - !wallets.some( - (existingWallet) => - existingWallet?.address0 === newWallet?.address0 - ) - ); - setWallets([...wallets, ...uniqueNewWallets]); - } - }, - }); + useEffect(() => { + if (wallets.length <= 8) { + setWalletEntrySearchOpen(false); + setWalletEntryFilterQuery(''); + } + }, [wallets.length]); - const { getRootProps: getRootPropsTemp, getInputProps: getInputPropsTemp } = - useDropzone({ - accept: { - 'application/json': ['.json'], // Only accept JSON files - }, - multiple: false, - onDrop: async (acceptedFiles) => { - const files: any = acceptedFiles; - let importedWallet: any = null; - - for (const file of files) { - try { - const fileContents = await new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onabort = () => reject('File reading was aborted'); // TODO translate - reader.onerror = () => reject('File reading has failed'); - reader.onload = () => { - // Resolve the promise with the reader result when reading completes - resolve(reader.result); - }; - - // Read the file as text - reader.readAsText(file); - }); - if (typeof fileContents !== 'string') continue; - const parsedData = JSON.parse(fileContents); - importedWallet = parsedData; - } catch (error) { - console.error(error); - } - } + const selectedWalletFunc = ( + wallet, + transitionSnapshot?: AuthUnlockTransitionSnapshot + ) => { + if (transitionSnapshot && mode === 'entry') { + onWalletUnlockStart?.(transitionSnapshot); + window.setTimeout(() => { + setRawWallet(wallet); + setExtState('wallet-dropped'); + }, 130); + return; + } - if (importedWallet) { - selectedWalletFunc(importedWallet); - } - }, - }); + setRawWallet(wallet); + setExtState('wallet-dropped'); + }; const updateWalletItem = (idx, wallet) => { - setWallets((prev) => { - let copyPrev = [...prev]; - if (wallet === null) { - copyPrev.splice(idx, 1); // Use splice to remove the item - return copyPrev; - } else { - copyPrev[idx] = wallet; // Update the wallet at the specified index - return copyPrev; - } - }); + if (wallet === null) { + setEditingWalletIndex(null); + } + + const nextWallets = [...wallets]; + if (wallet === null) { + nextWallets.splice(idx, 1); + } else { + nextWallets[idx] = wallet; + } + + void persistWallets(nextWallets).catch(console.error); }; - const handleSetSeedValue = async () => { + /** `gapBeforeIndex` is visual slot index in the pre-move ordering (0..wallets.length) */ + const finalizeWalletReorder = useCallback( + (fromIndex: number, gapBeforeIndex: number) => { + const n = wallets.length; + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(gapBeforeIndex) || + fromIndex < 0 || + fromIndex >= n || + gapBeforeIndex < 0 || + gapBeforeIndex > n + ) + return; + + const nextWallets = [...wallets]; + const [movedWallet] = nextWallets.splice(fromIndex, 1); + const rawInsert = + gapBeforeIndex > fromIndex ? gapBeforeIndex - 1 : gapBeforeIndex; + const insertAt = Math.max(0, Math.min(rawInsert, nextWallets.length)); + nextWallets.splice(insertAt, 0, movedWallet); + + const unchanged = + wallets.length === nextWallets.length && + wallets.every( + (walletItem, walletIdx) => + walletItem?.address0 === nextWallets[walletIdx]?.address0 + ); + if (unchanged) return; + + void persistWallets(nextWallets).catch(console.error); + }, + [persistWallets, wallets] + ); + + const importSeedphrase = async () => { try { - setIsOpenSeedModal(true); - const { seedValue, seedName, password } = await show({ - message: '', - publishFee: '', - }); setIsLoadingEncryptSeed(true); - const res = await decryptStoredWalletFromSeedPhrase(seedValue); + setSeedError(''); + const res = await decryptStoredWalletFromSeedPhrase(seedValue.trim()); const wallet2 = new PhraseWallet(res, walletVersion); const wallet = await wallet2.generateSaveWalletData( password, crypto.kdfThreads, () => {} ); + if (wallet?.address0) { - setWallets([ - ...wallets, - { - ...wallet, - name: seedName, - }, - ]); - setIsOpenSeedModal(false); + const latestWallets = await getLatestWallets(); + const existsAlready = latestWallets.some( + (existingWallet) => existingWallet?.address0 === wallet.address0 + ); + const nextWallets = existsAlready + ? latestWallets + : [ + ...latestWallets, + { + ...wallet, + name: '', + }, + ]; + + if (!existsAlready) { + await persistWallets(nextWallets); + } setSeedValue(''); - setSeedName(''); setPassword(''); - setSeedError(''); - } else { - setSeedError( - t('auth:message.error.account_creation', { - postProcess: 'capitalizeFirstChar', - }) + changeImportView('choice'); + setBackupImportHint( + existsAlready + ? t('auth:entry.seed_import_duplicate') + : t('auth:entry.seed_import_success') ); + if (!existsAlready) { + setExtState('not-authenticated'); + } + } else { + setSeedError(t('auth:entry.seed_import_error')); } - } catch (error) { - setSeedError( - error?.message || - t('auth:message.error.account_creation', { - postProcess: 'capitalizeFirstChar', - }) - ); + } catch (error: any) { + setSeedError(error?.message || t('auth:entry.seed_import_error')); } finally { setIsLoadingEncryptSeed(false); } }; - const selectedWalletFunc = (wallet) => { - setRawWallet(wallet); - setExtState('wallet-dropped'); - }; + const readWalletFiles = useCallback( + async (acceptedFiles: File[]) => { + const importedWallets: any[] = []; - useEffect(() => { - setIsLoading(true); - getWallets() - .then((res) => { - if (res && Array.isArray(res)) { - setWallets(res); + for (const file of acceptedFiles) { + try { + const fileContents = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onabort = () => + reject(new Error(t('auth:entry.import_file_read_aborted'))); + reader.onerror = () => + reject(new Error(t('auth:entry.import_file_read_failed'))); + reader.onload = () => resolve(reader.result); + reader.readAsText(file); + }); + if (typeof fileContents !== 'string') continue; + const parsedData = JSON.parse(fileContents); + importedWallets.push({ ...parsedData, filename: file.name }); + } catch (error) { + console.error(error); + } + } + + const uniqueInitialMap = new Map(); + importedWallets.forEach((wallet) => { + if (!wallet?.address0) return; + if (!uniqueInitialMap.has(wallet.address0)) { + uniqueInitialMap.set(wallet.address0, wallet); } - setIsLoading(false); - }) - .catch((error) => { - console.error(error); - setIsLoading(false); }); - }, []); - useEffect(() => { - if (!isLoading && wallets && Array.isArray(wallets)) { - storeWallets(wallets); + return Array.from(uniqueInitialMap.values()); + }, + [t] + ); + + const { getRootProps, getInputProps } = useDropzone({ + accept: { + 'application/json': ['.json'], + }, + onDrop: async (acceptedFiles) => { + const uniqueWallets = await readWalletFiles(acceptedFiles as File[]); + if (!uniqueWallets.length) return; + + const latestWallets = await getLatestWallets(); + const uniqueNewWallets = uniqueWallets.filter( + (newWallet) => + !latestWallets.some( + (existingWallet) => existingWallet?.address0 === newWallet?.address0 + ) + ); + + if (uniqueNewWallets.length > 0) { + const nextWallets = [...latestWallets, ...uniqueNewWallets]; + await persistWallets(nextWallets); + } + + setBackupImportHint( + uniqueNewWallets.length > 0 + ? t('auth:entry.import_backup_success', { + count: uniqueNewWallets.length, + }) + : t('auth:entry.import_backup_duplicate') + ); + changeImportView('choice'); + if (uniqueNewWallets.length > 0) { + setExtState('not-authenticated'); + } + }, + }); + + const { + getRootProps: getAuthenticateRootProps, + getInputProps: getAuthenticateInputProps, + } = useDropzone({ + accept: { + 'application/json': ['.json'], + }, + multiple: false, + onDrop: async (acceptedFiles) => { + const uniqueWallets = await readWalletFiles(acceptedFiles as File[]); + const wallet = uniqueWallets[0]; + + if (!wallet?.address0) { + setBackupImportHint(t('auth:entry.import_backup_invalid')); + return; + } + + setBackupImportHint(''); + setRawWallet(wallet); + setExtState('wallet-dropped'); + }, + }); + + const displayedWallets = useMemo(() => { + const base = + editingWalletIndex === null + ? wallets.map((wallet, idx) => ({ wallet, idx })) + : wallets + .map((wallet, idx) => ({ wallet, idx })) + .filter(({ idx: rowIdx }) => rowIdx === editingWalletIndex); + + if ( + mode !== 'entry' || + editingWalletIndex !== null || + wallets.length <= 8 + ) { + return base; } - }, [wallets, isLoading]); - if (isLoading) return null; + const q = walletEntryFilterQuery.trim().toLowerCase(); + if (!q) return base; - return ( - - {wallets?.length === 0 || !wallets ? ( - <> - - {t('auth:message.generic.no_account', { - postProcess: 'capitalizeFirstChar', - })} - + return base.filter(({ wallet }) => { + const address = String(wallet?.address0 || '').toLowerCase(); + const primary = String( + wallet?.address0 ? primaryNamesByAddress[wallet.address0] || '' : '' + ).toLowerCase(); + const name = String(wallet?.name || '').toLowerCase(); + const note = String(wallet?.note || '').toLowerCase(); + const fileLabel = + wallet?.filename != null + ? String(parsefilenameQortal(wallet.filename)).toLowerCase() + : ''; + return ( + address.includes(q) || + primary.includes(q) || + name.includes(q) || + note.includes(q) || + fileLabel.includes(q) + ); + }); + }, [ + editingWalletIndex, + mode, + primaryNamesByAddress, + walletEntryFilterQuery, + wallets, + ]); - - - ) : ( - <> - - {t('auth:message.generic.your_accounts', { - postProcess: 'capitalizeFirstChar', - })} - + if (isLoading) return null; - - - )} + const showsEntryWalletFilter = + mode === 'entry' && editingWalletIndex === null && wallets.length > 8; - {rawWallet && ( - - - {t('auth:account.selected', { - postProcess: 'capitalizeFirstChar', - })} - : - - {rawWallet?.name && {rawWallet.name}} - {rawWallet?.address0 && ( - {rawWallet?.address0} - )} - - )} + const entryFilteredNoMatches = + showsEntryWalletFilter && + walletEntryFilterQuery.trim().length > 0 && + displayedWallets.length === 0; - {wallets?.length > 0 && ( + const entryListFixedViewport = + mode === 'entry' && editingWalletIndex === null; + + const accountsList = ( + + {entryFilteredNoMatches ? ( - {wallets?.map((wallet, idx) => { - return ( - - ); - })} - - )} - - - - - {t('auth:temp_auth.tooltip', { - postProcess: 'capitalizeFirstChar', - })} - - - } - > - - - {t('auth:temp_auth.button', { - postProcess: 'capitalizeFirstChar', - })} - - - - - {t('auth:tips.existing_account', { - postProcess: 'capitalizeFirstChar', - })} - - - } - > - - {t('auth:action.add.seed_phrase', { - postProcess: 'capitalizeFirstChar', - })} - - - - - + + ) : ( + <> + {displayedWallets.map(({ wallet, idx }) => ( + + {walletDropGapBeforeIndex !== null && + walletReorderDragSourceIndex !== null && + walletDropGapBeforeIndex === idx && ( + - {t('auth:tips.additional_wallet', { - postProcess: 'capitalizeFirstChar', - })} - - - } - > - + )} + + + ))} + {walletDropGapBeforeIndex !== null && + walletReorderDragSourceIndex !== null && + walletDropGapBeforeIndex === wallets.length && ( + - - {t('auth:action.add.account', { - postProcess: 'capitalizeFirstChar', - })} - - - - - { - if (e.key === 'Enter' && seedValue && seedName && password) { - onOk({ seedValue, seedName, password }); - } - }} + /> + )} + + )} + + ); + + const entryAccountListColumn = ( + + {showsEntryWalletFilter && ( + + + setWalletEntrySearchOpen((open) => !open)} + sx={{ + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.1), + }, + border: `1px solid ${ + walletEntrySearchOpen || walletEntryFilterQuery.trim() + ? alpha(theme.palette.primary.main, 0.45) + : alpha(theme.palette.text.primary, 0.12) + }`, + borderRadius: '9px', + color: theme.palette.text.secondary, + flexShrink: 0, + height: 34, + width: 34, + }} + > + + + + + + {walletEntrySearchOpen && ( + + setWalletEntryFilterQuery('')} + > + + + + ) : undefined, + }} + onChange={(e) => setWalletEntryFilterQuery(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: alpha(theme.palette.background.paper, 0.45), + borderRadius: '9px', + fontSize: '0.875rem', + minHeight: 36, + }, + minWidth: 0, + }} + /> + )} + + )} + {accountsList} + + ); + + if (mode === 'entry') { + return wallets.length === 0 ? ( + - - {t('auth:message.generic.type_seed', { - postProcess: 'capitalizeFirstChar', - })} - + {t('auth:entry.no_accounts')} + + ) : ( + entryAccountListColumn + ); + } - + return ( + + {importView === 'choice' && ( + + } + onClick={() => changeImportView('backup')} + title={t('auth:entry.import_choice_backup_title')} + /> + } + onClick={() => changeImportView('authenticate')} + title={t('auth:entry.import_choice_authenticate_title')} + /> + } + onClick={() => changeImportView('seedphrase')} + title={t('auth:entry.import_choice_seed_title')} + /> + {backupImportHint && ( + + {backupImportHint} + + )} + + )} + + {importView === 'backup' && ( + + changeImportView('choice')} /> - - setSeedName(e.target.value)} - /> + + + {t('auth:entry.import_backup_heading')} + + + {t('auth:entry.import_backup_drop_hint')} + + + {backupImportHint && ( + + {backupImportHint} + + )} + + )} + + {importView === 'authenticate' && ( + + changeImportView('choice')} /> + + + + {t('auth:entry.import_authenticate_heading')} + + + {t('auth:entry.import_authenticate_drop_hint')} + + + {backupImportHint && ( + + {backupImportHint} + + )} + + )} - + {importView === 'seedphrase' && ( + + changeImportView('choice')} /> - - + + {t('auth:entry.import_seed_label')} + + setSeedValue(e.target.value)} - autoComplete="off" - sx={{ - width: '100%', + onChange={(event) => setSeedValue(event.target.value)} + placeholder={t('auth:entry.import_seed_placeholder')} + sx={seedTextFieldSx(theme, isSeedVisible)} + InputProps={{ + endAdornment: ( + setIsSeedVisible((prev) => !prev)} + sx={{ + alignSelf: 'flex-start', + color: 'rgba(214,221,233,0.62)', + mt: 1, + }} + > + {isSeedVisible ? : } + + ), }} /> + - - - + + + {t('auth:entry.import_wallet_password')} + setPassword(event.target.value)} + suppressAutofill + sx={{ width: '100%' }} value={password} - onChange={(e) => setPassword(e.target.value)} - autoComplete="off" - sx={{ - width: '100%', - }} /> - - - - - - { - if (!seedValue || !seedName || !password) return; - onOk({ seedValue, seedName, password }); - }} - variant="contained" + + {seedError && ( + + {seedError} + + )} + + - {t('core:action.add', { - postProcess: 'capitalizeFirstChar', - })} - + {isLoadingEncryptSeed + ? t('auth:entry.importing_account') + : t('auth:entry.import_account')} + +
+ )} +
+ ); +}; +const ChoiceRow = ({ icon, title, description, onClick }) => { + const theme = useTheme(); + return ( + + + + {icon} + + + + {title} + - {seedError} + {description} - - - + + + + ); }; -const WalletItem = ({ +const InlineReturn = ({ onClick }: { onClick: () => void }) => { + const theme = useTheme(); + const { t } = useTranslation(['core']); + + return ( + + + + ); +}; + +const WalletRow = ({ wallet, updateWalletItem, idx, setSelectedWallet, primaryName, registerCardRef, + finalizeWalletReorder, + registerReorderDragActive, + reorderDragSourceIndex, + reorderDragHover, + reorderDragHoverLeave, + onReorderDragStart, + onReorderDragEnd, + editingWalletIndex, + mode, + setEditingWalletIndex, }) => { - const [name, setName] = useState(''); + const { t } = useTranslation(['auth', 'core']); + const [accountName, setAccountName] = useState(''); const [note, setNote] = useState(''); - const [isEdit, setIsEdit] = useState(false); + const isEdit = editingWalletIndex === idx; + const addressRef = useRef(null); + const avatarRef = useRef(null); + const editButtonRef = useRef(null); + const editPanelRef = useRef(null); + const nameRef = useRef(null); + const rowRef = useRef(null); + const isDraggingRef = useRef(false); const theme = useTheme(); - const { t } = useTranslation([ - 'auth', - 'core', - 'group', - 'question', - 'tutorial', - ]); useEffect(() => { - if (wallet?.name) { - setName(wallet.name); - } - if (wallet?.note) { - setNote(wallet.note); - } + setAccountName(wallet?.name || ''); + setNote(wallet?.note || ''); }, [wallet]); + useEffect(() => { + if (!isEdit) return; + + const closeEditPanel = () => { + setNote(wallet?.note || ''); + setAccountName(wallet?.name || ''); + setEditingWalletIndex(null); + }; + + const closeOnOutsidePointer = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + + if ( + target && + (editButtonRef.current?.contains(target) || + editPanelRef.current?.contains(target)) + ) { + return; + } + + closeEditPanel(); + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + closeEditPanel(); + }; + + document.addEventListener('mousedown', closeOnOutsidePointer); + document.addEventListener('touchstart', closeOnOutsidePointer); + document.addEventListener('keydown', closeOnEscape); + + return () => { + document.removeEventListener('mousedown', closeOnOutsidePointer); + document.removeEventListener('touchstart', closeOnOutsidePointer); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [isEdit, setEditingWalletIndex, wallet]); + const qortalAvatarSrc = primaryName && `${getBaseApiReactForAvatar()}/arbitrary/THUMBNAIL/${primaryName}/qortal_avatar?async=true`; - const displayAvatarSrc = qortalAvatarSrc || undefined; const displayName = primaryName || wallet?.name || (wallet?.filename ? parsefilenameQortal(wallet.filename) : null) || - 'No name'; + t('auth:authentication_form.unnamed_account'); + const addressLabel = shortenAddress(wallet?.address0); + const canEditAccountName = + !primaryName && !wallet?.filename; + + const handleSaveEdit = () => { + updateWalletItem(idx, { + ...wallet, + ...(canEditAccountName ? { name: accountName.trim() } : {}), + note, + }); + setEditingWalletIndex(null); + }; + + const getTransitionSnapshot = (): AuthUnlockTransitionSnapshot | undefined => { + if ( + mode !== 'entry' || + !avatarRef.current || + !nameRef.current || + !addressRef.current + ) { + return undefined; + } + + const rectToObject = (rect: DOMRect) => ({ + height: rect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }); + + return { + addressLabel, + addressRect: rectToObject(addressRef.current.getBoundingClientRect()), + avatarRect: rectToObject(avatarRef.current.getBoundingClientRect()), + avatarSrc: qortalAvatarSrc || undefined, + displayName, + nameRect: rectToObject(nameRef.current.getBoundingClientRect()), + primaryName: primaryName || undefined, + walletAddress: wallet?.address0, + }; + }; + + const handleSelectWallet = () => { + if (isEdit || isDraggingRef.current) return; + setSelectedWallet(wallet, getTransitionSnapshot()); + }; + const isLight = theme.palette.mode === 'light'; + const entryRowBackground = isLight + ? 'linear-gradient(180deg, rgba(255,255,255,0.94), rgba(246,249,253,0.98))' + : 'linear-gradient(180deg, rgba(7,12,21,0.9), rgba(4,7,12,0.94))'; + const entryRowHoverBackground = isLight + ? 'linear-gradient(180deg, rgba(255,255,255,0.99), rgba(239,245,252,0.99))' + : 'linear-gradient(180deg, rgba(8,14,24,0.93), rgba(5,8,14,0.96))'; + const entryEditBackground = isLight + ? 'linear-gradient(180deg, rgba(252,253,255,0.98), rgba(244,248,252,0.99))' + : 'linear-gradient(180deg, rgba(13,19,31,0.92), rgba(7,11,18,0.94))'; return ( { + rowRef.current = element; + if (wallet?.address0) { + registerCardRef(wallet.address0)(element); + } + }} + draggable={!isEdit} + onDragStart={(event) => { + if (isEdit) { + event.preventDefault(); + return; + } + + isDraggingRef.current = true; + registerReorderDragActive?.(true); + onReorderDragStart?.(idx); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(idx)); + }} + onDragOver={(event) => { + if (isEdit) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + reorderDragHover?.(idx, event); + }} + onDragLeave={(event) => { + reorderDragHoverLeave?.(event); + }} + onDrop={(event) => { + event.preventDefault(); + const fromIndex = Number(event.dataTransfer.getData('text/plain')); + + const bounds = ( + event.currentTarget as HTMLElement + ).getBoundingClientRect(); + const gapBeforeIndex = + event.clientY < bounds.top + bounds.height / 2 ? idx : idx + 1; + + if (Number.isInteger(fromIndex)) { + finalizeWalletReorder(fromIndex, gapBeforeIndex); + } + onReorderDragEnd?.(); }} - onClick={() => { - if (!isEdit) setSelectedWallet(wallet); + onDragEnd={() => { + registerReorderDragActive?.(false); + onReorderDragEnd?.(); + window.setTimeout(() => { + isDraggingRef.current = false; + }, 0); + }} + sx={{ + borderBottom: + mode === 'entry' ? 'none' : '1px solid rgba(255,255,255,0.06)', + opacity: + reorderDragSourceIndex !== null && reorderDragSourceIndex === idx ? 0.46 : 1, + pb: mode === 'entry' ? 0 : isEdit ? 1.2 : 0, + pt: mode === 'entry' ? 0 : 0.2, + transition: 'opacity 160ms ease', }} > - {/* Card header: avatar + edit button */} - - - - { - e.stopPropagation(); - setIsEdit(true); - }} - aria-label={t('core:action.edit', { - postProcess: 'capitalizeFirstChar', - })} - > - - - - - {/* Card body: name, address, note */} - { + handleSelectWallet(); }} > - {displayName} - - - - {wallet?.address0} - - - {wallet?.note && ( - - {wallet.note} - - )} - - {/* Card footer: choose button */} - {!isEdit && ( - - + { - e.stopPropagation(); - setSelectedWallet(wallet); + > + + + + + + + + {displayName} + + { + event.stopPropagation(); + setEditingWalletIndex(isEdit ? null : idx); + }} + > + + + {wallet?.note && ( + + {wallet.note} + + )} + + - {t('core:action.choose', { - postProcess: 'capitalizeFirstChar', - })} -
+ {addressLabel} + - )} - {/* Edit mode panel */} + event.stopPropagation()}> + + {t('auth:authentication_form.unlock')} + + + {mode === 'entry' && ( + + )} + + {isEdit && ( e.stopPropagation()} + onClick={(event) => event.stopPropagation()} > - - setName(e.target.value)} - sx={{ - width: '100%', - }} - /> - - - - + {canEditAccountName && ( + <> + + {t('auth:entry.wallet_edit_name_label')} + + setAccountName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSaveEdit(); + } + }} + inputProps={{ maxLength: 48 }} + sx={inlineInputSx} + /> + + )} + + {t('auth:entry.wallet_edit_note_label')} + setNote(e.target.value)} - inputProps={{ - maxLength: 100, - }} - sx={{ - width: '100%', + onChange={(event) => setNote(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSaveEdit(); + } }} + inputProps={{ maxLength: 100 }} + sx={inlineInputSx} /> - - - - - + {t('auth:entry.wallet_edit_save')} + )} ); }; + +const seedTextFieldSx = (theme, isVisible: boolean) => ({ + '& .MuiOutlinedInput-root': { + alignItems: 'flex-start', + backgroundColor: 'rgba(255,255,255,0.03)', + borderRadius: '8px', + '& fieldset': { + borderColor: 'rgba(255,255,255,0.08)', + }, + '&:hover fieldset': { + borderColor: 'rgba(255,255,255,0.12)', + }, + '&.Mui-focused fieldset': { + borderColor: 'rgba(90,136,243,0.42)', + }, + }, + '& textarea': { + WebkitTextSecurity: isVisible ? 'none' : 'disc', + color: theme.palette.text.primary, + fontSize: '0.95rem', + lineHeight: 1.6, + }, +}); + +const inlineFieldLabelSx = { + color: 'rgba(214,221,233,0.56)', + fontSize: '0.74rem', + fontWeight: 700, + letterSpacing: '0.08em', + textTransform: 'uppercase', +}; + +const inlineInputSx = { + color: 'rgba(230,236,247,0.92)', + fontSize: '0.92rem', + '&:before': { + borderBottom: '1px solid rgba(255,255,255,0.08)', + }, + '&:after': { + borderBottom: '1px solid rgba(90,136,243,0.42)', + }, +}; + +const inlineActionSx = (danger: boolean) => ({ + alignItems: 'center', + backgroundColor: danger ? 'rgba(160,56,56,0.12)' : 'rgba(255,255,255,0.03)', + border: `1px solid ${danger ? 'rgba(213,92,92,0.18)' : 'rgba(255,255,255,0.08)'}`, + borderRadius: '8px', + color: danger ? 'rgba(240,165,165,0.92)' : 'rgba(230,236,247,0.88)', + display: 'inline-flex', + fontSize: '0.84rem', + fontWeight: 700, + height: 34, + justifyContent: 'center', + minWidth: 82, + px: 1.4, +}); diff --git a/src/components/Widgets/DashboardWidgetFrame.tsx b/src/components/Widgets/DashboardWidgetFrame.tsx new file mode 100644 index 00000000..85e3aebb --- /dev/null +++ b/src/components/Widgets/DashboardWidgetFrame.tsx @@ -0,0 +1,267 @@ +import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; +import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded'; +import { + Box, + ButtonBase, + IconButton, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import type { ReactNode, Ref } from 'react'; +import { + dashboardPanelSx, + handleDashboardPanelPointerLeave, + handleDashboardPanelPointerMove, +} from '../Group/dashboardPanelEffects'; + +export type WidgetDisplayMode = 'compact' | 'expanded'; + +type DashboardWidgetFrameProps = { + actionIcon?: ReactNode; + actionLabel?: string; + children: ReactNode; + contentBackground?: ReactNode; + contentBorderRadius?: number | string; + height: number; + onAction?: () => void; + onRefresh?: () => void; + onSwap?: () => void; + order?: number; + panelRef?: Ref; + refreshing?: boolean; + subtitle?: string; + title: string; + widgetId?: string; +}; + +export const DashboardWidgetFrame = ({ + actionIcon, + actionLabel, + children, + contentBackground = null, + contentBorderRadius = 5, + height, + onAction, + onRefresh, + onSwap, + order, + panelRef, + refreshing = false, + subtitle, + title, + widgetId, +}: DashboardWidgetFrameProps) => { + const theme = useTheme(); + + return ( + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + + {onRefresh ? ( + + + + + {refreshing ? ( + + ) : null} + + + + ) : null} + + {onSwap ? ( + + + + + + ) : null} + + {actionLabel && onAction ? ( + + {actionIcon} + {actionLabel} + + ) : null} + + + + + {contentBackground} + {children} + + + ); +}; diff --git a/src/components/Widgets/GroupsWidget.tsx b/src/components/Widgets/GroupsWidget.tsx new file mode 100644 index 00000000..46427f5e --- /dev/null +++ b/src/components/Widgets/GroupsWidget.tsx @@ -0,0 +1,2990 @@ +import CampaignRoundedIcon from '@mui/icons-material/CampaignRounded'; +import ForumRoundedIcon from '@mui/icons-material/ForumRounded'; +import GroupAddRoundedIcon from '@mui/icons-material/GroupAddRounded'; +import LockRoundedIcon from '@mui/icons-material/LockRounded'; +import MarkChatUnreadRoundedIcon from '@mui/icons-material/MarkChatUnreadRounded'; +import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded'; +import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; +import { LoadingButton } from '@mui/lab'; +import { + Avatar, + Box, + Button, + ButtonBase, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Select, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAtom, useAtomValue } from 'jotai'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + QORTAL_APP_CONTEXT, + getArbitraryEndpointReact, + getBaseApiReact, +} from '../../App'; +import { + GROUP_ACTIVITY_CACHE_TTL_MS, + groupChatTimestampsAtom, + groupInvitesCacheAtom, + groupsOwnerNamesAtom, + joinRequestsCacheAtom, + memberGroupsAtom, + myGroupsWhereIAmAdminAtom, + timestampEnterDataAtom, + userInfoAtom, +} from '../../atoms/global'; +import { getFee } from '../../background/background'; +import { executeEvent } from '../../utils/events'; +import { formatTimestamp } from '../../utils/time'; +import { + APP_BLUE_SURFACE_TEXT, + getBlueTier1ButtonSx, + getBlueTier1PillSurface, +} from '../Group/groupActivityColorSystem'; +import { GroupActivityEmptyStateGraphic } from '../Group/GroupActivityEmptyStateGraphic'; +import { QAppWidgetContainer } from './QAppWidgetContainer'; +import type { WidgetDisplayMode } from './DashboardWidgetFrame'; + +type GroupsWidgetTab = 'notifications' | 'invites' | 'requests' | 'promoted'; + +type GroupsWidgetProps = { + displayMode: WidgetDisplayMode; + myAddress: string; + onRefreshStateChange?: (refreshing: boolean) => void; + refreshToken?: number; +}; + +type GroupNotificationItem = { + avatarUrl: string | null; + groupId: string; + groupName: string; + id: string; + isEncryptedLike: boolean; + isUnread: boolean; + openedAt: number; + senderLabel: string; + snippet: string; + timestamp: number; +}; + +const sortGroupNotificationItems = ( + left: GroupNotificationItem, + right: GroupNotificationItem +) => { + if (left.isUnread !== right.isUnread) { + return left.isUnread ? -1 : 1; + } + + if (left.isUnread && right.isUnread) { + return right.timestamp - left.timestamp; + } + + if (left.openedAt !== right.openedAt) { + return left.openedAt - right.openedAt; + } + + return right.timestamp - left.timestamp; +}; + +type GroupInviteItem = { + description?: string; + groupId: number; + groupName: string; + id: string; + isOpen?: boolean; + participantCount?: number; +}; + +type GroupJoinRequestItem = { + groupId: number; + groupName: string; + id: string; + joiner: string; + requesterLabel: string; +}; + +type GroupPromotionItem = { + created: number; + description?: string; + groupId: number; + groupName: string; + id: string; + isOpen?: boolean; + memberCount?: number; + promoterName: string; + snippet: string; +}; + +type GroupPromotionVisualState = + | 'connecting' + | 'join' + | 'member' + | 'processing' + | 'request' + | 'request_sent'; + +type PromotionActionState = 'connecting' | 'processing' | 'request_sent'; + +const GROUP_PROMOTION_IDENTIFIER_PREFIX = 'group-promotions-ui24-'; +const GROUP_PROMOTION_MAX_ITEMS = 8; +const GROUP_NOTIFICATION_PREVIEW_LIMIT = 20; +const GROUP_ACTIVITY_MISC_STORAGE_PREFIX = 'group_activity_dismissed'; +/** Latest chat payload to omit from Group Activity list + unread count (system/meta). */ +const GROUP_ACTIVITY_EXCLUDED_MESSAGE_DATA = 'NDAwMQ=='; +const GROUP_WIDGET_CARD_RADIUS = '10px'; + +const getDismissedStorageKey = ( + address: string, + type: 'invites' | 'requests' +) => `${GROUP_ACTIVITY_MISC_STORAGE_PREFIX}:${address}:${type}`; + +const normalizeStoredIds = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return Array.from( + new Set(value.filter((id): id is string => typeof id === 'string' && !!id)) + ); +}; + +const loadMiscStoredIds = async (key: string): Promise => { + if (typeof window === 'undefined') return []; + try { + if (window.miscStorage) { + return normalizeStoredIds(await window.miscStorage.get(key)); + } + const raw = window.localStorage?.getItem(key); + return normalizeStoredIds(raw ? JSON.parse(raw) : []); + } catch (error) { + console.error('[GroupsWidget] Failed to load dismissed ids:', error); + return []; + } +}; + +const saveMiscStoredIds = async (key: string, ids: string[]): Promise => { + if (typeof window === 'undefined') return; + const nextIds = normalizeStoredIds(ids); + try { + if (window.miscStorage) { + await window.miscStorage.set(key, nextIds); + return; + } + window.localStorage?.setItem(key, JSON.stringify(nextIds)); + } catch (error) { + console.error('[GroupsWidget] Failed to save dismissed ids:', error); + } +}; + +const stripHtml = (value: string) => + value + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +const truncateAddress = (value: string) => + value.length > 14 ? `${value.slice(0, 7)}...${value.slice(-5)}` : value; + +const getGroupAvatarUrl = ( + ownerName: string | null, + groupId: string | number +) => + ownerName + ? `${getBaseApiReact()}/arbitrary/THUMBNAIL/${encodeURIComponent(ownerName)}/qortal_group_avatar_${groupId}?async=true` + : null; + +const normalizeSnippet = (value: unknown, fallback: string) => { + if (typeof value !== 'string') { + return fallback; + } + + const cleaned = stripHtml(value); + return cleaned || fallback; +}; + +const isLikelyEncryptedSnippet = (value: unknown) => { + if (typeof value !== 'string') { + return false; + } + + const cleaned = stripHtml(value).trim(); + + if (cleaned.length < 48) { + return false; + } + + const tokens = cleaned.split(/\s+/).filter(Boolean); + const compressed = cleaned.replace(/\s+/g, ''); + const base64LikeChars = compressed.match(/[A-Za-z0-9+/=._-]/g)?.length ?? 0; + const base64LikeRatio = + compressed.length > 0 ? base64LikeChars / compressed.length : 0; + const longestTokenLength = tokens.reduce( + (maxLength, token) => Math.max(maxLength, token.length), + 0 + ); + const averageTokenLength = + tokens.length > 0 + ? tokens.reduce((sum, token) => sum + token.length, 0) / tokens.length + : 0; + + return ( + base64LikeRatio >= 0.9 && + longestTokenLength >= 32 && + (tokens.length <= 3 || averageTokenLength >= 18) + ); +}; + +const getGroupInfo = async (groupId: number) => { + const response = await fetch(`${getBaseApiReact()}/groups/${groupId}`); + + if (!response.ok) { + throw new Error(`Unable to load group ${groupId} (${response.status})`); + } + + return response.json(); +}; + +const hydrateGroupsWithNames = async ( + groups: T[] +) => { + const uniqueGroupIds = [...new Set(groups.map((group) => group.groupId))]; + const groupInfoEntries = await Promise.all( + uniqueGroupIds.map(async (groupId) => { + try { + const groupInfo = await getGroupInfo(groupId); + return [groupId, groupInfo] as const; + } catch (error) { + console.error('Failed to hydrate group metadata for widget', error); + return [groupId, null] as const; + } + }) + ); + const groupInfoById = new Map(groupInfoEntries); + + return groups.map((group) => ({ + ...group, + ...(groupInfoById.get(group.groupId) ?? {}), + })); +}; + +const utf8ToBase64 = (input: string) => + btoa( + encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, code) => + String.fromCharCode(Number(`0x${code}`)) + ) + ); + +const TabButton = ({ + active, + count, + label, + onClick, + tabId, + subtle = false, +}: { + active: boolean; + count?: number; + label: string; + onClick: () => void; + tabId: GroupsWidgetTab; + subtle?: boolean; +}) => { + const theme = useTheme(); + const shouldPulseAttention = + !active && + tabId === 'notifications' && + typeof count === 'number' && + count > 0; + + return ( + *': { + position: 'relative', + zIndex: 1, + }, + '&:hover': { + ...(active ? getBlueTier1PillSurface(theme) : {}), + backgroundColor: active + ? undefined + : alpha( + theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.052 : 0.038 + ), + borderColor: alpha( + theme.palette.border.main, + theme.palette.mode === 'dark' ? 0.2 : 0.14 + ), + color: active ? APP_BLUE_SURFACE_TEXT : theme.palette.text.primary, + opacity: 1, + transform: 'translateY(-1px)', + }, + '&:active': { + transform: 'translateY(0)', + }, + '&:focus-visible': { + borderColor: alpha(theme.palette.primary.main, 0.48), + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.18)}`, + color: active ? APP_BLUE_SURFACE_TEXT : theme.palette.text.primary, + }, + }} + > + {label} + {typeof count === 'number' && count > 0 ? ( + + {count} + + ) : null} + + ); +}; + +const IllustratedEmptyState = ({ + actionLabel, + compact = false, + description, + onAction, + title, + variant, +}: { + actionLabel: string; + compact?: boolean; + description: string; + onAction: () => void; + title: string; + variant: 'invites' | 'requests'; +}) => { + const theme = useTheme(); + + return ( + + + + {title} + + + {description} + + + {actionLabel} + + + ); +}; + +const InlineFeedback = ({ + message, + tone, +}: { + message: string; + tone: 'error' | 'success'; +}) => { + const theme = useTheme(); + + return ( + + {message} + + ); +}; + +export const GroupsWidget = ({ + displayMode, + myAddress, + onRefreshStateChange, + refreshToken = 0, +}: GroupsWidgetProps) => { + const theme = useTheme(); + const { t } = useTranslation('group'); + const { show } = useContext(QORTAL_APP_CONTEXT); + const memberGroups = useAtomValue(memberGroupsAtom); + const myGroupsWhereIAmAdmin = useAtomValue(myGroupsWhereIAmAdminAtom); + const ownerNamesByGroupId = useAtomValue(groupsOwnerNamesAtom) as Record< + string, + string | null + >; + const groupChatTimestamps = useAtomValue(groupChatTimestampsAtom) as Record< + string, + number | undefined + >; + const timestampEnterData = useAtomValue(timestampEnterDataAtom) as Record< + string, + number | undefined + >; + const userInfo = useAtomValue(userInfoAtom); + const [groupInvitesCache, setGroupInvitesCache] = useAtom( + groupInvitesCacheAtom + ); + const [joinRequestsCache, setJoinRequestsCache] = useAtom( + joinRequestsCacheAtom + ); + const groupInvitesCacheRef = useRef(groupInvitesCache); + const joinRequestsCacheRef = useRef(joinRequestsCache); + const [activeTab, setActiveTab] = useState('notifications'); + const [actionFeedback, setActionFeedback] = useState<{ + message: string; + tone: 'error' | 'success'; + } | null>(null); + const [dismissedInviteIds, setDismissedInviteIds] = useState([]); + const [dismissedRequestIds, setDismissedRequestIds] = useState([]); + const [showIgnoredInvites, setShowIgnoredInvites] = useState(false); + const [showIgnoredRequests, setShowIgnoredRequests] = useState(false); + const [invites, setInvites] = useState([]); + const [invitesError, setInvitesError] = useState(null); + const [hasLoadedInvitesOnce, setHasLoadedInvitesOnce] = useState(false); + const [invitesLoading, setInvitesLoading] = useState(false); + const [joiningGroupId, setJoiningGroupId] = useState(null); + const [joiningPromotionGroupId, setJoiningPromotionGroupId] = useState< + number | null + >(null); + const [promotionActionStates, setPromotionActionStates] = useState< + Record + >({}); + const [promotions, setPromotions] = useState([]); + const [promotionsError, setPromotionsError] = useState(null); + const [hasLoadedPromotionsOnce, setHasLoadedPromotionsOnce] = useState(false); + const [promotionsLoading, setPromotionsLoading] = useState(false); + const [promotionDialogOpen, setPromotionDialogOpen] = useState(false); + const [promotionFee, setPromotionFee] = useState(null); + const [promotionGroupId, setPromotionGroupId] = useState(''); + const [promotionText, setPromotionText] = useState(''); + const [publishingPromotion, setPublishingPromotion] = useState(false); + const [requests, setRequests] = useState([]); + const [requestsError, setRequestsError] = useState(null); + const [hasLoadedRequestsOnce, setHasLoadedRequestsOnce] = useState(false); + const [requestsLoading, setRequestsLoading] = useState(false); + const [resolvingRequestId, setResolvingRequestId] = useState( + null + ); + const dismissedInviteStorageKey = useMemo( + () => getDismissedStorageKey(myAddress, 'invites'), + [myAddress] + ); + const dismissedRequestStorageKey = useMemo( + () => getDismissedStorageKey(myAddress, 'requests'), + [myAddress] + ); + const isCompact = displayMode === 'compact'; + const rowPadding = isCompact ? '11px 12px' : '13px 13px'; + const rowGap = isCompact ? '9px' : '11px'; + const bodyGap = isCompact ? '10px' : '13px'; + const messageLineClamp = isCompact ? 1 : 2; + const currentAddress = userInfo?.address; + const isAnyLoading = invitesLoading || requestsLoading || promotionsLoading; + const memberGroupIds = useMemo( + () => + new Set( + [...(memberGroups ?? [])] + .map((group: any) => Number(group?.groupId)) + .filter((groupId) => Number.isFinite(groupId)) + ), + [memberGroups] + ); + + useEffect(() => { + groupInvitesCacheRef.current = groupInvitesCache; + }, [groupInvitesCache]); + + useEffect(() => { + joinRequestsCacheRef.current = joinRequestsCache; + }, [joinRequestsCache]); + + useEffect(() => { + let isMounted = true; + + void (async () => { + try { + const fee = await getFee('ARBITRARY'); + + if (isMounted) { + setPromotionFee(fee?.fee ?? null); + } + } catch (error) { + console.error('Failed to load promotion fee for groups widget', error); + } + })(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + onRefreshStateChange?.(isAnyLoading); + + return () => { + onRefreshStateChange?.(false); + }; + }, [isAnyLoading, onRefreshStateChange]); + + const notificationItems = useMemo(() => { + return [...(memberGroups ?? [])] + .filter( + (group: any) => + group?.groupId != null && + group?.data !== GROUP_ACTIVITY_EXCLUDED_MESSAGE_DATA + ) + .map((group: any) => { + const groupId = String(group.groupId); + const timestamp = + typeof group.timestamp === 'number' ? group.timestamp : 0; + const snippet = normalizeSnippet( + group.data, + t('groups_widget.recent_activity_placeholder') + ); + const groupName = + group.groupId === '0' + ? t('groups_widget.group_general') + : group.groupName || + t('groups_widget.group_named', { id: groupId }); + const senderLabel = + group.sender === currentAddress + ? t('groups_widget.you') + : group.senderName || + truncateAddress( + String(group.sender || t('groups_widget.unknown')) + ); + const isUnread = + !!group.data && + !!groupChatTimestamps[groupId] && + group.sender !== currentAddress && + !!timestamp && + ((!timestampEnterData[groupId] && Date.now() - timestamp < 900000) || + (timestampEnterData[groupId] ?? 0) < timestamp); + const ownerName = ownerNamesByGroupId?.[groupId] ?? null; + + return { + avatarUrl: getGroupAvatarUrl(ownerName, groupId), + groupId, + groupName, + id: `${groupId}:${timestamp}:${group.sender ?? 'unknown'}`, + isEncryptedLike: isLikelyEncryptedSnippet(group.data), + isUnread, + openedAt: timestampEnterData[groupId] ?? 0, + senderLabel, + snippet, + timestamp, + }; + }) + .filter((item) => item.timestamp > 0) + .sort(sortGroupNotificationItems) + .slice(0, GROUP_NOTIFICATION_PREVIEW_LIMIT); + }, [ + currentAddress, + groupChatTimestamps, + memberGroups, + ownerNamesByGroupId, + t, + timestampEnterData, + ]); + + const unreadNotificationCount = useMemo( + () => notificationItems.filter((item) => item.isUnread).length, + [notificationItems] + ); + + const adminGroupIds = useMemo( + () => + [...(myGroupsWhereIAmAdmin ?? [])] + .map((group: any) => group?.groupId) + .filter((groupId): groupId is number => typeof groupId === 'number') + .sort((left, right) => left - right), + [myGroupsWhereIAmAdmin] + ); + + const promotionAdminGroups = useMemo( + () => [...(myGroupsWhereIAmAdmin ?? [])], + [myGroupsWhereIAmAdmin] + ); + + const hasPromotionAdminAccess = promotionAdminGroups.length > 0; + + useEffect(() => { + setPromotionActionStates((currentStates) => { + let didChange = false; + const nextStates = { ...currentStates }; + + for (const promotion of promotions) { + if ( + memberGroupIds.has(Number(promotion.groupId)) && + nextStates[promotion.id] != null + ) { + delete nextStates[promotion.id]; + didChange = true; + } + } + + return didChange ? nextStates : currentStates; + }); + }, [memberGroupIds, promotions]); + + const fetchInvites = useCallback( + async (force = false) => { + if (!myAddress) { + setInvites([]); + setHasLoadedInvitesOnce(true); + setInvitesLoading(false); + return; + } + + const currentCache = groupInvitesCacheRef.current; + const cacheIsFresh = + !force && + currentCache?.address === myAddress && + Date.now() - currentCache.fetchedAt < GROUP_ACTIVITY_CACHE_TTL_MS; + + if (cacheIsFresh && currentCache?.data) { + setInvites( + currentCache.data.map((group: any) => ({ + description: group.description, + groupId: group.groupId, + groupName: + group.groupName ?? + t('groups_widget.group_named', { id: group.groupId }), + id: `invite:${group.groupId}`, + isOpen: group.isOpen, + participantCount: + group.participantCount ?? group.memberCount ?? undefined, + })) + ); + setInvitesError(null); + setHasLoadedInvitesOnce(true); + setInvitesLoading(false); + return; + } + + setInvitesLoading(true); + setInvitesError(null); + + try { + const response = await fetch( + `${getBaseApiReact()}/groups/invites/${myAddress}/?limit=0` + ); + + if (!response.ok) { + throw new Error(`Unable to load invites (${response.status})`); + } + + const data = await response.json(); + const withNames = await hydrateGroupsWithNames( + Array.isArray(data) ? data : [] + ); + const nextInvites = withNames.map((group: any) => ({ + description: group.description, + groupId: group.groupId, + groupName: + group.groupName ?? + t('groups_widget.group_named', { id: group.groupId }), + id: `invite:${group.groupId}`, + isOpen: group.isOpen, + participantCount: + group.participantCount ?? group.memberCount ?? undefined, + })); + + setInvites(nextInvites); + setGroupInvitesCache({ + address: myAddress, + data: withNames, + fetchedAt: Date.now(), + }); + } catch (error) { + console.error('Failed to load group invites widget data', error); + setInvitesError(t('groups_widget.error_load_invites')); + } finally { + setHasLoadedInvitesOnce(true); + setInvitesLoading(false); + } + }, + [myAddress, setGroupInvitesCache, t] + ); + + const fetchJoinRequests = useCallback( + async (force = false) => { + if (!myAddress || adminGroupIds.length === 0) { + setRequests([]); + setHasLoadedRequestsOnce(true); + setRequestsLoading(false); + return; + } + + const currentCache = joinRequestsCacheRef.current; + const cacheIsFresh = + !force && + currentCache != null && + Date.now() - currentCache.fetchedAt < GROUP_ACTIVITY_CACHE_TTL_MS && + currentCache.adminGroupIds.length === adminGroupIds.length && + currentCache.adminGroupIds.every( + (value, index) => value === adminGroupIds[index] + ); + + if (cacheIsFresh && currentCache?.data) { + const nextRequests = currentCache.data.flatMap((entry: any) => + (entry?.data ?? []).map((request: any) => ({ + groupId: entry.group?.groupId, + groupName: + entry.group?.groupName ?? + t('groups_widget.group_named', { id: entry.group?.groupId }), + id: `request:${entry.group?.groupId}:${request?.joiner}`, + joiner: request?.joiner ?? '', + requesterLabel: + request?.name || + truncateAddress( + String(request?.joiner ?? t('groups_widget.unknown')) + ), + })) + ); + setRequests(nextRequests); + setRequestsError(null); + setHasLoadedRequestsOnce(true); + setRequestsLoading(false); + return; + } + + setRequestsLoading(true); + setRequestsError(null); + + try { + const response = await fetch( + `${getBaseApiReact()}/groups/joinrequests/admin/${myAddress}` + ); + + if (!response.ok) { + throw new Error(`Unable to load join requests (${response.status})`); + } + + const data = await response.json(); + const normalized = Array.isArray(data) + ? data.map((entry: any) => ({ + data: entry.joinRequests ?? [], + group: entry.group, + })) + : []; + const nextRequests = normalized.flatMap((entry: any) => + (entry?.data ?? []).map((request: any) => ({ + groupId: entry.group?.groupId, + groupName: + entry.group?.groupName ?? + t('groups_widget.group_named', { id: entry.group?.groupId }), + id: `request:${entry.group?.groupId}:${request?.joiner}`, + joiner: request?.joiner ?? '', + requesterLabel: + request?.name || + truncateAddress( + String(request?.joiner ?? t('groups_widget.unknown')) + ), + })) + ); + + setRequests(nextRequests); + setJoinRequestsCache({ + adminGroupIds: [...adminGroupIds], + data: normalized, + fetchedAt: Date.now(), + }); + } catch (error) { + console.error('Failed to load group join requests widget data', error); + setRequestsError(t('groups_widget.error_load_requests')); + } finally { + setHasLoadedRequestsOnce(true); + setRequestsLoading(false); + } + }, + [adminGroupIds, myAddress, setJoinRequestsCache, t] + ); + + const fetchPromotions = useCallback(async () => { + setPromotionsLoading(true); + setPromotionsError(null); + + try { + const url = `${getBaseApiReact()}${getArbitraryEndpointReact()}?mode=ALL&service=DOCUMENT&identifier=${GROUP_PROMOTION_IDENTIFIER_PREFIX}&limit=18&includemetadata=false&reverse=true&prefix=true`; + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`Unable to load promotions (${response.status})`); + } + + const resources = await response.json(); + + if (!Array.isArray(resources)) { + setPromotions([]); + setHasLoadedPromotionsOnce(true); + return; + } + + const promotionCandidates = resources + .filter( + (resource: any) => + resource?.identifier && + resource?.service === 'DOCUMENT' && + typeof resource?.created === 'number' && + (!resource?.size || resource.size < 260) + ) + .slice(0, 18); + + const hydrated = await Promise.all( + promotionCandidates.map(async (resource: any) => { + const textResponse = await fetch( + `${getBaseApiReact()}/arbitrary/${resource.service}/${resource.name}/${resource.identifier}`, + { + method: 'GET', + } + ); + + if (!textResponse.ok) { + return null; + } + + const groupIdMatch = /group-(\d+)-/.exec(resource.identifier); + const groupId = groupIdMatch ? Number(groupIdMatch[1]) : NaN; + + if (!Number.isFinite(groupId)) { + return null; + } + + return { + created: resource.created, + data: await textResponse.text(), + groupId, + identifier: resource.identifier, + name: resource.name, + }; + }) + ); + + const uniqueGroupIds = new Set(); + const normalized = hydrated + .filter((item): item is NonNullable => item != null) + .sort((left, right) => right.created - left.created) + .filter((item) => { + if (uniqueGroupIds.has(item.groupId)) { + return false; + } + + uniqueGroupIds.add(item.groupId); + return true; + }) + .slice(0, GROUP_PROMOTION_MAX_ITEMS); + + const withGroupNames = await hydrateGroupsWithNames(normalized); + setPromotions( + withGroupNames.map((promotion: any) => ({ + created: promotion.created, + description: promotion.description, + groupId: promotion.groupId, + groupName: + promotion.groupName ?? + t('groups_widget.group_named', { id: promotion.groupId }), + id: `promotion:${promotion.identifier}`, + isOpen: + typeof promotion.isOpen === 'boolean' + ? promotion.isOpen + : undefined, + memberCount: + promotion.memberCount ?? promotion.participantCount ?? undefined, + promoterName: promotion.name ?? t('groups_widget.unknown'), + snippet: normalizeSnippet( + promotion.data, + t('groups_widget.fresh_promotion_snippet') + ), + })) + ); + setHasLoadedPromotionsOnce(true); + } catch (error) { + console.error('Failed to load group promotions widget data', error); + setPromotionsError(t('groups_widget.error_load_promotions')); + } finally { + setHasLoadedPromotionsOnce(true); + setPromotionsLoading(false); + } + }, [t]); + + useEffect(() => { + let cancelled = false; + setActionFeedback(null); + setShowIgnoredInvites(false); + setShowIgnoredRequests(false); + void (async () => { + const [storedInvites, storedRequests] = await Promise.all([ + loadMiscStoredIds(dismissedInviteStorageKey), + loadMiscStoredIds(dismissedRequestStorageKey), + ]); + if (cancelled) return; + setDismissedInviteIds(storedInvites); + setDismissedRequestIds(storedRequests); + })(); + void fetchInvites(false); + void fetchJoinRequests(false); + return () => { + cancelled = true; + }; + }, [ + dismissedInviteStorageKey, + dismissedRequestStorageKey, + fetchInvites, + fetchJoinRequests, + ]); + + useEffect(() => { + if (!hasLoadedInvitesOnce || invitesError || invites.length === 0) return; + const liveIds = new Set(invites.map((invite) => invite.id)); + setDismissedInviteIds((current) => { + const next = current.filter((id) => liveIds.has(id)); + if (next.length === current.length) return current; + void saveMiscStoredIds(dismissedInviteStorageKey, next); + return next; + }); + }, [dismissedInviteStorageKey, hasLoadedInvitesOnce, invites, invitesError]); + + useEffect(() => { + if (!hasLoadedRequestsOnce || requestsError || requests.length === 0) return; + const liveIds = new Set(requests.map((request) => request.id)); + setDismissedRequestIds((current) => { + const next = current.filter((id) => liveIds.has(id)); + if (next.length === current.length) return current; + void saveMiscStoredIds(dismissedRequestStorageKey, next); + return next; + }); + }, [ + dismissedRequestStorageKey, + hasLoadedRequestsOnce, + requests, + requestsError, + ]); + + useEffect(() => { + if (refreshToken === 0) { + return; + } + + setActionFeedback(null); + void fetchInvites(true); + void fetchJoinRequests(true); + }, [fetchInvites, fetchJoinRequests, refreshToken]); + + useEffect(() => { + if (activeTab !== 'promoted') { + return; + } + void fetchPromotions(); + }, [activeTab, fetchPromotions, refreshToken]); + + const visibleInvites = useMemo( + () => invites.filter((invite) => !dismissedInviteIds.includes(invite.id)), + [dismissedInviteIds, invites] + ); + const ignoredInvites = useMemo( + () => invites.filter((invite) => dismissedInviteIds.includes(invite.id)), + [dismissedInviteIds, invites] + ); + + const visibleRequests = useMemo( + () => + requests.filter((request) => !dismissedRequestIds.includes(request.id)), + [dismissedRequestIds, requests] + ); + const ignoredRequests = useMemo( + () => + requests.filter((request) => dismissedRequestIds.includes(request.id)), + [dismissedRequestIds, requests] + ); + + const showInitialInvitesLoading = + invitesLoading && !hasLoadedInvitesOnce && visibleInvites.length === 0; + const showInitialRequestsLoading = + requestsLoading && !hasLoadedRequestsOnce && visibleRequests.length === 0; + const showInitialPromotionsLoading = + promotionsLoading && !hasLoadedPromotionsOnce && promotions.length === 0; + + const handleOpenGroupChat = useCallback((groupId: string) => { + executeEvent('openGroupMessage', { + from: groupId, + }); + }, []); + + const handleOpenGroupDiscovery = useCallback(() => { + executeEvent('open-group-discovery', {}); + }, []); + + const handleAcceptInvite = useCallback( + async (invite: GroupInviteItem) => { + try { + const fee = await getFee('JOIN_GROUP'); + await show({ + message: t('groups_widget.confirm_join_group'), + publishFee: `${fee.fee} QORT`, + }); + setJoiningGroupId(invite.groupId); + setActionFeedback(null); + + const response = await window.sendMessage('joinGroup', { + groupId: invite.groupId, + }); + + if (response?.error) { + throw new Error(response.error); + } + + setInvites((current) => + current.filter((currentInvite) => currentInvite.id !== invite.id) + ); + setDismissedInviteIds((current) => { + const next = current.filter((id) => id !== invite.id); + void saveMiscStoredIds(dismissedInviteStorageKey, next); + return next; + }); + setGroupInvitesCache(null); + setActionFeedback({ + message: t('groups_widget.joined_group', { name: invite.groupName }), + tone: 'success', + }); + } catch (error: any) { + console.error('Failed to join group invite from widget', error); + setActionFeedback({ + message: error?.message || t('groups_widget.error_accept_invite'), + tone: 'error', + }); + } finally { + setJoiningGroupId(null); + } + }, + [dismissedInviteStorageKey, setGroupInvitesCache, show, t] + ); + + const handleIgnoreInvite = useCallback( + async (inviteId: string) => { + const next = dismissedInviteIds.includes(inviteId) + ? dismissedInviteIds + : [...dismissedInviteIds, inviteId]; + await saveMiscStoredIds(dismissedInviteStorageKey, next); + setDismissedInviteIds(next); + setActionFeedback({ + message: t('groups_widget.invite_hidden'), + tone: 'success', + }); + }, + [dismissedInviteIds, dismissedInviteStorageKey, t] + ); + + const handleRestoreInvite = useCallback( + async (inviteId: string) => { + const next = dismissedInviteIds.filter((id) => id !== inviteId); + await saveMiscStoredIds(dismissedInviteStorageKey, next); + setDismissedInviteIds(next); + }, + [dismissedInviteIds, dismissedInviteStorageKey] + ); + + const handleApproveRequest = useCallback( + async (request: GroupJoinRequestItem) => { + try { + const fee = await getFee('GROUP_INVITE'); + await show({ + message: t('groups_widget.confirm_approve_request'), + publishFee: `${fee.fee} QORT`, + }); + setResolvingRequestId(request.id); + setActionFeedback(null); + + const response = await window.sendMessage('inviteToGroup', { + groupId: request.groupId, + inviteTime: 10800, + qortalAddress: request.joiner, + }); + + if (response?.error) { + throw new Error(response.error); + } + + setRequests((current) => + current.filter( + (currentRequest) => currentRequest.id !== request.id + ) + ); + setDismissedRequestIds((current) => { + const next = current.filter((id) => id !== request.id); + void saveMiscStoredIds(dismissedRequestStorageKey, next); + return next; + }); + setJoinRequestsCache(null); + setActionFeedback({ + message: t('groups_widget.request_approved', { + requester: request.requesterLabel, + group: request.groupName, + }), + tone: 'success', + }); + } catch (error: any) { + console.error('Failed to approve join request from widget', error); + setActionFeedback({ + message: error?.message || t('groups_widget.error_approve_request'), + tone: 'error', + }); + } finally { + setResolvingRequestId(null); + } + }, + [dismissedRequestStorageKey, setJoinRequestsCache, show, t] + ); + + const handleRejectRequest = useCallback( + async (requestId: string) => { + const next = dismissedRequestIds.includes(requestId) + ? dismissedRequestIds + : [...dismissedRequestIds, requestId]; + await saveMiscStoredIds(dismissedRequestStorageKey, next); + setDismissedRequestIds(next); + setActionFeedback({ + message: t('groups_widget.request_removed'), + tone: 'success', + }); + }, + [dismissedRequestIds, dismissedRequestStorageKey, t] + ); + + const handleRestoreRequest = useCallback( + async (requestId: string) => { + const next = dismissedRequestIds.filter((id) => id !== requestId); + await saveMiscStoredIds(dismissedRequestStorageKey, next); + setDismissedRequestIds(next); + }, + [dismissedRequestIds, dismissedRequestStorageKey] + ); + + const handleJoinPromotedGroup = useCallback( + async (promotion: GroupPromotionItem) => { + try { + const fee = await getFee('JOIN_GROUP'); + await show({ + message: promotion.isOpen + ? t('groups_widget.confirm_join_promoted_open') + : t('groups_widget.confirm_join_promoted_closed'), + publishFee: `${fee.fee} QORT`, + }); + setPromotionActionStates((currentStates) => ({ + ...currentStates, + [promotion.id]: 'connecting', + })); + setJoiningPromotionGroupId(promotion.groupId); + setActionFeedback(null); + + const response = await window.sendMessage('joinGroup', { + groupId: promotion.groupId, + }); + + if (response?.error) { + throw new Error(response.error); + } + + setPromotionActionStates((currentStates) => ({ + ...currentStates, + [promotion.id]: + promotion.isOpen === false ? 'request_sent' : 'processing', + })); + setActionFeedback({ + message: promotion.isOpen + ? t('groups_widget.joined_promoted', { name: promotion.groupName }) + : t('groups_widget.join_request_sent', { + name: promotion.groupName, + }), + tone: 'success', + }); + } catch (error: any) { + console.error('Failed to join promoted group from widget', error); + setPromotionActionStates((currentStates) => { + const nextStates = { ...currentStates }; + delete nextStates[promotion.id]; + return nextStates; + }); + setActionFeedback({ + message: error?.message || t('groups_widget.error_promoted'), + tone: 'error', + }); + } finally { + setJoiningPromotionGroupId(null); + } + }, + [show, t] + ); + + const handlePublishPromotion = useCallback(async () => { + try { + if (!promotionGroupId || !promotionText.trim()) { + return; + } + + setPublishingPromotion(true); + setActionFeedback(null); + + const identifier = `group-promotions-ui24-group-${promotionGroupId}-${Date.now().toString(36)}`; + + const response = await window.sendMessage('publishOnQDN', { + data: utf8ToBase64(promotionText.trim()), + identifier, + service: 'DOCUMENT', + uploadType: 'base64', + }); + + if (response?.error) { + throw new Error(response.error); + } + + setPromotionDialogOpen(false); + setPromotionGroupId(''); + setPromotionText(''); + setActionFeedback({ + message: t('groups_widget.promotion_published'), + tone: 'success', + }); + await fetchPromotions(); + setActiveTab('promoted'); + } catch (error: any) { + console.error('Failed to publish group promotion from widget', error); + setActionFeedback({ + message: error?.message || t('groups_widget.error_publish_promotion'), + tone: 'error', + }); + } finally { + setPublishingPromotion(false); + } + }, [fetchPromotions, promotionGroupId, promotionText, t]); + + const handleOpenPromotionDialog = useCallback(() => { + if (!hasPromotionAdminAccess) { + return; + } + + setPromotionGroupId((currentValue) => { + if (currentValue) { + return currentValue; + } + + const firstGroupId = promotionAdminGroups[0]?.groupId; + return firstGroupId != null ? String(firstGroupId) : currentValue; + }); + setPromotionDialogOpen(true); + }, [hasPromotionAdminAccess, promotionAdminGroups]); + + const sharedScrollerSx = { + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + gap: bodyGap, + minHeight: 0, + // Virtualized lists adjust total height as rows measure — disable scroll anchoring. + overflowAnchor: 'none', + overflowY: 'auto', + + pr: '4px', + scrollbarColor: `${alpha(theme.palette.text.secondary, 0.3)} transparent`, + scrollbarWidth: 'thin', + '&::-webkit-scrollbar': { + width: '10px', + }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: alpha(theme.palette.text.secondary, 0.24), + border: '3px solid transparent', + borderRadius: '999px', + backgroundClip: 'padding-box', + }, + } as const; + + const promotionPrimaryActionSx = { + ...getBlueTier1ButtonSx(), + borderRadius: '999px', + fontSize: '0.67rem', + fontWeight: 800, + minHeight: '27px', + minWidth: '102px', + px: 1.15, + py: 0.35, + textTransform: 'none', + whiteSpace: 'nowrap', + } as const; + + const promotionSecondaryActionSx = { + alignItems: 'center', + backgroundColor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.045) + : alpha(theme.palette.text.primary, 0.045), + border: `1px solid ${alpha( + theme.palette.border.main, + theme.palette.mode === 'dark' ? 0.22 : 0.14 + )}`, + borderRadius: '999px', + color: theme.palette.text.secondary, + display: 'inline-flex', + fontSize: '0.67rem', + fontWeight: 700, + justifyContent: 'center', + minHeight: '27px', + minWidth: '102px', + px: 1.05, + py: 0.35, + textTransform: 'none', + whiteSpace: 'nowrap', + '&.Mui-disabled': { + color: alpha(theme.palette.text.secondary, 0.84), + opacity: 1, + }, + } as const; + + const ignoredItemsActionSx = { + alignItems: 'center', + border: `1px solid ${alpha( + theme.palette.border.main, + theme.palette.mode === 'dark' ? 0.22 : 0.14 + )}`, + borderRadius: '999px', + color: theme.palette.text.secondary, + display: 'inline-flex', + flexShrink: 0, + fontSize: '0.7rem', + fontWeight: 700, + minHeight: '30px', + px: 1.25, + textTransform: 'none', + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + }, + } as const; + + const headerUtilityActionSx = { + alignItems: 'center', + backgroundColor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.03) + : alpha(theme.palette.text.primary, 0.035), + border: `1px solid ${alpha( + theme.palette.border.main, + theme.palette.mode === 'dark' ? 0.16 : 0.1 + )}`, + borderRadius: '999px', + color: theme.palette.text.secondary, + display: 'inline-flex', + fontSize: '0.69rem', + fontWeight: 700, + gap: '6px', + minHeight: '28px', + px: 1.15, + textTransform: 'none', + transition: + 'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 120ms ease', + whiteSpace: 'nowrap', + '&:hover': { + backgroundColor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.05) + : alpha(theme.palette.text.primary, 0.05), + borderColor: alpha( + theme.palette.border.main, + theme.palette.mode === 'dark' ? 0.24 : 0.16 + ), + color: theme.palette.text.primary, + transform: 'translateY(-1px)', + }, + '&:active': { + transform: 'translateY(0)', + }, + '&.Mui-disabled': { + color: alpha(theme.palette.text.secondary, 0.7), + opacity: 0.72, + }, + } as const; + + const discoverGroupsActionSx = { + ...headerUtilityActionSx, + background: + theme.palette.mode === 'dark' + ? 'rgba(88, 122, 178, 0.34)' + : 'rgba(117, 161, 227, 0.18)', + border: `1px solid ${alpha( + '#8FB8F3', + theme.palette.mode === 'dark' ? 0.18 : 0.24 + )}`, + boxShadow: + theme.palette.mode === 'dark' + ? '0 0 0 1px rgba(255,255,255,0.028) inset, 0 0 12px rgba(132,175,240,0.1)' + : '0 0 0 1px rgba(255,255,255,0.05) inset, 0 0 10px rgba(132,175,240,0.08)', + color: + theme.palette.mode === 'dark' + ? alpha('#8FB8F3', 0.92) + : alpha('#5A8FE0', 0.9), + '&:hover': { + ...getBlueTier1ButtonSx()['&:hover'], + borderColor: 'rgba(143, 184, 243, 0.22)', + color: APP_BLUE_SURFACE_TEXT, + transform: 'translateY(-1px)', + }, + '&:active': { + ...getBlueTier1ButtonSx()['&:active'], + transform: 'scale(0.97)', + }, + } as const; + + const widgetItemSurfaceColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(45, 49, 60, 0.9) 0%, rgba(36, 40, 50, 0.96) 100%)' + : alpha(theme.palette.text.primary, 0.036); + + const widgetItemHoverSurfaceColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(49, 54, 66, 0.94) 0%, rgba(39, 43, 53, 0.98) 100%)' + : alpha(theme.palette.text.primary, 0.048); + + const widgetItemBorderColor = + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.06)' + : alpha(theme.palette.border.main, 0.12); + + const widgetItemHoverBorderColor = + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.085)' + : alpha(theme.palette.border.main, 0.18); + + const widgetItemInsetShadow = + theme.palette.mode === 'dark' + ? `0 10px 24px rgba(0,0,0,0.18), inset 0 1px 0 ${alpha(theme.palette.common.white, 0.045)}` + : `inset 0 1px 0 ${alpha(theme.palette.common.white, 0.72)}`; + const readNotificationSurfaceColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(43, 47, 58, 0.88) 0%, rgba(34, 38, 48, 0.95) 100%)' + : alpha(theme.palette.text.primary, 0.03); + const readNotificationHoverSurfaceColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(47, 52, 63, 0.92) 0%, rgba(37, 41, 51, 0.98) 100%)' + : alpha(theme.palette.text.primary, 0.04); + const unreadNotificationSurfaceColor = + theme.palette.mode === 'dark' + ? `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.14)} 0%, rgba(40, 49, 63, 0.94) 20%, rgba(34, 40, 50, 0.98) 100%)` + : alpha(theme.palette.primary.main, 0.03); + const unreadNotificationHoverSurfaceColor = + theme.palette.mode === 'dark' + ? `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.18)} 0%, rgba(44, 53, 68, 0.97) 22%, rgba(37, 43, 54, 1) 100%)` + : alpha(theme.palette.primary.main, 0.04); + const unreadNotificationBorderColor = + theme.palette.mode === 'dark' + ? alpha(theme.palette.primary.main, 0.16) + : alpha(theme.palette.primary.main, 0.085); + + const effectiveNotificationItems = useMemo( + () => [...notificationItems].sort(sortGroupNotificationItems), + [notificationItems] + ); + const effectiveInvites = showIgnoredInvites ? ignoredInvites : visibleInvites; + const effectiveRequests = showIgnoredRequests + ? ignoredRequests + : visibleRequests; + const effectivePromotions = promotions; + const effectiveUnreadNotificationCount = unreadNotificationCount; + + const getPromotionVisualState = useCallback( + ( + promotion: GroupPromotionItem, + isMember: boolean + ): GroupPromotionVisualState => { + if (isMember) { + return 'member'; + } + + if (joiningPromotionGroupId === promotion.groupId) { + return 'connecting'; + } + + return ( + promotionActionStates[promotion.id] ?? + (promotion.isOpen === false ? 'request' : 'join') + ); + }, + [joiningPromotionGroupId, promotionActionStates] + ); + + const groupsListScrollRef = useRef(null); + const bodyGapPx = isCompact ? 10 : 13; + + const virtualListLength = + activeTab === 'notifications' + ? effectiveNotificationItems.length + : activeTab === 'invites' + ? effectiveInvites.length + : activeTab === 'requests' + ? effectiveRequests.length + : effectivePromotions.length; + + const rowVirtualizer = useVirtualizer({ + count: virtualListLength, + getScrollElement: () => groupsListScrollRef.current, + estimateSize: useCallback(() => { + switch (activeTab) { + case 'notifications': + return isCompact ? 132 : 152; + case 'invites': + return isCompact ? 168 : 182; + case 'requests': + return isCompact ? 185 : 200; + case 'promoted': + return isCompact ? 210 : 230; + default: + return 160; + } + }, [activeTab, isCompact]), + getItemKey: useCallback( + (index: number) => { + switch (activeTab) { + case 'notifications': + return `n:${effectiveNotificationItems[index]?.id ?? index}`; + case 'invites': + return `i:${effectiveInvites[index]?.id ?? index}`; + case 'requests': + return `r:${effectiveRequests[index]?.id ?? index}`; + case 'promoted': + return `p:${effectivePromotions[index]?.id ?? index}`; + default: + return `x:${index}`; + } + }, + [ + activeTab, + effectiveNotificationItems, + effectiveInvites, + effectiveRequests, + effectivePromotions, + ] + ), + overscan: 6, + }); + + const renderVirtualizedList = ( + itemCount: number, + renderRow: (index: number) => ReactElement | null + ) => ( + + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const node = renderRow(virtualRow.index); + if (!node) { + return null; + } + return ( + + {node} + + ); + })} + + + + ); + + const renderNotificationList = () => ( + 0} + isEmpty={effectiveNotificationItems.length === 0} + isLoading={false} + loadingLabel={t('groups_widget.loading_notifications')} + onSecondaryAction={handleOpenGroupDiscovery} + secondaryActionLabel={t('groups_widget.discover_groups')} + secondaryActionVariant="link" + stateVerticalOffset="-24px" + > + + {actionFeedback ? ( + + ) : null} + {renderVirtualizedList(effectiveNotificationItems.length, (index) => { + const item = effectiveNotificationItems[index]; + if (!item) return null; + + return ( + handleOpenGroupChat(item.groupId)} + sx={{ + alignItems: 'flex-start', + background: item.isUnread + ? unreadNotificationSurfaceColor + : readNotificationSurfaceColor, + border: `1px solid ${ + item.isUnread + ? unreadNotificationBorderColor + : widgetItemBorderColor + }`, + borderRadius: GROUP_WIDGET_CARD_RADIUS, + boxShadow: widgetItemInsetShadow, + display: 'flex', + flexShrink: 0, + gap: rowGap, + overflow: 'hidden', + p: rowPadding, + position: 'relative', + textAlign: 'left', + transition: + 'background 140ms ease, border-color 140ms ease, transform 120ms ease, box-shadow 140ms ease', + width: '100%', + '&::before': item.isUnread + ? { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.34 : 0.28 + ), + borderBottomLeftRadius: GROUP_WIDGET_CARD_RADIUS, + borderTopLeftRadius: GROUP_WIDGET_CARD_RADIUS, + content: '""', + left: 0, + position: 'absolute', + top: 0, + bottom: 0, + width: '3px', + } + : undefined, + '&:hover': { + background: item.isUnread + ? unreadNotificationHoverSurfaceColor + : readNotificationHoverSurfaceColor, + borderColor: item.isUnread + ? alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.18 : 0.13 + ) + : widgetItemHoverBorderColor, + boxShadow: + theme.palette.mode === 'dark' + ? `0 14px 28px rgba(0,0,0,0.22), inset 0 1px 0 ${alpha(theme.palette.common.white, 0.05)}` + : widgetItemInsetShadow, + transform: 'translateY(-1px)', + }, + }} + > + + {item.groupName.charAt(0).toUpperCase()} + + + + + + + {item.groupName} + + {item.isUnread ? ( + + ) : null} + + + {formatTimestamp(item.timestamp)} + + + + + {t('groups_widget.from_label')} + + + {item.senderLabel} + + + + {item.isEncryptedLike ? ( + + + + {t('groups_widget.new_encrypted_message')} + + + ) : ( + + {item.snippet} + + )} + + + + {item.isEncryptedLike + ? t('groups_widget.view_conversation') + : t('groups_widget.open_conversation')} + + + + ); + })} + + + ); + + const renderInvitesList = () => ( + 0} + isEmpty={false} + isLoading={showInitialInvitesLoading} + loadingLabel={t('groups_widget.loading_invites')} + onRetry={() => void fetchInvites(true)} + stateVerticalOffset="-24px" + > + + {actionFeedback ? ( + + ) : null} + {(ignoredInvites.length > 0 || showIgnoredInvites) && ( + + + {showIgnoredInvites + ? t('groups_widget.ignored_invites', { + count: ignoredInvites.length, + defaultValue: 'Ignored invites ({{count}})', + }) + : t('groups_widget.hidden_invites_count', { + count: ignoredInvites.length, + defaultValue: '{{count}} ignored', + })} + + setShowIgnoredInvites((prev) => !prev)} + sx={ignoredItemsActionSx} + > + {showIgnoredInvites + ? t('groups_widget.back_to_invites', { + defaultValue: 'Back to invites', + }) + : t('groups_widget.show_ignored', { + defaultValue: 'Show ignored', + })} + + + )} + {effectiveInvites.length === 0 && + !showInitialInvitesLoading && + !invitesError ? ( + void fetchInvites(true)} + title={ + showIgnoredInvites + ? t('groups_widget.ignored_invites_empty_title', { + defaultValue: 'No ignored invites', + }) + : t('groups_widget.invites_empty_title') + } + variant="invites" + /> + ) : ( + renderVirtualizedList(effectiveInvites.length, (index) => { + const invite = effectiveInvites[index]; + if (!invite) return null; + + return ( + + + + + + + + {invite.groupName} + + + {invite.description + ? normalizeSnippet( + invite.description, + t('groups_widget.private_group_invite') + ) + : invite.participantCount != null + ? t('groups_widget.members_count', { + count: invite.participantCount, + }) + : t('groups_widget.invitation_ready')} + + + + + + void (showIgnoredInvites + ? handleRestoreInvite(invite.id) + : handleIgnoreInvite(invite.id)) + } + sx={ignoredItemsActionSx} + > + {showIgnoredInvites + ? t('groups_widget.restore', { + defaultValue: 'Restore', + }) + : t('groups_widget.ignore')} + + void handleAcceptInvite(invite)} + sx={{ + borderRadius: '999px', + fontSize: '0.7rem', + fontWeight: 800, + minHeight: '30px', + px: 1.35, + textTransform: 'none', + }} + variant="contained" + > + {t('groups_widget.accept')} + + + + ); + }) + )} + + + ); + + const renderRequestsList = () => ( + 0} + isEmpty={false} + isLoading={showInitialRequestsLoading} + loadingLabel={t('groups_widget.loading_requests')} + onRetry={() => void fetchJoinRequests(true)} + stateVerticalOffset="-24px" + > + + {actionFeedback ? ( + + ) : null} + {(ignoredRequests.length > 0 || showIgnoredRequests) && ( + + + {showIgnoredRequests + ? t('groups_widget.ignored_requests', { + count: ignoredRequests.length, + defaultValue: 'Ignored requests ({{count}})', + }) + : t('groups_widget.hidden_requests_count', { + count: ignoredRequests.length, + defaultValue: '{{count}} ignored', + })} + + setShowIgnoredRequests((prev) => !prev)} + sx={ignoredItemsActionSx} + > + {showIgnoredRequests + ? t('groups_widget.back_to_requests', { + defaultValue: 'Back to requests', + }) + : t('groups_widget.show_ignored', { + defaultValue: 'Show ignored', + })} + + + )} + {effectiveRequests.length === 0 && + !showInitialRequestsLoading && + !requestsError ? ( + void fetchJoinRequests(true)} + title={ + showIgnoredRequests + ? t('groups_widget.ignored_requests_empty_title', { + defaultValue: 'No ignored requests', + }) + : t('groups_widget.requests_empty_title') + } + variant="requests" + /> + ) : ( + renderVirtualizedList(effectiveRequests.length, (index) => { + const request = effectiveRequests[index]; + if (!request) return null; + + return ( + + + + + + + + {request.groupName} + + + {request.requesterLabel} + + + {t('groups_widget.wants_to_join')} + + + + + + void (showIgnoredRequests + ? handleRestoreRequest(request.id) + : handleRejectRequest(request.id)) + } + sx={ignoredItemsActionSx} + > + {showIgnoredRequests + ? t('groups_widget.restore', { + defaultValue: 'Restore', + }) + : t('groups_widget.ignore')} + + void handleApproveRequest(request)} + sx={{ + borderRadius: '999px', + fontSize: '0.7rem', + fontWeight: 800, + minHeight: '30px', + px: 1.35, + textTransform: 'none', + }} + variant="contained" + > + {t('groups_widget.approve')} + + + + ); + }) + )} + + + ); + + const renderPromotionsList = () => ( + 0} + isEmpty={ + !showInitialPromotionsLoading && effectivePromotions.length === 0 + } + isLoading={showInitialPromotionsLoading} + loadingLabel={t('groups_widget.loading_promoted')} + onRetry={() => void fetchPromotions()} + stateVerticalOffset="-24px" + > + + {actionFeedback ? ( + + ) : null} + {renderVirtualizedList(effectivePromotions.length, (index) => { + const promotion = effectivePromotions[index]; + if (!promotion) return null; + + const isMember = memberGroupIds.has(Number(promotion.groupId)); + const promotionVisualState = getPromotionVisualState( + promotion, + isMember + ); + + return ( + + + + + + + + {promotion.groupName} + + + {t('groups_widget.promoted_by', { + name: promotion.promoterName, + })} + + + + {formatTimestamp(promotion.created)} + + + + {promotion.snippet} + + + + + {promotion.memberCount != null + ? t('groups_widget.members_count', { + count: promotion.memberCount, + }) + : promotion.isOpen === false + ? t('groups_widget.private_group') + : t('groups_widget.public_group')} + + + + {promotionVisualState === 'member' ? ( + + ) : promotionVisualState === 'connecting' ? ( + + {t('groups_widget.connecting')} + + ) : promotionVisualState === 'processing' ? ( + + ) : promotionVisualState === 'request_sent' ? ( + + ) : ( + + )} + + + + ); + })} + + + ); + + const invitesCount = visibleInvites.length; + const requestsCount = visibleRequests.length; + + return ( + + + + { + setActiveTab('notifications'); + }} + tabId="notifications" + /> + { + setActiveTab('invites'); + }} + tabId="invites" + /> + { + setActiveTab('requests'); + }} + tabId="requests" + /> + { + setActiveTab('promoted'); + }} + tabId="promoted" + subtle + /> + + + + + {t('groups_widget.discover_groups')} + + {activeTab === 'promoted' ? ( + + + + + {t('groups_widget.promote_group')} + + + + ) : null} + + + + + {activeTab === 'notifications' + ? renderNotificationList() + : activeTab === 'invites' + ? renderInvitesList() + : activeTab === 'requests' + ? renderRequestsList() + : renderPromotionsList()} + + + { + if (!publishingPromotion) { + setPromotionDialogOpen(false); + } + }} + > + + {t('groups_widget.promote_group')} + + + + {t('groups_widget.promote_dialog_intro')} + {promotionFee + ? t('groups_widget.promote_dialog_fee', { fee: promotionFee }) + : ''} + + + { + setPromotionText(event.target.value.slice(0, 200)); + }} + /> + + {t('groups_widget.characters_count', { + current: promotionText.length, + max: 200, + })} + + + + + { + void handlePublishPromotion(); + }} + sx={{ + borderRadius: '999px', + px: 1.8, + textTransform: 'none', + ...getBlueTier1ButtonSx(), + }} + > + {t('groups_widget.publish')} + + + + + ); +}; diff --git a/src/components/Widgets/QAppWidgetContainer.tsx b/src/components/Widgets/QAppWidgetContainer.tsx new file mode 100644 index 00000000..dbc843f9 --- /dev/null +++ b/src/components/Widgets/QAppWidgetContainer.tsx @@ -0,0 +1,312 @@ +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; +import { Box, ButtonBase, CircularProgress, Typography, useTheme } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +type QAppWidgetContainerProps = { + children?: ReactNode; + emptyMessage?: string | null; + emptyTitle?: string; + error?: string | null; + errorMessage?: string | null; + errorTitle?: string; + hasContent?: boolean; + isEmpty?: boolean; + isLoading?: boolean; + loadingLabel?: string; + loadingMessage?: string | null; + onRetry?: () => void; + onSecondaryAction?: () => void; + retryLabel?: string; + secondaryActionLabel?: string; + secondaryActionVariant?: 'button' | 'link'; + stateVerticalOffset?: number | string; +}; + +export const QAppWidgetStatePanel = ({ + description, + loadingLabel, + onRetry, + onSecondaryAction, + retryLabel, + secondaryActionLabel, + secondaryActionVariant = 'button', + title, + verticalOffset = 0, +}: { + description?: string | null; + loadingLabel?: string; + onRetry?: () => void; + onSecondaryAction?: () => void; + retryLabel?: string; + secondaryActionLabel?: string; + secondaryActionVariant?: 'button' | 'link'; + title: string; + verticalOffset?: number | string; +}) => { + const theme = useTheme(); + const { t } = useTranslation('core'); + const resolvedRetryLabel = retryLabel ?? t('widget_container.retry'); + const translateValue = + typeof verticalOffset === 'number' ? `${verticalOffset}px` : verticalOffset; + + return ( + + {loadingLabel ? ( + + ) : null} + + {title} + + {description ? ( + + {description} + + ) : null} + {onRetry || onSecondaryAction ? ( + + {onRetry ? ( + + + + {resolvedRetryLabel} + + + ) : null} + {onSecondaryAction && secondaryActionLabel ? ( + + + {secondaryActionLabel} + + {secondaryActionVariant === 'link' ? ( + + ) : null} + + ) : null} + + ) : null} + + ); +}; + +export const QAppWidgetContainer = ({ + children, + emptyMessage, + emptyTitle, + error = null, + errorMessage, + errorTitle, + hasContent, + isEmpty = false, + isLoading = false, + loadingLabel, + loadingMessage, + onRetry, + onSecondaryAction, + retryLabel, + secondaryActionLabel, + secondaryActionVariant = 'button', + stateVerticalOffset = 0, +}: QAppWidgetContainerProps) => { + const { t } = useTranslation('core'); + const resolvedRetryLabel = retryLabel ?? t('widget_container.retry'); + const resolvedEmptyTitle = emptyTitle ?? t('widget_container.empty_title'); + const resolvedEmptyMessage = + emptyMessage === undefined ? t('widget_container.empty_message') : emptyMessage; + const resolvedErrorTitle = errorTitle ?? t('widget_container.error_title'); + const resolvedLoadingLabel = loadingLabel ?? t('widget_container.loading'); + const resolvedLoadingMessage = + loadingMessage === undefined ? t('widget_container.loading_message') : loadingMessage; + const resolvedHasContent = hasContent ?? (!!children && !isEmpty); + + let state: ReactNode = null; + + if (isLoading && !resolvedHasContent) { + state = ( + + ); + } else if (error && !resolvedHasContent) { + state = ( + + ); + } else if (isEmpty) { + state = ( + + ); + } + + return ( + + {state ? ( + state + ) : ( + + {children} + + )} + + ); +}; diff --git a/src/components/Widgets/QuitterFeedWidget.tsx b/src/components/Widgets/QuitterFeedWidget.tsx new file mode 100644 index 00000000..a901e57c --- /dev/null +++ b/src/components/Widgets/QuitterFeedWidget.tsx @@ -0,0 +1,1280 @@ +import { + Box, + ButtonBase, + Collapse, + CircularProgress, + Typography, + useTheme, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAtom, useAtomValue } from 'jotai'; +import { + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { quitterDashboardFeedCacheAtom, userInfoAtom } from '../../atoms/global'; +import { executeEvent } from '../../utils/events'; +import type { WidgetDisplayMode } from './DashboardWidgetFrame'; +import { + QAppWidgetContainer, + QAppWidgetStatePanel, +} from './QAppWidgetContainer'; +import { QuitterFeedCard } from './quitter/QuitterFeedCard'; +import { + fetchQuitterFeedPage, + fetchQuitterFollowedNames, +} from './quitter/quitterFeedApi'; +import type { + QuitterDashboardFeedCache, + QuitterDashboardInitialFeedState, + QuitterFeedItem, + QuitterFeedPage, + QuitterFollowingEmptyReason, +} from './quitter/quitterFeedTypes'; + +type QuitterFeedWidgetProps = { + batchSize?: number; + displayMode?: WidgetDisplayMode; + initialBatchSize?: number; + onRefreshStateChange?: (refreshing: boolean) => void; + refreshToken?: number; + searchLimit?: number; +}; + +type QuitterFeedMode = 'following' | 'general'; + +const QUITTER_FEED_MODE_STORAGE_PREFIX = 'quitter_dashboard_feed_mode:'; + +const emptyCacheForKey = (feedKey: string): QuitterDashboardFeedCache => ({ + error: null, + feedKey, + followingEmptyReason: null, + initialFeedState: 'loading', + items: [], + lastFullFetchAt: 0, + lastPollAt: null, + pendingItems: [], +}); + +const parseStoredQuitterFeedMode = ( + raw: string | null +): QuitterFeedMode | null => + raw === 'general' || raw === 'following' ? raw : null; + +const readQuitterFeedMode = (storageKey: string): QuitterFeedMode => { + if (typeof localStorage === 'undefined') return 'following'; + try { + return ( + parseStoredQuitterFeedMode(localStorage.getItem(storageKey)) ?? + 'following' + ); + } catch { + return 'following'; + } +}; + +const writeQuitterFeedMode = (storageKey: string, mode: QuitterFeedMode) => { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(storageKey, mode); + } catch { + // ignore quota / private mode + } +}; + +const FOLLOWING_LOAD_TIMEOUT_MS = 40_000; +const FEED_POLL_INTERVAL_MS = 90_000; +const NEW_POST_REVEAL_DURATION_MS = 420; + +class FeedLoadTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeedLoadTimeoutError'; + } +} + +const runAbortableTaskWithTimeout = async ( + signal: AbortSignal, + timeoutMs: number, + task: (signal: AbortSignal) => Promise, + timeoutMessage: string +) => { + const controller = new AbortController(); + let didTimeout = false; + + const handleAbort = () => { + controller.abort(); + }; + + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', handleAbort, { once: true }); + } + + const timeoutId = window.setTimeout(() => { + didTimeout = true; + controller.abort(); + }, timeoutMs); + + try { + return await task(controller.signal); + } catch (error) { + if (didTimeout) { + throw new FeedLoadTimeoutError(timeoutMessage); + } + + throw error; + } finally { + window.clearTimeout(timeoutId); + signal.removeEventListener('abort', handleAbort); + } +}; + +const prependUniqueFeedItems = ( + incomingItems: QuitterFeedItem[], + existingItems: QuitterFeedItem[] +) => { + const mergedItems: QuitterFeedItem[] = []; + const seenIds = new Set(); + + for (const item of incomingItems) { + if (seenIds.has(item.id)) { + continue; + } + + seenIds.add(item.id); + mergedItems.push(item); + } + + for (const item of existingItems) { + if (seenIds.has(item.id)) { + continue; + } + + seenIds.add(item.id); + mergedItems.push(item); + } + + return mergedItems; +}; + +export const QuitterFeedWidget = ({ + batchSize = 4, + displayMode = 'compact', + initialBatchSize = 6, + onRefreshStateChange, + refreshToken = 0, + searchLimit = 8, +}: QuitterFeedWidgetProps) => { + const theme = useTheme(); + const { t } = useTranslation('core'); + const userInfo = useAtomValue(userInfoAtom); + const [feedCache, setFeedCache] = useAtom(quitterDashboardFeedCacheAtom); + const isCompact = displayMode === 'compact'; + const currentUserName = + typeof userInfo?.name === 'string' && userInfo.name.trim().length > 0 + ? userInfo.name.trim() + : null; + const feedModeStorageKey = useMemo(() => { + const addr = + typeof userInfo?.address === 'string' && + userInfo.address.trim().length > 0 + ? userInfo.address.trim() + : 'anonymous'; + return `${QUITTER_FEED_MODE_STORAGE_PREFIX}${addr}`; + }, [userInfo?.address]); + const [feedMode, setFeedMode] = useState('following'); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [revealedItemIds, setRevealedItemIds] = useState([]); + const [reloadToken, setReloadToken] = useState(0); + const itemsRef = useRef([]); + const pendingItemsRef = useRef([]); + const feedKeyRef = useRef(''); + const feedCacheRef = useRef(null); + const scrollerRef = useRef(null); + const previousRefreshTokenRef = useRef(refreshToken); + const activeFeedRequestIdRef = useRef(0); + const activeUpdateRequestIdRef = useRef(0); + const isUpdateCheckInFlightRef = useRef(false); + const feedKey = `${feedMode}:${currentUserName ?? ''}`; + + const cacheMatches = feedCache?.feedKey === feedKey; + const items = cacheMatches ? feedCache.items : []; + const pendingItems = cacheMatches ? feedCache.pendingItems : []; + const followingEmptyReason = cacheMatches ? feedCache.followingEmptyReason : null; + const initialFeedState: QuitterDashboardInitialFeedState = cacheMatches + ? feedCache.initialFeedState + : 'loading'; + const error = cacheMatches ? feedCache.error : null; + + const isFollowingFeed = feedMode === 'following'; + const loadedPostLabel = useMemo( + () => t('quitter_feed.posts_showing', { count: items.length }), + [items.length, t] + ); + const pendingPostLabel = useMemo( + () => t('quitter_feed.new_posts', { count: pendingItems.length }), + [pendingItems.length, t] + ); + const revealedItemIdSet = useMemo( + () => new Set(revealedItemIds), + [revealedItemIds] + ); + + const rowGapPx = isCompact ? 9 : 12; + const scrollListBottomPadPx = 10; + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getItemKey: useCallback( + (index: number) => items[index]?.id ?? String(index), + [items] + ), + getScrollElement: () => scrollerRef.current, + estimateSize: useCallback(() => (isCompact ? 220 : 285), [isCompact]), + overscan: 6, + }); + + useEffect(() => { + feedKeyRef.current = feedKey; + }, [feedKey]); + + useEffect(() => { + feedCacheRef.current = feedCache; + }, [feedCache]); + + useEffect(() => { + if (feedCache?.feedKey === feedKey) { + itemsRef.current = feedCache.items; + pendingItemsRef.current = feedCache.pendingItems; + } + }, [feedCache, feedKey]); + + useEffect(() => { + setFeedMode(readQuitterFeedMode(feedModeStorageKey)); + }, [feedModeStorageKey]); + + const commitVisibleItems = useCallback((nextItems: QuitterFeedItem[]) => { + itemsRef.current = nextItems; + setFeedCache((prev) => { + const key = feedKeyRef.current; + const next: QuitterDashboardFeedCache = + prev?.feedKey === key + ? { ...prev, items: nextItems } + : { ...emptyCacheForKey(key), items: nextItems }; + feedCacheRef.current = next; + return next; + }); + }, [setFeedCache]); + + const commitPendingItems = useCallback((nextItems: QuitterFeedItem[]) => { + pendingItemsRef.current = nextItems; + setFeedCache((prev) => { + const key = feedKeyRef.current; + const next: QuitterDashboardFeedCache = + prev?.feedKey === key + ? { ...prev, pendingItems: nextItems } + : { ...emptyCacheForKey(key), pendingItems: nextItems }; + feedCacheRef.current = next; + return next; + }); + }, [setFeedCache]); + + useEffect(() => { + onRefreshStateChange?.(isRefreshing); + }, [isRefreshing, onRefreshStateChange]); + + useEffect( + () => () => { + onRefreshStateChange?.(false); + }, + [onRefreshStateChange] + ); + + useEffect(() => { + if (revealedItemIds.length === 0) { + return undefined; + } + + const timeoutId = window.setTimeout(() => { + setRevealedItemIds([]); + }, NEW_POST_REVEAL_DURATION_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [revealedItemIds]); + + const fetchFeedPageForMode = useEffectEvent( + async ( + signal: AbortSignal, + options: { + feedMode?: QuitterFeedMode; + itemLimit?: number; + } = {} + ) => { + const resolvedFeedMode = options.feedMode ?? feedMode; + const requestedItemLimit = options.itemLimit ?? initialBatchSize; + + if (resolvedFeedMode === 'following' && currentUserName) { + return runAbortableTaskWithTimeout( + signal, + FOLLOWING_LOAD_TIMEOUT_MS, + async (timedSignal) => { + const followedNames = await fetchQuitterFollowedNames( + currentUserName, + timedSignal + ); + + if (timedSignal.aborted) { + return { + hasMore: false, + items: [], + nextOffset: 0, + }; + } + + return fetchQuitterFeedPage({ + allowedAuthors: followedNames, + itemLimit: requestedItemLimit, + offset: 0, + searchLimit, + signal: timedSignal, + }); + }, + t('quitter_feed.error_timeout') + ); + } + + if (resolvedFeedMode === 'following') { + return { + hasMore: false, + items: [], + nextOffset: 0, + }; + } + + return fetchQuitterFeedPage({ + itemLimit: requestedItemLimit, + offset: 0, + searchLimit, + signal, + }); + } + ); + + const loadFeed = useEffectEvent( + async ( + signal: AbortSignal, + options: { + feedMode?: QuitterFeedMode; + } = {} + ) => { + const requestId = activeFeedRequestIdRef.current + 1; + const resolvedFeedMode = options.feedMode ?? feedMode; + const requestFeedKey = feedKeyRef.current; + + activeFeedRequestIdRef.current = requestId; + activeUpdateRequestIdRef.current = 0; + isUpdateCheckInFlightRef.current = false; + + setFeedCache(() => { + const next = emptyCacheForKey(requestFeedKey); + feedCacheRef.current = next; + return next; + }); + setIsInitialLoading(true); + setIsRefreshing(false); + + try { + let nextPage: QuitterFeedPage; + let nextFollowingEmptyReason: QuitterFollowingEmptyReason = null; + + if (resolvedFeedMode === 'following') { + if (!currentUserName) { + nextPage = { + hasMore: false, + items: [], + nextOffset: 0, + }; + nextFollowingEmptyReason = 'no-name'; + } else { + const followingResult = await runAbortableTaskWithTimeout( + signal, + FOLLOWING_LOAD_TIMEOUT_MS, + async (timedSignal) => { + const followedNames = await fetchQuitterFollowedNames( + currentUserName, + timedSignal + ); + + if (timedSignal.aborted) { + return { + followedCount: followedNames.length, + page: { + hasMore: false, + items: [], + nextOffset: 0, + }, + }; + } + + const page = await fetchQuitterFeedPage({ + allowedAuthors: followedNames, + itemLimit: initialBatchSize, + offset: 0, + searchLimit, + signal: timedSignal, + }); + + return { + followedCount: followedNames.length, + page, + }; + }, + t('quitter_feed.error_timeout') + ); + + nextPage = followingResult.page; + nextFollowingEmptyReason = + followingResult.followedCount === 0 + ? 'no-following' + : followingResult.page.items.length === 0 + ? 'no-posts' + : null; + } + } else { + nextPage = await fetchFeedPageForMode(signal, { + feedMode: resolvedFeedMode, + itemLimit: initialBatchSize, + }); + } + + if ( + signal.aborted || + activeFeedRequestIdRef.current !== requestId || + feedKeyRef.current !== requestFeedKey + ) { + return; + } + + const nextCache: QuitterDashboardFeedCache = { + error: null, + feedKey: requestFeedKey, + followingEmptyReason: nextFollowingEmptyReason, + initialFeedState: 'success', + items: nextPage.items, + lastFullFetchAt: Date.now(), + lastPollAt: null, + pendingItems: [], + }; + setFeedCache(nextCache); + feedCacheRef.current = nextCache; + itemsRef.current = nextPage.items; + pendingItemsRef.current = []; + } catch (error) { + if ( + signal.aborted || + activeFeedRequestIdRef.current !== requestId || + feedKeyRef.current !== requestFeedKey + ) { + return; + } + + console.error('Failed to load Quitter feed widget', error); + const message = + error instanceof FeedLoadTimeoutError + ? error.message + : resolvedFeedMode === 'following' + ? t('quitter_feed.error_following') + : t('quitter_feed.error_generic'); + const nextCache: QuitterDashboardFeedCache = { + ...emptyCacheForKey(requestFeedKey), + error: message, + initialFeedState: 'error', + }; + setFeedCache(nextCache); + feedCacheRef.current = nextCache; + itemsRef.current = []; + pendingItemsRef.current = []; + } finally { + if (activeFeedRequestIdRef.current === requestId) { + setIsInitialLoading(false); + } + } + } + ); + + const checkForNewPosts = useEffectEvent( + async ( + signal: AbortSignal, + options: { + feedMode?: QuitterFeedMode; + showRefreshIndicator?: boolean; + } = {} + ) => { + if (isInitialLoading || itemsRef.current.length === 0) { + return; + } + + if (isUpdateCheckInFlightRef.current) { + return; + } + + const requestId = activeUpdateRequestIdRef.current + 1; + const resolvedFeedMode = options.feedMode ?? feedMode; + const pollFeedKey = feedKeyRef.current; + const showRefreshIndicator = !!options.showRefreshIndicator; + const pollItemLimit = Math.min( + Math.max(initialBatchSize, batchSize) + + Math.min(pendingItemsRef.current.length, 6) + + 2, + 18 + ); + + activeUpdateRequestIdRef.current = requestId; + isUpdateCheckInFlightRef.current = true; + + if (showRefreshIndicator) { + setIsRefreshing(true); + } + + try { + const nextPage = await fetchFeedPageForMode(signal, { + feedMode: resolvedFeedMode, + itemLimit: pollItemLimit, + }); + + if (signal.aborted || activeUpdateRequestIdRef.current !== requestId) { + return; + } + + const knownIds = new Set([ + ...itemsRef.current.map((item) => item.id), + ...pendingItemsRef.current.map((item) => item.id), + ]); + const currentTopItemId = itemsRef.current[0]?.id ?? null; + const firstKnownIndex = nextPage.items.findIndex((item) => + knownIds.has(item.id) + ); + const currentTopIndex = currentTopItemId + ? nextPage.items.findIndex((item) => item.id === currentTopItemId) + : -1; + const cutoffIndex = + currentTopIndex >= 0 + ? currentTopIndex + : firstKnownIndex >= 0 + ? firstKnownIndex + : nextPage.items.length; + const nextPendingItems = nextPage.items + .slice(0, cutoffIndex) + .filter((item) => !knownIds.has(item.id)); + + if (nextPendingItems.length === 0) { + return; + } + + commitPendingItems( + prependUniqueFeedItems(nextPendingItems, pendingItemsRef.current) + ); + } catch (error) { + if (signal.aborted || activeUpdateRequestIdRef.current !== requestId) { + return; + } + + console.error('Failed to refresh Quitter feed widget', error); + } finally { + if (activeUpdateRequestIdRef.current === requestId) { + isUpdateCheckInFlightRef.current = false; + + if (showRefreshIndicator) { + setIsRefreshing(false); + } + + setFeedCache((prev) => { + if (!prev || prev.feedKey !== pollFeedKey) { + return prev; + } + const next = { ...prev, lastPollAt: Date.now() }; + feedCacheRef.current = next; + return next; + }); + } + } + } + ); + + useEffect(() => { + const controller = new AbortController(); + + previousRefreshTokenRef.current = refreshToken; + const key = feedKey; + feedKeyRef.current = key; + + const snapshot = feedCacheRef.current; + const canReuse = + snapshot != null && + snapshot.feedKey === key && + snapshot.initialFeedState === 'success'; + + if (canReuse) { + itemsRef.current = snapshot.items; + pendingItemsRef.current = snapshot.pendingItems; + setIsInitialLoading(false); + setRevealedItemIds([]); + return () => { + controller.abort(); + }; + } + + setRevealedItemIds([]); + void loadFeed(controller.signal, { + feedMode, + }); + + return () => { + controller.abort(); + }; + }, [feedKey, feedMode, reloadToken]); + + useEffect(() => { + if (refreshToken <= previousRefreshTokenRef.current) { + return undefined; + } + + previousRefreshTokenRef.current = refreshToken; + + if (isInitialLoading || initialFeedState === 'loading') { + return undefined; + } + + if (itemsRef.current.length === 0) { + setReloadToken((value) => value + 1); + return undefined; + } + + const controller = new AbortController(); + + void checkForNewPosts(controller.signal, { + feedMode, + showRefreshIndicator: true, + }); + + return () => { + controller.abort(); + }; + }, [feedMode, initialFeedState, isInitialLoading, refreshToken]); + + useEffect(() => { + if (initialFeedState !== 'success' || items.length === 0) { + return undefined; + } + + let isCancelled = false; + let timeoutId: number | null = null; + let activeController: AbortController | null = null; + const pollKey = feedKey; + + const scheduleNextPoll = () => { + if (isCancelled) { + return; + } + + const lastPollAt = + feedCacheRef.current?.feedKey === pollKey + ? feedCacheRef.current.lastPollAt + : null; + const now = Date.now(); + const delay = + lastPollAt == null + ? FEED_POLL_INTERVAL_MS + : Math.max(0, FEED_POLL_INTERVAL_MS - (now - lastPollAt)); + + timeoutId = window.setTimeout(() => { + if (isCancelled) { + return; + } + + activeController = new AbortController(); + + void checkForNewPosts(activeController.signal, { + feedMode, + }).finally(() => { + activeController = null; + scheduleNextPoll(); + }); + }, delay); + }; + + scheduleNextPoll(); + + return () => { + isCancelled = true; + + if (timeoutId != null) { + window.clearTimeout(timeoutId); + } + + activeController?.abort(); + }; + }, [feedMode, feedKey, initialFeedState, items.length]); + + const handleOpenPost = useCallback((item: QuitterFeedItem) => { + executeEvent('addTab', { + data: { + identifier: '', + name: 'Quitter', + path: `post/${encodeURIComponent(item.author)}/${encodeURIComponent(item.identifier)}`, + service: 'APP', + }, + }); + executeEvent('open-apps-mode', {}); + }, []); + + const handleApplyPendingPosts = useCallback(() => { + const nextPendingItems = pendingItemsRef.current; + + if (nextPendingItems.length === 0) { + return; + } + + const scrollNode = scrollerRef.current; + const previousScrollTop = scrollNode?.scrollTop ?? 0; + const previousScrollHeight = scrollNode?.scrollHeight ?? 0; + const shouldPreserveViewport = previousScrollTop > 24; + const nextVisibleItems = prependUniqueFeedItems( + nextPendingItems, + itemsRef.current + ); + const nextRevealedIds = nextPendingItems.map((item) => item.id); + + commitPendingItems([]); + commitVisibleItems(nextVisibleItems); + setRevealedItemIds(nextRevealedIds); + + if ( + shouldPreserveViewport && + typeof window !== 'undefined' && + typeof window.requestAnimationFrame === 'function' + ) { + window.requestAnimationFrame(() => { + const nextScrollNode = scrollerRef.current; + + if (!nextScrollNode) { + return; + } + + const scrollDelta = nextScrollNode.scrollHeight - previousScrollHeight; + nextScrollNode.scrollTop = previousScrollTop + Math.max(scrollDelta, 0); + }); + } + }, [commitPendingItems, commitVisibleItems]); + + const showInitialLoadingState = isInitialLoading && items.length === 0; + const showInitialErrorState = + !isInitialLoading && initialFeedState === 'error' && items.length === 0; + const showEmptyState = + !isInitialLoading && initialFeedState === 'success' && items.length === 0; + + const handleRetryInitialFeed = useCallback(() => { + setFeedCache(() => { + const key = feedKeyRef.current; + const next = emptyCacheForKey(key); + feedCacheRef.current = next; + return next; + }); + setIsInitialLoading(true); + setReloadToken((value) => value + 1); + }, [setFeedCache]); + + const handleSelectFollowingFeed = useCallback(() => { + if (feedMode === 'following') { + return; + } + + setFeedMode('following'); + writeQuitterFeedMode(feedModeStorageKey, 'following'); + }, [feedMode, feedModeStorageKey]); + + const handleSelectGeneralFeed = useCallback(() => { + setFeedMode('general'); + writeQuitterFeedMode(feedModeStorageKey, 'general'); + }, [feedModeStorageKey]); + + const loadingLabel = useMemo( + () => + isFollowingFeed + ? t('quitter_feed.loading_following') + : t('quitter_feed.loading_general'), + [isFollowingFeed, t] + ); + + const errorTitle = useMemo( + () => + isFollowingFeed + ? t('quitter_feed.error_title_following') + : t('quitter_feed.error_title_general'), + [isFollowingFeed, t] + ); + + const emptyTitle = useMemo(() => { + if (!isFollowingFeed) { + return t('quitter_feed.empty_title_general'); + } + if (followingEmptyReason === 'no-name') { + return t('quitter_feed.empty_title_no_name'); + } + if (followingEmptyReason === 'no-following') { + return t('quitter_feed.empty_title_no_following'); + } + return t('quitter_feed.empty_title_no_posts'); + }, [followingEmptyReason, isFollowingFeed, t]); + + const emptyMessage = useMemo(() => { + if (!isFollowingFeed) { + return null; + } + if (followingEmptyReason === 'no-name') { + return t('quitter_feed.empty_message_no_name'); + } + if (followingEmptyReason === 'no-following') { + return t('quitter_feed.empty_message_no_following'); + } + return t('quitter_feed.empty_message_no_posts'); + }, [followingEmptyReason, isFollowingFeed, t]); + const showStatePanel = + showInitialLoadingState || showInitialErrorState || showEmptyState; + const segmentedToggleSx = { + alignItems: 'center', + borderRadius: '999px', + display: 'inline-flex', + fontSize: '0.69rem', + fontWeight: 700, + minHeight: '28px', + px: 1.25, + transition: + 'background-color 140ms ease, border-color 140ms ease, color 140ms ease', + whiteSpace: 'nowrap', + } as const; + + return ( + <> + + + + + + {t('quitter_feed.tab_general')} + + + {t('quitter_feed.tab_following')} + + + + {feedMode === 'following' + ? t('quitter_feed.subtitle_personalized') + : t('quitter_feed.subtitle_public')} + + + + {!showStatePanel && items.length > 0 ? ( + 0} mountOnEnter unmountOnExit> + + { + event.preventDefault(); + event.stopPropagation(); + handleApplyPendingPosts(); + }} + sx={{ + alignItems: 'center', + animation: + 'quitterNewPostsThresholdFade 5.8s ease-in-out infinite', + display: 'inline-flex', + gap: '9px', + maxWidth: '100%', + position: 'relative', + px: '4px', + py: '2px', + zIndex: 1, + '@keyframes quitterNewPostsThresholdFade': { + '0%, 100%': { + opacity: 0.84, + }, + '50%': { + opacity: 1, + }, + }, + '&:hover': { + opacity: 1, + }, + }} + > + + + + {pendingPostLabel} + + + + + + ) : null} + + {showStatePanel ? ( + + ) : ( + <> + + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = items[virtualRow.index]; + if (!item) { + return null; + } + + const revealSx = revealedItemIdSet.has(item.id) + ? { + '@keyframes quitterFeedInsert': { + '0%': { + opacity: 0, + transform: 'translateY(-6px)', + }, + '100%': { + opacity: 1, + transform: 'translateY(0)', + }, + }, + animation: 'quitterFeedInsert 280ms ease both', + } + : undefined; + + return ( + + + { + handleOpenPost(item); + }} + /> + + + ); + })} + + + + {items.length > 0 && ( + + + {isRefreshing ? ( + + ) : ( + + )} + + {loadedPostLabel} + + + + )} + + )} + + + + ); +}; diff --git a/src/components/Widgets/quitter/QuitterFeedCard.tsx b/src/components/Widgets/quitter/QuitterFeedCard.tsx new file mode 100644 index 00000000..dc67d586 --- /dev/null +++ b/src/components/Widgets/quitter/QuitterFeedCard.tsx @@ -0,0 +1,468 @@ +import PlayCircleOutlineRoundedIcon from '@mui/icons-material/PlayCircleOutlineRounded'; +import { Avatar, Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatTimestamp } from '../../../utils/time'; +import type { WidgetDisplayMode } from '../DashboardWidgetFrame'; +import type { QuitterFeedItem } from './quitterFeedTypes'; + +type QuitterFeedCardProps = { + displayMode?: WidgetDisplayMode; + item: QuitterFeedItem; + onOpen?: () => void; +}; + +export const QuitterFeedCard = ({ + displayMode = 'compact', + item, + onOpen, +}: QuitterFeedCardProps) => { + const theme = useTheme(); + const { t } = useTranslation('group'); + const hasText = item.text.trim().length > 0; + const imageCount = item.images.length; + const hasMedia = imageCount > 0 || item.hasVideo; + const isCompact = displayMode === 'compact'; + const collapsedLineCount = hasMedia + ? isCompact + ? 2 + : 3 + : isCompact + ? 3 + : 4; + const cardGap = isCompact ? '10px' : '12px'; + const imageHeight = + imageCount > 1 ? (isCompact ? 118 : 142) : isCompact ? 172 : 204; + const textFontSize = isCompact ? '0.78rem' : '0.83rem'; + const cardSurfaceColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(46, 50, 61, 0.92) 0%, rgba(36, 39, 49, 0.96) 100%)' + : alpha(theme.palette.text.primary, 0.038); + const cardSurfaceHoverColor = + theme.palette.mode === 'dark' + ? 'linear-gradient(180deg, rgba(50, 55, 67, 0.96) 0%, rgba(39, 43, 53, 0.98) 100%)' + : alpha(theme.palette.text.primary, 0.05); + const cardBorderColor = + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.06)' + : alpha(theme.palette.border.main, 0.12); + const cardHoverBorderColor = + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.085)' + : alpha(theme.palette.border.main, 0.18); + const cardInsetShadow = + theme.palette.mode === 'dark' + ? `0 10px 24px rgba(0,0,0,0.2), inset 0 1px 0 ${alpha(theme.palette.common.white, 0.045)}` + : `inset 0 1px 0 ${alpha(theme.palette.common.white, 0.76)}`; + const textRef = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isTextExpandable, setIsTextExpandable] = useState(false); + + useEffect(() => { + setIsExpanded(false); + }, [item.id]); + + useEffect(() => { + if (!hasText) { + setIsTextExpandable(false); + return; + } + + const node = textRef.current; + if (!node) { + return; + } + + const measureOverflow = () => { + if (isExpanded) { + return; + } + + setIsTextExpandable(node.scrollHeight - node.clientHeight > 1); + }; + + measureOverflow(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureOverflow); + + return () => { + window.removeEventListener('resize', measureOverflow); + }; + } + + const resizeObserver = new ResizeObserver(() => { + measureOverflow(); + }); + + resizeObserver.observe(node); + + return () => { + resizeObserver.disconnect(); + }; + }, [collapsedLineCount, hasText, isExpanded, item.text]); + + return ( + { + if (!onOpen) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(); + } + }} + role={onOpen ? 'link' : undefined} + sx={{ + background: cardSurfaceColor, + border: `1px solid ${cardBorderColor}`, + borderRadius: '12px', + boxShadow: cardInsetShadow, + display: 'flex', + flex: '0 0 auto', + flexDirection: 'column', + flexShrink: 0, + gap: cardGap, + minHeight: 'max-content', + overflow: 'hidden', + p: isCompact ? '14px 14px 15px' : '16px 16px 17px', + position: 'relative', + tabIndex: onOpen ? 0 : undefined, + transition: + 'transform 140ms ease, border-color 140ms ease, background 140ms ease, box-shadow 140ms ease', + width: '100%', + ...(onOpen + ? { + cursor: 'pointer', + '&:hover': { + background: cardSurfaceHoverColor, + borderColor: cardHoverBorderColor, + boxShadow: + theme.palette.mode === 'dark' + ? `0 14px 28px rgba(0,0,0,0.24), inset 0 1px 0 ${alpha(theme.palette.common.white, 0.055)}` + : `inset 0 1px 0 ${alpha(theme.palette.common.white, 0.84)}`, + transform: 'translateY(-1px)', + }, + '&:focus-visible': { + outline: `2px solid ${alpha(theme.palette.primary.main, 0.48)}`, + outlineOffset: '-2px', + }, + } + : null), + }} + > + + + {item.author.charAt(0).toUpperCase()} + + + + + + {item.author} + + + + + {formatTimestamp(item.publishedAt)} + + + + + {hasText ? ( + + + {item.text} + + {isTextExpandable || isExpanded ? ( + { + event.stopPropagation(); + setIsExpanded((value) => !value); + }} + sx={{ + alignItems: 'center', + color: alpha(theme.palette.primary.main, 0.84), + display: 'inline-flex', + fontSize: isCompact ? '0.71rem' : '0.73rem', + fontWeight: 700, + justifyContent: 'flex-start', + minHeight: 'unset', + mt: '5px', + textAlign: 'left', + '&:hover': { + color: theme.palette.primary.main, + textDecoration: 'underline', + }, + }} + > + {isExpanded + ? t('dashboard.quitter_show_less', { + defaultValue: 'Show less', + }) + : t('dashboard.quitter_show_more', { + defaultValue: 'Show more', + })} + + ) : null} + + ) : null} + + + + {item.hasVideo ? ( + 0 ? (isCompact ? 88 : 100) : isCompact ? 104 : 120, + overflow: 'hidden', + position: 'relative', + px: isCompact ? 1.2 : 1.35, + py: isCompact ? 1.05 : 1.25, + '&::before': { + background: + theme.palette.mode === 'dark' + ? `radial-gradient(54% 92% at 30% 52%, rgba(90, 182, 255, 0.24) 0%, rgba(45, 97, 161, 0.14) 42%, transparent 76%)` + : `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.18)} 0%, ${alpha(theme.palette.primary.main, 0.08)} 42%, transparent 82%)`, + content: '""', + inset: 0, + opacity: 0.9, + pointerEvents: 'none', + position: 'absolute', + }, + '&::after': { + background: + theme.palette.mode === 'dark' + ? `linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 34%)` + : `repeating-linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.12)} 0px, ${alpha(theme.palette.primary.main, 0.12)} 2px, transparent 2px, transparent 10px)`, + content: '""', + inset: 0, + opacity: 0.8, + pointerEvents: 'none', + position: 'absolute', + }, + }} + > + + + + + {t('dashboard.quitter_video_attachment', { + defaultValue: 'Video attachment', + })} + + + {t('dashboard.quitter_video_preview_hub', { + defaultValue: 'Preview stays read-only in Hub', + })} + + + + + + + + ) : null} + + {imageCount > 0 ? ( + 1 ? 'repeat(2, minmax(0, 1fr))' : 'minmax(0, 1fr)', + }} + > + {item.images.map((image) => ( + + 1 ? '1 / 1' : '16 / 10', + display: 'block', + height: imageHeight, + maxHeight: imageHeight, + objectFit: 'cover', + width: '100%', + }} + /> + + ))} + + ) : null} + + ); +}; diff --git a/src/components/Widgets/quitter/quitterFeedApi.ts b/src/components/Widgets/quitter/quitterFeedApi.ts new file mode 100644 index 00000000..1a9b73fd --- /dev/null +++ b/src/components/Widgets/quitter/quitterFeedApi.ts @@ -0,0 +1,616 @@ +import type { + FetchQuitterFeedOptions, + QuitterFeedDocument, + QuitterFeedPage, + QuitterFeedImageRef, + QuitterFeedItem, + QuitterFeedItemImage, + QuitterFeedSearchResource, + QuitterFeedVideoRef, +} from './quitterFeedTypes'; +import { getBaseApiReact } from '../../../utils/globalApi'; + +export const QUITTER_PUBLIC_FEED_SEARCH_ENDPOINT = + '/arbitrary/resources/search'; + +const QUITTER_PUBLIC_FEED_SEARCH_LIMIT = 10; +const QUITTER_WIDGET_ITEM_LIMIT = 6; +const QUITTER_MAX_PAGINATION_PASSES = 4; +const QUITTER_FOLLOWING_SCAN_TOTAL_LIMIT = 60; +const QUITTER_FOLLOW_CANDIDATE_MAX_SIZE = 160; +const QUITTER_FOLLOW_CANDIDATE_LIMIT = 24; +const QUITTER_FOLLOWING_CACHE_TTL_MS = 5 * 60 * 1000; +/** Max entries; LRU eviction. Payloads can be large (e.g. embedded images). */ +const QUITTER_DOCUMENT_PAYLOAD_CACHE_MAX_ENTRIES = 250; + +// Verified against the public node on April 19, 2026. +// This is Quitter's qapp-core-derived POST + ROOT search prefix. +const QUITTER_PUBLIC_POST_PREFIX = 'MhNiRYdzkaP9dz-kX47dT-XrFXaYetyErMdF-'; +const QUITTER_FOLLOWING_PREFIX = 'gY.TWOeB25Co.7'; +const followedNamesCache = new Map< + string, + { fetchedAt: number; names: string[] } +>(); + +const documentPayloadLru = new Map(); + +const documentPayloadCacheKey = (resource: QuitterFeedSearchResource) => + `${resource.name}:${resource.identifier}:${resource.latestSignature}`; + +const readDocumentPayloadCache = (key: string): unknown | undefined => { + const value = documentPayloadLru.get(key); + if (value === undefined) { + return undefined; + } + documentPayloadLru.delete(key); + documentPayloadLru.set(key, value); + return value; +}; + +const writeDocumentPayloadCache = (key: string, value: unknown) => { + if (documentPayloadLru.has(key)) { + documentPayloadLru.delete(key); + } + documentPayloadLru.set(key, value); + + while (documentPayloadLru.size > QUITTER_DOCUMENT_PAYLOAD_CACHE_MAX_ENTRIES) { + const oldest = documentPayloadLru.keys().next().value as string | undefined; + if (oldest === undefined) { + break; + } + documentPayloadLru.delete(oldest); + } +}; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const isAbortError = (error: unknown) => + error instanceof DOMException && error.name === 'AbortError'; + +const toSafeString = (value: unknown) => + typeof value === 'string' ? value : ''; + +const toSafeNumber = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const detectImageMimeType = (base64: string): string => { + try { + const binary = atob(base64.slice(0, 20)); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + if ( + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + return 'image/png'; + } + + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + + if ( + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp'; + } + + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) { + return 'image/gif'; + } + } catch { + return 'image/webp'; + } + + return 'image/webp'; +}; + +const toRenderableImage = ( + image: QuitterFeedImageRef, + author: string, + index: number +): QuitterFeedItemImage | null => { + const src = toSafeString(image?.src).trim(); + + if (!src) { + return null; + } + + return { + alt: `${author} image ${index + 1}`, + src: `data:${detectImageMimeType(src)};base64,${src}`, + }; +}; + +const isQuitterVideoRef = (value: unknown): value is QuitterFeedVideoRef => + isRecord(value) && + toSafeString(value.identifier).length > 0 && + toSafeString(value.name).length > 0 && + toSafeString(value.service) === 'DOCUMENT'; + +const isQuitterDocument = (value: unknown): value is QuitterFeedDocument => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.text === 'string' && + typeof value.name === 'string' && + typeof value.timestamp === 'number' && + Number.isFinite(value.timestamp) + ); +}; + +const mapSearchResource = ( + value: unknown +): QuitterFeedSearchResource | null => { + if (!isRecord(value)) { + return null; + } + + const created = toSafeNumber(value.created); + const size = toSafeNumber(value.size); + const updated = toSafeNumber(value.updated) ?? undefined; + const name = toSafeString(value.name); + const service = toSafeString(value.service); + const identifier = toSafeString(value.identifier); + const latestSignature = toSafeString(value.latestSignature); + + if ( + created == null || + size == null || + !name || + service !== 'DOCUMENT' || + !identifier || + !latestSignature + ) { + return null; + } + + return { + created, + identifier, + latestSignature, + name, + service: 'DOCUMENT', + size, + updated, + }; +}; + +const mapDocumentToFeedItem = ( + resource: QuitterFeedSearchResource, + document: unknown +): QuitterFeedItem | null => { + if (!isQuitterDocument(document)) { + return null; + } + + const author = document.name.trim() || resource.name; + const images = (Array.isArray(document.images) ? document.images : []) + .map((image, index) => + toRenderableImage(image, author || resource.name, index) + ) + .filter((image): image is QuitterFeedItemImage => image != null); + const hasVideo = (Array.isArray(document.videos) ? document.videos : []).some( + isQuitterVideoRef + ); + + return { + author, + avatarUrl: getQuitterAvatarUrl(author), + hasVideo, + id: `${resource.name}:${resource.identifier}`, + identifier: resource.identifier, + images, + latestSignature: resource.latestSignature, + publishedAt: document.timestamp, + searchCreatedAt: resource.created, + service: resource.service, + text: document.text, + updatedAt: resource.updated, + }; +}; + +const getFollowedNameFromDocument = (document: unknown) => { + if (!isRecord(document)) { + return null; + } + + const followedName = toSafeString(document.followedName).trim(); + return followedName || null; +}; + +const fetchText = async (url: string, signal?: AbortSignal) => { + const response = await fetch(url, { + cache: 'no-store', + signal, + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return response.text(); +}; + +const fetchJSON = async (url: string, signal?: AbortSignal) => { + const response = await fetch(url, { + cache: 'no-store', + signal, + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return response.json(); +}; +const buildQuitterFeedSearchUrl = ( + searchLimit: number, + offset: number, + names: string[] = [] +) => { + const params = new URLSearchParams({ + identifier: QUITTER_PUBLIC_POST_PREFIX, + limit: String(searchLimit), + mode: 'ALL', + offset: String(offset), + prefix: 'true', + reverse: 'true', + service: 'DOCUMENT', + excludeblocked: 'true', + }); + + names.forEach((name) => { + params.append('name', name); + }); + + return `${getBaseApiReact()}${QUITTER_PUBLIC_FEED_SEARCH_ENDPOINT}?${params.toString()}`; +}; + +const buildQuitterUserResourceSearchUrl = ( + userName: string, + searchLimit: number, + offset: number +) => { + const params = new URLSearchParams({ + exactmatchnames: 'true', + limit: String(searchLimit), + mode: 'ALL', + name: userName, + offset: String(offset), + reverse: 'true', + service: 'DOCUMENT', + excludeblocked: 'true', + identifier: QUITTER_FOLLOWING_PREFIX, + prefix: 'true', + }); + + return `${getBaseApiReact()}${QUITTER_PUBLIC_FEED_SEARCH_ENDPOINT}?${params.toString()}`; +}; + +const buildQuitterDocumentUrl = (name: string, identifier: string) => + `${getBaseApiReact()}/arbitrary/DOCUMENT/${encodeURIComponent(name)}/${encodeURIComponent(identifier)}`; + +export const getQuitterAvatarUrl = (author: string) => + `${getBaseApiReact()}/arbitrary/THUMBNAIL/${encodeURIComponent(author)}/qortal_avatar?async=true`; + +const fetchQuitterSearchResources = async ( + searchLimit: number, + offset = 0, + names: string[] = [], + signal?: AbortSignal +) => { + const parsed = await fetchJSON( + buildQuitterFeedSearchUrl(searchLimit, offset, names), + signal + ); + + if (!Array.isArray(parsed)) { + throw new Error('Unexpected Quitter feed response shape'); + } + + return parsed + .map(mapSearchResource) + .filter( + (resource): resource is QuitterFeedSearchResource => resource != null + ); +}; + +const fetchQuitterUserResources = async ( + userName: string, + searchLimit: number, + offset = 0, + signal?: AbortSignal +) => { + const text = await fetchJSON( + buildQuitterUserResourceSearchUrl(userName, searchLimit, offset), + signal + ); + + const parsed = text; + + if (!Array.isArray(parsed)) { + throw new Error('Unexpected Quitter user resource response shape'); + } + + return parsed + .map(mapSearchResource) + .filter( + (resource): resource is QuitterFeedSearchResource => resource != null + ); +}; + +const fetchQuitterDocumentPayload = async ( + resource: QuitterFeedSearchResource, + signal?: AbortSignal +) => { + const cacheKey = documentPayloadCacheKey(resource); + const cached = readDocumentPayloadCache(cacheKey); + if (cached !== undefined) { + return cached; + } + + const text = await fetchText( + buildQuitterDocumentUrl(resource.name, resource.identifier), + signal + ); + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + writeDocumentPayloadCache(cacheKey, parsed); + return parsed; +}; + +const fetchFollowedNames = async ( + resources: QuitterFeedSearchResource[], + signal?: AbortSignal +) => { + const response = await fetch( + `${getBaseApiReact()}/arbitrary/resources/onchain/data`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + resources.map((resource) => ({ + service: resource.service, + name: resource.name, + identifier: resource.identifier, + })) + ), + } + ); + const followedNames = new Set(); + const results = await response.json(); + + if (response.ok) { + for (const result of results) { + if (!result.data || result.error) continue; + + try { + const json = JSON.parse(atob(result.data)); + if (!json.followedName) continue; + followedNames.add(json.followedName); + } catch { + console.warn( + 'Failed to decode follow data for identifier:', + result.identifier + ); + } + } + } + + return Array.from(followedNames); +}; + +export const fetchQuitterFeed = async ({ + excludeIds = [], + itemLimit = QUITTER_WIDGET_ITEM_LIMIT, + offset = 0, + searchLimit = QUITTER_PUBLIC_FEED_SEARCH_LIMIT, + signal, +}: FetchQuitterFeedOptions = {}): Promise => { + const page = await fetchQuitterFeedPage({ + excludeIds, + itemLimit, + offset, + searchLimit, + signal, + }); + + return page.items; +}; + +export const fetchQuitterFeedPage = async ({ + allowedAuthors, + excludeIds = [], + itemLimit = QUITTER_WIDGET_ITEM_LIMIT, + offset = 0, + searchLimit = QUITTER_PUBLIC_FEED_SEARCH_LIMIT, + signal, +}: FetchQuitterFeedOptions = {}): Promise => { + const seenIds = new Set(excludeIds); + const normalizedAllowedAuthors = + allowedAuthors + ?.map((author) => author.trim().toLowerCase()) + .filter(Boolean) ?? null; + const allowedAuthorsSet = + normalizedAllowedAuthors != null ? new Set(normalizedAllowedAuthors) : null; + + if (allowedAuthorsSet && allowedAuthorsSet.size === 0) { + return { + hasMore: false, + items: [], + nextOffset: offset, + }; + } + + const items: QuitterFeedItem[] = []; + let hasMore = true; + let nextOffset = offset; + + for ( + let pass = 0; + pass < QUITTER_MAX_PAGINATION_PASSES && items.length < itemLimit && hasMore; + pass += 1 + ) { + const requestedOffset = nextOffset; + const remaining = itemLimit - items.length; + const requestLimit = Math.max( + 1, + Math.min( + QUITTER_PUBLIC_FEED_SEARCH_LIMIT, + Math.max(searchLimit, remaining + 4) + ) + ); + const resources = await fetchQuitterSearchResources( + requestLimit, + requestedOffset, + allowedAuthors || [], + signal + ); + const filteredResources = resources + .map((resource, resourceIndex) => ({ + resource, + resourceIndex, + })) + .filter(({ resource }) => { + const normalizedName = resource.name.trim().toLowerCase(); + return allowedAuthorsSet ? allowedAuthorsSet.has(normalizedName) : true; + }); + + const reachedSearchEnd = resources.length < requestLimit; + + if (resources.length === 0) { + hasMore = false; + break; + } + + const settled = await Promise.allSettled( + filteredResources.map(async (resourceEntry) => { + const resource = resourceEntry.resource; + const payload = await fetchQuitterDocumentPayload(resource, signal); + return mapDocumentToFeedItem(resource, payload); + }) + ); + let consumedResourceCount = resources.length; + let reachedItemLimit = false; + + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index]; + + if (result.status === 'fulfilled') { + if (!result.value) { + continue; + } + + if (seenIds.has(result.value.id)) { + continue; + } + + seenIds.add(result.value.id); + items.push(result.value); + + if (items.length >= itemLimit) { + consumedResourceCount = filteredResources[index].resourceIndex + 1; + reachedItemLimit = true; + break; + } + + continue; + } + + if (!isAbortError(result.reason)) { + console.error('Failed to load Quitter feed document', result.reason); + } + } + + nextOffset = requestedOffset + consumedResourceCount; + const hasBufferedResources = + reachedItemLimit && consumedResourceCount < resources.length; + + if (reachedSearchEnd && !hasBufferedResources) { + hasMore = false; + } + } + + return { + hasMore, + items, + nextOffset, + }; +}; + +export const fetchQuitterFollowedNames = async ( + userName: string, + signal?: AbortSignal +) => { + const normalizedUserName = userName.trim(); + + if (!normalizedUserName) { + return []; + } + + const cached = followedNamesCache.get(normalizedUserName); + if ( + cached && + Date.now() - cached.fetchedAt < QUITTER_FOLLOWING_CACHE_TTL_MS + ) { + return cached.names; + } + + const followedNames = new Set(); + const resources = await fetchQuitterUserResources( + normalizedUserName, + QUITTER_FOLLOWING_SCAN_TOTAL_LIMIT, + 0, + signal + ); + + const candidateResources = resources + .filter( + (resource) => + resource.size <= QUITTER_FOLLOW_CANDIDATE_MAX_SIZE && + resource.size !== 32 + ) + .sort((left, right) => left.size - right.size) + .slice(0, QUITTER_FOLLOW_CANDIDATE_LIMIT); + + const settled = await fetchFollowedNames(candidateResources, signal); + + for (const result of settled) { + if (!result || result === normalizedUserName) { + continue; + } + + followedNames.add(result); + } + + const names = [...followedNames]; + followedNamesCache.set(normalizedUserName, { + fetchedAt: Date.now(), + names, + }); + + return names; +}; diff --git a/src/components/Widgets/quitter/quitterFeedTypes.ts b/src/components/Widgets/quitter/quitterFeedTypes.ts new file mode 100644 index 00000000..1d5f8ad6 --- /dev/null +++ b/src/components/Widgets/quitter/quitterFeedTypes.ts @@ -0,0 +1,88 @@ +export type QuitterFeedSearchResource = { + created: number; + identifier: string; + latestSignature: string; + name: string; + service: 'DOCUMENT'; + size: number; + updated?: number; +}; + +export type QuitterFeedImageRef = { + src: string; +}; + +export type QuitterFeedVideoRef = { + identifier: string; + mimeType?: string; + name: string; + service: 'DOCUMENT'; +}; + +export type QuitterFeedDocument = { + images?: QuitterFeedImageRef[]; + name: string; + text: string; + timestamp: number; + videos?: QuitterFeedVideoRef[]; +}; + +export type QuitterFeedItemImage = { + alt: string; + src: string; +}; + +export type QuitterFeedItem = { + author: string; + avatarUrl: string; + hasVideo: boolean; + id: string; + identifier: string; + images: QuitterFeedItemImage[]; + latestSignature: string; + publishedAt: number; + searchCreatedAt: number; + service: 'DOCUMENT'; + text: string; + updatedAt?: number; +}; + +export type FetchQuitterFeedOptions = { + allowedAuthors?: string[]; + excludeIds?: string[]; + itemLimit?: number; + offset?: number; + searchLimit?: number; + signal?: AbortSignal; +}; + +export type QuitterFeedPage = { + hasMore: boolean; + items: QuitterFeedItem[]; + nextOffset: number; +}; + +export type QuitterFollowingEmptyReason = + | 'no-following' + | 'no-name' + | 'no-posts' + | null; + +export type QuitterDashboardInitialFeedState = 'error' | 'loading' | 'success'; + +/** + * Home dashboard Quitter widget feed snapshot. + * Intended: subscribe via useAtom only from QuitterFeedWidget. + */ +export type QuitterDashboardFeedCache = { + error: string | null; + feedKey: string; + followingEmptyReason: QuitterFollowingEmptyReason; + initialFeedState: QuitterDashboardInitialFeedState; + items: QuitterFeedItem[]; + /** Timestamp of last successful full load (loadFeed). */ + lastFullFetchAt: number; + /** Timestamp of last poll / refresh fetch (checkForNewPosts). */ + lastPollAt: number | null; + pendingItems: QuitterFeedItem[]; +}; diff --git a/src/components/common/BorderGlow.css b/src/components/common/BorderGlow.css new file mode 100644 index 00000000..f547e91e --- /dev/null +++ b/src/components/common/BorderGlow.css @@ -0,0 +1,293 @@ +/* Keep sweep motion in CSS so React does not write styles every frame. */ +@property --cursor-angle { + syntax: ''; + inherits: true; + initial-value: 110deg; +} + +@property --edge-proximity { + syntax: ''; + inherits: true; + initial-value: 0; +} + +@keyframes border-glow-sweep-rotate { + from { + transform: rotate(110deg); + } + + to { + transform: rotate(470deg); + } +} + +@keyframes border-glow-intro-rotate { + 0% { + transform: rotate(110deg); + animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); + } + + 37.5% { + transform: rotate(287.5deg); + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + } + + 93.75%, + 100% { + transform: rotate(465deg); + } +} + +@keyframes border-glow-intro-opacity { + 0% { + opacity: 0; + } + + 12.5%, + 62.5% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.border-glow-card.border-glow-css-sweep.border-glow-card--always-on { + --edge-proximity: 100; +} + +/* + * Rotating `.edge-light` makes its paint extend past the card's axis-aligned + * bounds; clip at the root so the sweep cannot bleed into the grid gutters. + */ +.border-glow-card.border-glow-css-sweep { + overflow: hidden; + clip-path: inset(0 round var(--border-radius)); +} + +.border-glow-card.border-glow-css-sweep.border-glow-card--always-on > .edge-light { + opacity: 1; + animation: border-glow-sweep-rotate var(--sweep-duration, 4000ms) linear infinite; +} + +.border-glow-card.border-glow-css-sweep.border-glow-css-sweep-intro > .edge-light { + animation: + border-glow-intro-rotate var(--sweep-duration, 4000ms) both, + border-glow-intro-opacity var(--sweep-duration, 4000ms) both; + animation-iteration-count: var(--sweep-iteration-count, 1); +} + +.border-glow-card.border-glow-css-sweep.border-glow-css-sweep-reverse > .edge-light { + animation-direction: reverse; +} + +@media (prefers-reduced-motion: reduce) { + .border-glow-card.border-glow-css-sweep > .edge-light { + animation: none; + } + + .border-glow-card.border-glow-css-sweep.border-glow-card--always-on > .edge-light { + transform: rotate(118deg); + } +} + +.border-glow-card { + --edge-proximity: 0; + --cursor-angle: 45deg; + --edge-sensitivity: 30; + --color-sensitivity: calc(var(--edge-sensitivity) + 20); + --border-radius: 28px; + --glow-padding: 40px; + --cone-spread: 25; + + position: relative; + border-radius: var(--border-radius); + isolation: isolate; + transform: translate3d(0, 0, 0.01px); + display: grid; + border: 1px solid var(--card-border, rgb(255 255 255 / 15%)); + background: var(--card-bg, #120f17); + overflow: visible; + box-shadow: + var(--card-shadow, + rgba(0, 0, 0, 0.1) 0px 1px 2px, + rgba(0, 0, 0, 0.1) 0px 2px 4px, + rgba(0, 0, 0, 0.1) 0px 4px 8px, + rgba(0, 0, 0, 0.1) 0px 8px 16px, + rgba(0, 0, 0, 0.1) 0px 16px 32px, + rgba(0, 0, 0, 0.1) 0px 32px 64px); +} + +.border-glow-card::before, +.border-glow-card::after, +.border-glow-card > .edge-light { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + transition: opacity 0.25s ease-out; + z-index: -1; +} + +.border-glow-card.border-glow-card--foreground::before, +.border-glow-card.border-glow-card--foreground::after, +.border-glow-card.border-glow-card--foreground > .edge-light { + z-index: 2; +} + +.border-glow-card:not(:hover):not(.sweep-active):not(.border-glow-card--always-on)::before, +.border-glow-card:not(:hover):not(.sweep-active):not(.border-glow-card--always-on)::after, +.border-glow-card:not(:hover):not(.sweep-active):not(.border-glow-card--always-on) > .edge-light { + opacity: 0; + transition: opacity 0.75s ease-in-out; +} + +.border-glow-card.non-interactive:not(.sweep-active):not(.border-glow-card--always-on)::before, +.border-glow-card.non-interactive:not(.sweep-active):not(.border-glow-card--always-on)::after, +.border-glow-card.non-interactive:not(.sweep-active):not(.border-glow-card--always-on) > .edge-light { + opacity: 0; + transition: opacity 0.75s ease-in-out; +} + +.border-glow-card::before { + border: 1px solid transparent; + background: + var(--card-bg, #120f17) padding-box, + linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box, + var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) border-box, + var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) border-box, + var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) border-box, + var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) border-box, + var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) border-box, + var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) border-box, + var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) border-box, + var(--gradient-base, linear-gradient(#c299ff 0 100%)) border-box; + + opacity: calc((var(--edge-proximity) - var(--color-sensitivity)) / (100 - var(--color-sensitivity))); + + mask-image: + conic-gradient( + from var(--cursor-angle) at center, + black calc(var(--cone-spread) * 1%), + transparent calc((var(--cone-spread) + 15) * 1%), + transparent calc((100 - var(--cone-spread) - 15) * 1%), + black calc((100 - var(--cone-spread)) * 1%) + ); +} + +.border-glow-card::after { + border: 1px solid transparent; + background: + var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-base, linear-gradient(#c299ff 0 100%)) padding-box; + + mask-image: + linear-gradient(to bottom, black, black), + radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%), + radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%), + radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%), + radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%), + radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%), + conic-gradient(from var(--cursor-angle) at center, transparent 5%, black 15%, black 85%, transparent 95%); + + mask-composite: subtract, add, add, add, add, add; + opacity: calc(var(--fill-opacity, 0.5) * (var(--edge-proximity) - var(--color-sensitivity)) / (100 - var(--color-sensitivity))); + mix-blend-mode: soft-light; +} + +.border-glow-card > .edge-light { + inset: calc(var(--glow-padding) * -1); + pointer-events: none; + z-index: 1; + + mask-image: + conic-gradient( + from var(--cursor-angle) at center, black 2.5%, transparent 10%, transparent 90%, black 97.5% + ); + + opacity: calc((var(--edge-proximity) - var(--edge-sensitivity)) / (100 - var(--edge-sensitivity))); + mix-blend-mode: plus-lighter; +} + +.border-glow-card > .edge-light::before { + content: ""; + position: absolute; + inset: var(--glow-padding); + border-radius: inherit; + box-shadow: + inset 0 0 0 1px var(--glow-color, hsl(40deg 80% 80% / 100%)), + inset 0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)), + inset 0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)), + inset 0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)), + inset 0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)), + inset 0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)), + inset 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)), + 0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)), + 0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)), + 0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)), + 0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)), + 0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)), + 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)); +} + +.border-glow-card.border-glow-css-sweep::before, +.border-glow-card.border-glow-css-sweep::after { + display: none; +} + +.border-glow-card.border-glow-css-sweep > .edge-light { + inset: 0; + border-radius: inherit; + mask-image: none; + mix-blend-mode: normal; + opacity: 0; + overflow: hidden; + transform-origin: center; + will-change: transform, opacity; + z-index: 2; +} + +.border-glow-card.border-glow-css-sweep > .edge-light::before { + background: + conic-gradient( + from -22deg at center, + transparent 0deg, + transparent 38deg, + var(--glow-color-10, hsl(40deg 80% 80% / 10%)) 54deg, + var(--glow-color-30, hsl(40deg 80% 80% / 30%)) 72deg, + var(--glow-color-10, hsl(40deg 80% 80% / 10%)) 96deg, + transparent 122deg, + transparent 360deg + ); + border-radius: inherit; + bottom: 0; + box-shadow: none; + filter: blur(12px); + height: auto; + inset: 0; + opacity: 0.5; + right: 0; + top: 0; + transform: none; + width: auto; +} + +.border-glow-card.border-glow-css-sweep > .edge-light::after { + content: none; +} + +.border-glow-inner { + display: flex; + flex-direction: column; + position: relative; + overflow: auto; + z-index: 1; +} diff --git a/src/components/common/BorderGlow.tsx b/src/components/common/BorderGlow.tsx new file mode 100644 index 00000000..761625fa --- /dev/null +++ b/src/components/common/BorderGlow.tsx @@ -0,0 +1,240 @@ +import { + CSSProperties, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import './BorderGlow.css'; + +type BorderGlowProps = { + children: ReactNode; + className?: string; + interactive?: boolean; + alwaysOn?: boolean; + foregroundGlow?: boolean; + reverseSweep?: boolean; + edgeSensitivity?: number; + glowColor?: string; + backgroundColor?: string; + borderRadius?: number; + glowRadius?: number; + glowIntensity?: number; + coneSpread?: number; + animated?: boolean; + loopAnimated?: boolean; + animationDurationMs?: number; + colors?: string[]; + fillOpacity?: number; + style?: CSSProperties; +}; + +function parseHSL(hslStr: string) { + const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/); + if (!match) return { h: 40, s: 80, l: 80 }; + return { + h: parseFloat(match[1]), + s: parseFloat(match[2]), + l: parseFloat(match[3]), + }; +} + +function buildGlowVars(glowColor: string, intensity: number) { + const { h, s, l } = parseHSL(glowColor); + const base = `${h}deg ${s}% ${l}%`; + const opacities = [100, 60, 50, 40, 30, 20, 10]; + const keys = ['', '-60', '-50', '-40', '-30', '-20', '-10']; + const vars: Record = {}; + + for (let i = 0; i < opacities.length; i += 1) { + vars[`--glow-color${keys[i]}`] = `hsl(${base} / ${Math.min( + opacities[i] * intensity, + 100 + )}%)`; + } + + return vars; +} + +const GRADIENT_POSITIONS = [ + '80% 55%', + '69% 34%', + '8% 6%', + '41% 38%', + '86% 85%', + '82% 18%', + '51% 4%', +]; +const GRADIENT_KEYS = [ + '--gradient-one', + '--gradient-two', + '--gradient-three', + '--gradient-four', + '--gradient-five', + '--gradient-six', + '--gradient-seven', +]; +const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1]; + +function buildGradientVars(colors: string[]) { + const vars: Record = {}; + for (let i = 0; i < 7; i += 1) { + const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)]; + vars[GRADIENT_KEYS[i]] = + `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`; + } + vars['--gradient-base'] = `linear-gradient(${colors[0]} 0 100%)`; + return vars; +} + +function getEdgeProximity(width: number, height: number, x: number, y: number) { + const cx = width / 2; + const cy = height / 2; + const dx = x - cx; + const dy = y - cy; + let kx = Infinity; + let ky = Infinity; + if (dx !== 0) kx = cx / Math.abs(dx); + if (dy !== 0) ky = cy / Math.abs(dy); + return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1); +} + +function getCursorAngle(width: number, height: number, x: number, y: number) { + const dx = x - width / 2; + const dy = y - height / 2; + if (dx === 0 && dy === 0) return 0; + const radians = Math.atan2(dy, dx); + let degrees = radians * (180 / Math.PI) + 90; + if (degrees < 0) degrees += 360; + return degrees; +} + +const BorderGlow = ({ + children, + className = '', + interactive = true, + alwaysOn = false, + foregroundGlow = false, + reverseSweep = false, + edgeSensitivity = 30, + glowColor = '40 80 80', + backgroundColor = '#120F17', + borderRadius = 28, + glowRadius = 40, + glowIntensity = 1.0, + coneSpread = 25, + animated = false, + loopAnimated = false, + animationDurationMs = 4000, + colors = ['#c084fc', '#f472b6', '#38bdf8'], + fillOpacity = 0.5, + style, +}: BorderGlowProps) => { + const cardRef = useRef(null); + const rectRef = useRef(null); + const pointerRef = useRef({ clientX: 0, clientY: 0 }); + const pointerRafRef = useRef(null); + + const updatePointerGlow = useCallback(() => { + pointerRafRef.current = null; + const card = cardRef.current; + const rect = rectRef.current; + if (!card || !rect) return; + + const x = pointerRef.current.clientX - rect.left; + const y = pointerRef.current.clientY - rect.top; + const edge = getEdgeProximity(rect.width, rect.height, x, y); + const angle = getCursorAngle(rect.width, rect.height, x, y); + + card.style.setProperty('--edge-proximity', `${(edge * 100).toFixed(3)}`); + card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`); + }, []); + + const cacheCardRect = useCallback(() => { + if (!cardRef.current) return; + rectRef.current = cardRef.current.getBoundingClientRect(); + }, []); + + const handlePointerEnter = useCallback(() => { + cacheCardRect(); + }, [cacheCardRect]); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!rectRef.current) { + cacheCardRect(); + } + + pointerRef.current.clientX = e.clientX; + pointerRef.current.clientY = e.clientY; + + if (pointerRafRef.current === null) { + pointerRafRef.current = requestAnimationFrame(updatePointerGlow); + } + }, + [cacheCardRect, updatePointerGlow] + ); + + const handlePointerLeave = useCallback(() => { + rectRef.current = null; + if (pointerRafRef.current !== null) { + cancelAnimationFrame(pointerRafRef.current); + pointerRafRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + if (pointerRafRef.current !== null) { + cancelAnimationFrame(pointerRafRef.current); + } + }; + }, []); + + const glowVars = useMemo( + () => buildGlowVars(glowColor, glowIntensity), + [glowColor, glowIntensity] + ); + const gradientVars = useMemo(() => buildGradientVars(colors), [colors]); + const useCssSweep = alwaysOn || animated; + const sweepIterationCount = loopAnimated || alwaysOn ? 'infinite' : 1; + + return ( +
+ +
{children}
+
+ ); +}; + +export default BorderGlow; diff --git a/src/components/common/DecryptedText.tsx b/src/components/common/DecryptedText.tsx new file mode 100644 index 00000000..04347e38 --- /dev/null +++ b/src/components/common/DecryptedText.tsx @@ -0,0 +1,138 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Box } from '@mui/material'; + +type DecryptedTextProps = { + text: string; + animateOn?: 'hover'; + active?: boolean; + speed?: number; + maxIterations?: number; + sequential?: boolean; + revealDirection?: 'start' | 'end'; + useOriginalCharsOnly?: boolean; +}; + +const getScrambledChar = ( + pool: string[], + fallback: string, + index: number, + step: number +) => { + if (!pool.length) return fallback; + return pool[(index + step) % pool.length] ?? fallback; +}; + +export const DecryptedText = ({ + text, + animateOn = 'hover', + active = false, + speed = 35, + maxIterations = 12, + sequential = true, + revealDirection = 'start', + useOriginalCharsOnly = true, +}: DecryptedTextProps) => { + const [displayText, setDisplayText] = useState(text); + const timeoutRef = useRef(null); + const stepRef = useRef(0); + + const charPool = useMemo( + () => Array.from(new Set(text.split('').filter((char) => char.trim()))), + [text] + ); + + useEffect(() => { + setDisplayText(text); + + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, [text]); + + useEffect(() => { + if (animateOn !== 'hover') return; + + if (active) { + runAnimation(); + return; + } + + stopAnimation(); + }, [active, animateOn]); + + const stopAnimation = () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + stepRef.current = 0; + setDisplayText(text); + }; + + const runAnimation = () => { + stopAnimation(); + + const tick = () => { + const step = stepRef.current; + const progress = sequential + ? Math.min( + text.length, + Math.ceil(((step + 1) / maxIterations) * text.length) + ) + : 0; + + const chars = text.split('').map((char, index) => { + if (char === ' ') return char; + + const revealed = + revealDirection === 'start' + ? index < progress + : index >= text.length - progress; + + if (revealed || step >= maxIterations - 1) { + return char; + } + + return useOriginalCharsOnly + ? getScrambledChar(charPool, char, index, step) + : getScrambledChar( + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split(''), + char, + index, + step + ); + }); + + setDisplayText(chars.join('')); + + if (step < maxIterations - 1) { + stepRef.current += 1; + timeoutRef.current = window.setTimeout(tick, speed); + return; + } + + timeoutRef.current = null; + stepRef.current = 0; + setDisplayText(text); + }; + + tick(); + }; + + return ( + + {displayText} + + ); +}; diff --git a/src/components/common/GlassSurface.css b/src/components/common/GlassSurface.css new file mode 100644 index 00000000..24bcb40e --- /dev/null +++ b/src/components/common/GlassSurface.css @@ -0,0 +1,103 @@ +.glass-surface { + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + backface-visibility: hidden; + transform: translateZ(0); + transition: opacity 0.26s ease-out; +} + +.glass-surface__filter { + width: 100%; + height: 100%; + pointer-events: none; + position: absolute; + inset: 0; + opacity: 0; + z-index: -1; +} + +.glass-surface__content { + width: 100%; + height: 100%; + display: flex; + border-radius: inherit; + overflow: hidden; + position: relative; + z-index: 1; +} + +.glass-surface--svg { + background: light-dark(hsl(0 0% 100% / var(--glass-frost, 0)), hsl(0 0% 0% / var(--glass-frost, 0))); + backdrop-filter: var(--filter-id, url(#glass-filter)) saturate(var(--glass-saturation, 1)); + box-shadow: + 0 0 2px 1px light-dark(color-mix(in oklch, black, transparent 85%), color-mix(in oklch, white, transparent 65%)) inset, + 0 0 10px 4px light-dark(color-mix(in oklch, black, transparent 90%), color-mix(in oklch, white, transparent 85%)) inset, + 0 4px 16px rgba(17, 17, 26, 0.05), + 0 8px 24px rgba(17, 17, 26, 0.05), + 0 16px 56px rgba(17, 17, 26, 0.05), + 0 4px 16px rgba(17, 17, 26, 0.05) inset, + 0 8px 24px rgba(17, 17, 26, 0.05) inset, + 0 16px 56px rgba(17, 17, 26, 0.05) inset; +} + +.glass-surface--fallback { + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(12px) saturate(1.8) brightness(1.1); + -webkit-backdrop-filter: blur(12px) saturate(1.8) brightness(1.1); + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: + 0 8px 32px 0 rgba(31, 38, 135, 0.2), + 0 2px 16px 0 rgba(31, 38, 135, 0.1), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.2); +} + +@media (prefers-color-scheme: dark) { + .glass-surface--fallback { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px) saturate(1.8) brightness(1.2); + -webkit-backdrop-filter: blur(12px) saturate(1.8) brightness(1.2); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.1); + } +} + +@supports not (backdrop-filter: blur(10px)) { + .glass-surface--fallback { + background: rgba(255, 255, 255, 0.4); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.5), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.3); + } + + .glass-surface--fallback::before { + content: ''; + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.15); + border-radius: inherit; + z-index: -1; + } +} + +@supports not (backdrop-filter: blur(10px)) { + @media (prefers-color-scheme: dark) { + .glass-surface--fallback { + background: rgba(0, 0, 0, 0.4); + } + + .glass-surface--fallback::before { + background: rgba(255, 255, 255, 0.05); + } + } +} + +.glass-surface:focus-visible { + outline: 2px solid light-dark(#007aff, #0a84ff); + outline-offset: 2px; +} diff --git a/src/components/common/GlassSurface.tsx b/src/components/common/GlassSurface.tsx new file mode 100644 index 00000000..e2d0ad6b --- /dev/null +++ b/src/components/common/GlassSurface.tsx @@ -0,0 +1,235 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react'; +import './GlassSurface.css'; + +type GlassSurfaceProps = { + children: ReactNode; + width?: number | string; + height?: number | string; + borderRadius?: number; + borderWidth?: number; + brightness?: number; + opacity?: number; + blur?: number; + displace?: number; + backgroundOpacity?: number; + saturation?: number; + distortionScale?: number; + redOffset?: number; + greenOffset?: number; + blueOffset?: number; + xChannel?: 'R' | 'G' | 'B' | 'A'; + yChannel?: 'R' | 'G' | 'B' | 'A'; + mixBlendMode?: CSSProperties['mixBlendMode']; + className?: string; + style?: CSSProperties; +}; + +export default function GlassSurface({ + children, + width = '100%', + height = '100%', + borderRadius = 20, + borderWidth = 0.07, + brightness = 50, + opacity = 0.93, + blur = 11, + displace = 0, + backgroundOpacity = 0, + saturation = 1, + distortionScale = -180, + redOffset = 0, + greenOffset = 10, + blueOffset = 20, + xChannel = 'R', + yChannel = 'G', + mixBlendMode = 'difference', + className = '', + style = {}, +}: GlassSurfaceProps) { + const uniqueId = useId().replace(/:/g, '-'); + const filterId = `glass-filter-${uniqueId}`; + const redGradId = `red-grad-${uniqueId}`; + const blueGradId = `blue-grad-${uniqueId}`; + + const [svgSupported, setSvgSupported] = useState(false); + + const containerRef = useRef(null); + const feImageRef = useRef(null); + const redChannelRef = useRef(null); + const greenChannelRef = useRef(null); + const blueChannelRef = useRef(null); + const gaussianBlurRef = useRef(null); + + const generateDisplacementMap = () => { + const rect = containerRef.current?.getBoundingClientRect(); + const actualWidth = rect?.width || 400; + const actualHeight = rect?.height || 200; + const edgeSize = Math.min(actualWidth, actualHeight) * (borderWidth * 0.5); + + const svgContent = ` + + + + + + + + + + + + + + + + + `; + + return `data:image/svg+xml,${encodeURIComponent(svgContent)}`; + }; + + const updateDisplacementMap = () => { + feImageRef.current?.setAttribute('href', generateDisplacementMap()); + }; + + useEffect(() => { + updateDisplacementMap(); + + [ + { ref: redChannelRef, offset: redOffset }, + { ref: greenChannelRef, offset: greenOffset }, + { ref: blueChannelRef, offset: blueOffset }, + ].forEach(({ ref, offset }) => { + if (ref.current) { + ref.current.setAttribute('scale', (distortionScale + offset).toString()); + ref.current.setAttribute('xChannelSelector', xChannel); + ref.current.setAttribute('yChannelSelector', yChannel); + } + }); + + gaussianBlurRef.current?.setAttribute('stdDeviation', displace.toString()); + }, [ + width, + height, + borderRadius, + borderWidth, + brightness, + opacity, + blur, + displace, + distortionScale, + redOffset, + greenOffset, + blueOffset, + xChannel, + yChannel, + mixBlendMode, + ]); + + useEffect(() => { + if (!containerRef.current) return undefined; + + const resizeObserver = new ResizeObserver(() => { + setTimeout(updateDisplacementMap, 0); + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + setTimeout(updateDisplacementMap, 0); + }, [width, height]); + + useEffect(() => { + const supportsSVGFilters = () => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return false; + } + + const isWebkit = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); + const isFirefox = /Firefox/.test(navigator.userAgent); + + if (isWebkit || isFirefox) { + return false; + } + + const div = document.createElement('div'); + div.style.backdropFilter = `url(#${filterId})`; + + return div.style.backdropFilter !== ''; + }; + + setSvgSupported(supportsSVGFilters()); + }, [filterId]); + + const containerStyle = { + ...style, + width: typeof width === 'number' ? `${width}px` : width, + height: typeof height === 'number' ? `${height}px` : height, + borderRadius: `${borderRadius}px`, + '--glass-radius': `${borderRadius}px`, + '--glass-frost': backgroundOpacity, + '--glass-saturation': saturation, + '--filter-id': `url(#${filterId})`, + } as CSSProperties; + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/src/components/common/TiltedCard.css b/src/components/common/TiltedCard.css new file mode 100644 index 00000000..16e72d56 --- /dev/null +++ b/src/components/common/TiltedCard.css @@ -0,0 +1,36 @@ +.tilted-card-figure { + position: relative; + perspective: 800px; + transform-style: preserve-3d; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; +} + +.tilted-card-inner { + position: relative; + display: inline-block; + transform-style: preserve-3d; + will-change: transform; +} + +.tilted-card-content { + display: inline-flex; + transform: translateZ(18px); + transform-style: preserve-3d; +} + +.tilted-card-caption { + pointer-events: none; + position: absolute; + left: 0; + top: 0; + border-radius: 4px; + background-color: #fff; + padding: 4px 10px; + font-size: 10px; + color: #2d2d2d; + opacity: 0; + z-index: 3; +} diff --git a/src/components/common/TiltedCard.tsx b/src/components/common/TiltedCard.tsx new file mode 100644 index 00000000..138c727b --- /dev/null +++ b/src/components/common/TiltedCard.tsx @@ -0,0 +1,131 @@ +import { ReactNode, useRef, useState } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import './TiltedCard.css'; + +const springValues = { + damping: 30, + stiffness: 100, + mass: 2, +}; + +type TiltedCardProps = { + children: ReactNode; + className?: string; + captionText?: string; + containerHeight?: string; + containerWidth?: string; + innerHeight?: string; + innerWidth?: string; + scaleOnHover?: number; + rotateAmplitude?: number; + showTooltip?: boolean; + disabled?: boolean; +}; + +export default function TiltedCard({ + children, + className = '', + captionText = '', + containerHeight, + containerWidth, + innerHeight, + innerWidth, + scaleOnHover = 1.06, + rotateAmplitude = 10, + showTooltip = false, + disabled = false, +}: TiltedCardProps) { + const ref = useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + const rotateX = useSpring(0, springValues); + const rotateY = useSpring(0, springValues); + const scale = useSpring(1, springValues); + const opacity = useSpring(0, springValues); + const rotateFigcaption = useSpring(0, { + stiffness: 350, + damping: 30, + mass: 1, + }); + const [lastY, setLastY] = useState(0); + + const handleMouseMove = (e: React.MouseEvent) => { + if (disabled || !ref.current) return; + + const rect = ref.current.getBoundingClientRect(); + const offsetX = e.clientX - rect.left - rect.width / 2; + const offsetY = e.clientY - rect.top - rect.height / 2; + const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude; + const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude; + + rotateX.set(rotationX); + rotateY.set(rotationY); + x.set(e.clientX - rect.left); + y.set(e.clientY - rect.top); + + const velocityY = offsetY - lastY; + rotateFigcaption.set(-velocityY * 0.6); + setLastY(offsetY); + }; + + const handleMouseEnter = () => { + if (disabled) return; + scale.set(scaleOnHover); + opacity.set(1); + }; + + const handleMouseLeave = () => { + opacity.set(0); + scale.set(1); + rotateX.set(0); + rotateY.set(0); + rotateFigcaption.set(0); + setLastY(0); + }; + + return ( +
} + onMouseEnter={handleMouseEnter} + onPointerEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onPointerLeave={handleMouseLeave} + > + +
{children}
+
+ + {showTooltip ? ( + + {captionText} + + ) : null} +
+ ); +} diff --git a/src/components/ui/confetti.tsx b/src/components/ui/confetti.tsx new file mode 100644 index 00000000..0554e193 --- /dev/null +++ b/src/components/ui/confetti.tsx @@ -0,0 +1,140 @@ +"use client"; + +import type { ReactNode } from 'react'; +import React, { + createContext, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; +import type { + CreateTypes as ConfettiInstance, + GlobalOptions as ConfettiGlobalOptions, + Options as ConfettiOptions, +} from 'canvas-confetti'; +import confetti from 'canvas-confetti'; +import { Button } from '@mui/material'; + +type Api = { + fire: (options?: ConfettiOptions) => void; +}; + +type Props = React.ComponentPropsWithRef<'canvas'> & { + children?: ReactNode; + globalOptions?: ConfettiGlobalOptions; + manualstart?: boolean; + options?: ConfettiOptions; +}; + +export type ConfettiRef = Api | null; + +export const ConfettiContext = createContext({} as Api); +const DEFAULT_CONFETTI_GLOBAL_OPTIONS: ConfettiGlobalOptions = { + resize: true, + useWorker: true, +}; + +const ConfettiComponent = forwardRef((props, ref) => { + const { + options, + globalOptions = DEFAULT_CONFETTI_GLOBAL_OPTIONS, + manualstart = false, + children, + ...rest + } = props; + const instanceRef = useRef(null); + + const canvasRef = useCallback( + (node: HTMLCanvasElement | null) => { + if (node !== null) { + if (instanceRef.current) return; + instanceRef.current = confetti.create(node, { + ...globalOptions, + resize: true, + }); + } else if (instanceRef.current) { + instanceRef.current.reset(); + instanceRef.current = null; + } + }, + [globalOptions] + ); + + const fire = useCallback( + async (opts: ConfettiOptions = {}) => { + try { + await instanceRef.current?.({ ...options, ...opts }); + } catch (error) { + console.error('Confetti error:', error); + } + }, + [options] + ); + + const api = useMemo( + () => ({ + fire, + }), + [fire] + ); + + useImperativeHandle(ref, () => api, [api]); + + useEffect(() => { + if (!manualstart) { + void fire(); + } + }, [manualstart, fire]); + + return ( + + + {children} + + ); +}); + +ConfettiComponent.displayName = 'Confetti'; + +export const Confetti = ConfettiComponent; + +interface ConfettiButtonProps extends React.ComponentProps<'button'> { + options?: ConfettiOptions & + ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; +} + +const ConfettiButtonComponent = ({ + options, + children, + ...props +}: ConfettiButtonProps) => { + const handleClick = async (event: React.MouseEvent) => { + try { + const rect = event.currentTarget.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + await confetti({ + ...options, + origin: { + x: x / window.innerWidth, + y: y / window.innerHeight, + }, + }); + } catch (error) { + console.error('Confetti button error:', error); + } + }; + + return ( + + ); +}; + +ConfettiButtonComponent.displayName = 'ConfettiButton'; + +export const ConfettiButton = ConfettiButtonComponent; diff --git a/src/components/ui/dot-pattern.tsx b/src/components/ui/dot-pattern.tsx new file mode 100644 index 00000000..26b0d40e --- /dev/null +++ b/src/components/ui/dot-pattern.tsx @@ -0,0 +1,46 @@ +import { Box } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import type { ComponentProps } from 'react'; + +type DotPatternProps = ComponentProps & { + color?: string; + cr?: number; + cx?: number; + cy?: number; + height?: number; + width?: number; +}; + +export const DotPattern = ({ + color = '#8DB8FF', + cr = 1, + cx = 1, + cy = 1, + height = 20, + sx, + width = 20, + ...rest +}: DotPatternProps) => { + const innerDot = `${alpha(color, 0.72)} 0 ${cr}px, transparent ${cr + 0.45}px`; + const outerGlow = `${alpha(color, 0.24)} 0 ${cr + 0.95}px, transparent ${ + cr + 1.95 + }px`; + + return ( +