diff --git a/electron/package-lock.json b/electron/package-lock.json index ea077640..a5b28caf 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -17,11 +17,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", @@ -1086,6 +1088,16 @@ "license": "MIT", "optional": true }, + "node_modules/@types/write-file-atomic": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz", + "integrity": "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5990,6 +6002,30 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", diff --git a/electron/package.json b/electron/package.json index b535920a..11d2cc15 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/index.ts b/electron/src/index.ts index f5ba3515..c9682c7e 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -17,6 +17,7 @@ import { import { log as loggerLog, error as loggerError } from './logger'; import { ElectronCapacitorApp, + flushPersistentStore, setupContentSecurityPolicy, setupReloadWatcher, } from './setup'; @@ -193,6 +194,7 @@ async function setupMultiInstanceUserData(basePort = 55000, maxInstances = 10) { // Set isQuitting flag before the app quits app.on('before-quit', () => { setIsQuitting(true); + flushPersistentStore(); }); // 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..9158d91d 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -18,14 +18,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 })), + ipcRenderer + .invoke('window:isMaximized') + .then((isMaximized: boolean) => ({ isMaximized })), getPlatform: () => ipcRenderer.invoke('window:getPlatform'), showAppMenu: (x?: number, y?: number) => ipcRenderer.invoke('window:showAppMenu', { x, y }), getAppSettings: () => ipcRenderer.invoke('appSettings:get'), - setAppSettings: (settings: { closeAction?: 'ask' | 'minimizeToTray' | 'quit' }) => - ipcRenderer.invoke('appSettings:set', settings), + setAppSettings: (settings: { + closeAction?: 'ask' | 'minimizeToTray' | 'quit'; + }) => ipcRenderer.invoke('appSettings:set', settings), }); // Expose other utility functions @@ -86,6 +90,19 @@ try { }, }); + // 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); + }, + }); + // Expose it contextBridge.exposeInMainWorld('coreSetup', { isCoreRunning: async () => { diff --git a/electron/src/setup.ts b/electron/src/setup.ts index dc136801..fef79386 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -55,6 +55,7 @@ import { const AdmZip = require('adm-zip'); const fs = require('fs'); const path = require('path'); +const writeFileAtomic = require('write-file-atomic'); const defaultDomains = [ 'capacitor-electron://-', @@ -540,6 +541,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(); @@ -652,7 +661,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 +673,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 @@ -697,6 +711,126 @@ ipcMain.handle( } ); +// 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'; + +let persistentStoreCache: Record | null = null; +let persistentStoreLoadedFromDisk = false; + +function getPersistentStoreFilePath(): string { + const dir = path.join(app.getPath('appData'), 'qortal-hub'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, PERSISTENT_STORE_FILENAME); +} + +function parsePersistentStoreRaw(raw: string): Record { + const trimmed = raw?.trim() ?? ''; + if (trimmed === '') return {}; + try { + return (JSON.parse(trimmed) as Record) || {}; + } catch (_) { + return {}; + } +} + +async function readPersistentStoreFromDisk(): Promise> { + try { + const filePath = getPersistentStoreFilePath(); + 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 persistent store from disk', err); + return {}; + } +} + +async function loadPersistentStore(): Promise> { + if (persistentStoreCache !== null) return persistentStoreCache; + const data = await readPersistentStoreFromDisk(); + const hadData = Object.keys(data).length > 0; + persistentStoreCache = data; + if (hadData) persistentStoreLoadedFromDisk = true; + return persistentStoreCache; +} + + +export function flushPersistentStore(): void { + if (persistentStoreCache === null) return; + if ( + !persistentStoreLoadedFromDisk && + Object.keys(persistentStoreCache).length === 0 + ) { + return; + } + try { + const filePath = getPersistentStoreFilePath(); + // Read current on-disk state, merge our cache on top, write atomically (sync). + let onDisk: Record = {}; + if (fs.existsSync(filePath)) { + try { + onDisk = parsePersistentStoreRaw(fs.readFileSync(filePath, 'utf-8')); + } catch (_) { + onDisk = {}; + } + } + const merged = { ...onDisk, ...persistentStoreCache }; + writeFileAtomic.sync(filePath, JSON.stringify(merged, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError('Error flushing persistent store', err); + } +} + +ipcMain.handle('persistentStore:get', async (_event, key: string) => { + const store = await loadPersistentStore(); + return store[key]; +}); + +ipcMain.handle( + 'persistentStore:set', + async (_event, key: string, value: unknown) => { + // 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 readPersistentStoreFromDisk(); + onDisk[key] = value; + try { + const filePath = getPersistentStoreFilePath(); + await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError('Error writing persistent store (set)', err); + } + // Keep local cache in sync. + if (persistentStoreCache === null) persistentStoreCache = {}; + persistentStoreCache[key] = value; + persistentStoreLoadedFromDisk = true; + } +); + +ipcMain.handle('persistentStore:delete', async (_event, key: string) => { + // Read-merge-write: fetch fresh disk state, remove the key, write atomically. + const onDisk = await readPersistentStoreFromDisk(); + delete onDisk[key]; + try { + const filePath = getPersistentStoreFilePath(); + await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2), { + encoding: 'utf8', + }); + } catch (err) { + loggerError('Error writing persistent store (delete)', err); + } + // Keep local cache in sync. + if (persistentStoreCache !== null) delete persistentStoreCache[key]; +}); + // App settings (stored in SharedSettingsFilePath) - e.g. close/minimize to tray ipcMain.handle('appSettings:get', async () => { return readAppSettings(); 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/src/App.tsx b/src/App.tsx index b9db8306..4798d2d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,8 +26,10 @@ import { ConnectionRequestScreen, CountdownOverlay, CreateWalletView, + ElectronPersistentStorageHydration, InfoDialog, NotAuthenticatedFooter, + NotificationPermissionSlideDown, PaymentPublishDialog, PaymentRequestScreen, QortalRequestExtensionDialog, @@ -136,6 +138,13 @@ function App() { const [requestConnection, setRequestConnection] = useState(null); const [requestBuyOrder, setRequestBuyOrder] = useState(null); const [userInfo, setUserInfo] = useAtom(userInfoAtom); + useEffect(() => { + const w = window as Window & { __qortalCurrentAddress?: string | null }; + w.__qortalCurrentAddress = userInfo?.address ?? null; + return () => { + delete w.__qortalCurrentAddress; + }; + }, [userInfo?.address]); const [balance, setBalance] = useAtom(balanceAtom); const [paymentTo, setPaymentTo] = useState(''); const [sendPaymentError, setSendPaymentError] = useState(''); @@ -994,6 +1003,7 @@ function App() { + {extState === 'not-authenticated' && ( @@ -1212,6 +1222,7 @@ function App() { onCancel={onCancelUnsavedChanges} onConfirm={() => onOkUnsavedChanges(undefined)} /> + {isMainWindow && } {isShowQortalRequestExtension && isMainWindow && ( (null) as PrimitiveAtom; +export const groupInvitesCacheAtom = atom( + null +) as PrimitiveAtom; /** Cache for join requests (admin list). Invalidated after TTL or explicit refresh. */ export type JoinRequestsCache = { @@ -70,7 +71,9 @@ export type JoinRequestsCache = { fetchedAt: number; adminGroupIds: number[]; } | null; -export const joinRequestsCacheAtom = atom(null) as PrimitiveAtom; +export const joinRequestsCacheAtom = atom( + null +) as PrimitiveAtom; export const qMailLastEnteredTimestampAtom = atomWithReset(null); export const resourceDownloadControllerAtom = atomWithReset({}); export const globalDownloadsAtom = atomWithReset< @@ -92,19 +95,330 @@ export const globalChatWidgetBoundsAtom = atomWithStorage<{ x: number; width: number; height: number; -} | null>( - 'qortal_chat_widget_bounds', - null, - undefined, - { getOnInit: true } +} | null>('qortal_chat_widget_bounds', null, undefined, { 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 + +/** Internal: per-address record of notificationKey -> addedAt (ms). Pruned to last 3 days when read/set. */ +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); + } +); + +/** 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([]); + +/** 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 isOpenDialogCoreRecommendationAtom = 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 rawWalletAtom = atomWithReset(null); export const walletToBeDecryptedErrorAtom = atomWithReset(''); export const balanceAtom = atomWithReset(null); diff --git a/src/background/background-cases.ts b/src/background/background-cases.ts index 2525373d..4ffb238e 100644 --- a/src/background/background-cases.ts +++ b/src/background/background-cases.ts @@ -1426,11 +1426,16 @@ 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( diff --git a/src/background/background.ts b/src/background/background.ts index d5840086..463793f0 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'; @@ -136,7 +137,15 @@ export const groupApiSocketLocal = 'ws://' + LOCALHOST_12391; const timeDifferenceForNotificationChatsBackground = 86400000; const requestQueueAnnouncements = new RequestQueueWithPromise(1); +/** Payload for general-notification OS clicks (open q-app or wallets). Cleared after use or after 10s. */ +const generalNotificationPayloadById = new Map(); + function handleNotificationClick(notificationId) { + // Focus the app window when on Electron (e.g. after clicking an OS notification) + if (typeof window?.electronAPI?.focusWindow === 'function') { + window.electronAPI.focusWindow(); + } + // Decode the notificationId if it was encoded const decodedNotificationId = decodeURIComponent(notificationId); @@ -147,6 +156,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 +167,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 } }, @@ -3369,10 +3390,7 @@ function setupMessageListener() { clearInterval(notificationCheckInterval); notificationCheckInterval = null; } - if (paymentsCheckInterval) { - clearInterval(paymentsCheckInterval); - paymentsCheckInterval = null; - } + groupSecretkeys = {}; const wallet = await getSaveWallet(); const address = wallet.address0; @@ -3566,82 +3584,54 @@ 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`; + const notificationId = encodeURIComponent( + 'general_notification_' + Date.now() + '_type=general-notification' + ); - // Create and show the notification - const notification = new window.Notification(title, { - body, - icon: window.location.origin + '/qortal192.png', - data: { id: notificationId }, - }); + generalNotificationPayloadById.set( + notificationId, + qortalLink ? { link: qortalLink } : { openWallets: true } + ); - // Handle notification click with specific actions based on `notificationId` - notification.onclick = () => { - handleNotificationClick(notificationId); - notification.close(); // Clean up the notification on click - }; + const body = messageBody; - // Automatically close the notification after 5 seconds if it’s not clicked - setTimeout(() => { - notification.close(); - }, 10000); // Close after 5 seconds + const osNotification = new window.Notification(title, { + body, + icon, + data: { id: notificationId }, + }); - const targetOrigin = window.location.origin; + osNotification.onclick = () => { + handleNotificationClick(notificationId); + osNotification.close(); + }; - window.postMessage( - { - action: 'SET_PAYMENT_ANNOUNCEMENT', - payload: latestPayment, - }, - targetOrigin - ); - } + setTimeout(() => { + generalNotificationPayloadById.delete(notificationId); + osNotification.close(); + }, 10000); } catch (error) { console.error(error); } @@ -3814,7 +3804,6 @@ export const checkThreads = async (bringBack) => { }; let notificationCheckInterval; -let paymentsCheckInterval; const createNotificationCheck = () => { // Check if an interval already exists before creating it @@ -3834,21 +3823,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/components/App/ElectronPersistentStorageHydration.tsx b/src/components/App/ElectronPersistentStorageHydration.tsx new file mode 100644 index 00000000..75c7b5fd --- /dev/null +++ b/src/components/App/ElectronPersistentStorageHydration.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { + customWebsocketSubscriptionsByAddressAtom, + filterSeenInAppRecordByAge, + notificationSeenInAppKeysRecordAtom, + parseSeenInAppStored, + seenAllNotificationsByAddressAtom, +} from '../../atoms/global'; +import { + hydrateElectronPersistentCache, + ELECTRON_PERSISTENT_ATOM_KEYS, +} 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 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]); + + return null; +} diff --git a/src/components/App/NotificationPermissionSlideDown.tsx b/src/components/App/NotificationPermissionSlideDown.tsx new file mode 100644 index 00000000..5d6ab1aa --- /dev/null +++ b/src/components/App/NotificationPermissionSlideDown.tsx @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, Button, Paper, Typography, useTheme } from '@mui/material'; +import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; +import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; + +const TIMEOUT_MS = 60_000; +const TIMEOUT_S = TIMEOUT_MS / 1000; + +type Payload = { text1?: string }; + +type AppInfo = { name?: string; tabId?: string }; + +type PendingRequest = { + requestId: string; + appInfo: AppInfo; + payload: Payload; + /** Wall-clock time when get.ts will auto-deny this request. */ + expiresAt: number; +}; + +const defaultPayload: Payload = { + text1: 'Allow this app to send you Hub notifications?', +}; + +function sendResponse(requestId: string, result: { accepted: boolean }) { + window.postMessage( + { + action: 'NOTIFICATION_PERMISSION_RESPONSE', + requestId, + result, + }, + window.location.origin + ); +} + +export function NotificationPermissionSlideDown() { + const { t } = useTranslation('question'); + const theme = useTheme(); + // Queue: index 0 is the currently displayed request. + const [queue, setQueue] = useState([]); + const [secondsLeft, setSecondsLeft] = useState(TIMEOUT_S); + const autoTimeoutRef = useRef | null>(null); + const intervalRef = useRef | null>(null); + + // Listen for new requests and append to queue. + useEffect(() => { + const listener = (e: CustomEvent>) => { + const { requestId, appInfo, payload } = e.detail || {}; + if (!requestId || !appInfo) return; + setQueue((prev) => [ + ...prev, + { + requestId, + appInfo, + payload: payload || defaultPayload, + expiresAt: Date.now() + TIMEOUT_MS, + }, + ]); + }; + subscribeToEvent('show-notification-permission', listener as any); + return () => + unsubscribeFromEvent('show-notification-permission', listener as any); + }, []); + + // Whenever the front of the queue changes, start its own countdown using + // the remaining time from its original expiresAt. + 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()); + const remainingS = Math.ceil(remaining / 1000); + setSecondsLeft(remainingS); + + // Auto-deny when its time runs out. + autoTimeoutRef.current = setTimeout(() => { + sendResponse(current.requestId, { accepted: false }); + setQueue((prev) => prev.slice(1)); + }, remaining); + + // Tick the countdown display. + 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[0]?.requestId]); // eslint-disable-line react-hooks/exhaustive-deps + + const dismissCurrent = (accepted: boolean) => { + const current = queue[0]; + if (!current) return; + sendResponse(current.requestId, { accepted }); + setQueue((prev) => prev.slice(1)); + }; + + const current = queue[0]; + if (!current) return null; + + const { appInfo, payload } = current; + const text1 = payload?.text1 ?? defaultPayload.text1; + + const isDark = theme.palette.mode === 'dark'; + const iconBg = isDark + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.04)'; + + const progress = secondsLeft / TIMEOUT_S; + + return ( + + {/* Countdown progress bar */} + + + + + + + + + {t('permission.notification_title', { + appName: appInfo?.name || 'App', + postProcess: 'capitalizeFirstChar', + })} + + + {text1} + + + + + {secondsLeft}s + + {queue.length > 1 && ( + + +{queue.length - 1} more + + )} + + + + + + + + + ); +} diff --git a/src/components/App/index.ts b/src/components/App/index.ts index 9e2f7dc3..9e1278e8 100644 --- a/src/components/App/index.ts +++ b/src/components/App/index.ts @@ -16,3 +16,5 @@ export { InfoDialog } from './InfoDialog'; export { UnsavedChangesDialog } from './UnsavedChangesDialog'; export { QortalRequestExtensionDialog } from './QortalRequestExtensionDialog'; export type { MessageQortalRequestExtension } from './QortalRequestExtensionDialog'; +export { NotificationPermissionSlideDown } from './NotificationPermissionSlideDown'; +export { ElectronPersistentStorageHydration } from './ElectronPersistentStorageHydration'; diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index 00f4aa3e..5ce7ab70 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -310,6 +310,74 @@ export const AppViewer = forwardRef( }; }, [app, history, themeMode, currentLang]); + const navigateToPathFunc = useCallback( + async (e) => { + const { path: targetPath } = e.detail; + if (!targetPath || !iframeRef.current?.contentWindow) return; + + const targetOrigin = iframeRef.current + ? new URL(iframeRef.current.src).origin + : '*'; + + 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(`navigateToPath-${app?.tabId}`, navigateToPathFunc); + + return () => { + unsubscribeFromEvent( + `navigateToPath-${app?.tabId}`, + navigateToPathFunc + ); + }; + }, [app?.tabId, navigateToPathFunc]); + // Function to navigate back in iframe const navigateForwardInIframe = async () => { if (iframeRef.current && iframeRef.current.contentWindow) { diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index 19e4f4d1..41948b9a 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -45,6 +45,7 @@ import { useTranslation } from 'react-i18next'; import { TIME_MINUTES_20_IN_MILLISECONDS } from '../../constants/constants'; import { appHeighOffsetPx } from '../Desktop/CustomTitleBar'; import { APPS_BOTTOM_NAV_HEIGHT_PX } from './Apps-styles'; +import { flushSync } from 'react-dom'; const uid = new ShortUniqueId({ length: 8 }); @@ -271,6 +272,33 @@ export const AppsDesktop = ({ mode, setMode, show }) => { const addTabFunc = (e) => { const data = e.detail?.data; + + 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(() => { + setSelectedTab(existingTab); + setMode('viewer'); + setIsNewTabWindow(false); + }); + + if (path) { + setTimeout(() => { + executeEvent(`navigateToPath-${existingTab.tabId}`, { path }); + }, 100); + } + return; + } + } + const newTab = { ...data, tabId: uid.rnd(), diff --git a/src/components/Apps/AppsPrivate.tsx b/src/components/Apps/AppsPrivate.tsx index 622b391d..0864f484 100644 --- a/src/components/Apps/AppsPrivate.tsx +++ b/src/components/Apps/AppsPrivate.tsx @@ -163,7 +163,38 @@ export const AppsPrivate = ({ myName, myAddress }) => { const publishPrivateApp = async () => { try { - if (selectedGroup === 0) return; + if (selectedGroup === 0) { + setOpenSnackGlobal(true); + setInfoSnackCustom({ + type: 'error', + message: t('group:message.generic.no_selection', { + postProcess: 'capitalizeFirstChar', + }), + }); + return; + } + + if (!name || name === 0) { + setOpenSnackGlobal(true); + setInfoSnackCustom({ + type: 'error', + message: t('core:action.select_name_app', { + postProcess: 'capitalizeFirstChar', + }), + }); + return; + } + + if (!file) { + setOpenSnackGlobal(true); + setInfoSnackCustom({ + type: 'error', + message: t('core:message.generic.select_zip', { + postProcess: 'capitalizeFirstChar', + }), + }); + return; + } if (!logo) throw new Error( @@ -759,6 +790,10 @@ export const AppsPrivate = ({ myName, myAddress }) => { (null); - const searchQuery = directToValue.trim().length >= 1 ? directToValue.trim() : ''; + const searchQuery = + directToValue.trim().length >= 1 ? directToValue.trim() : ''; const { results: nameSearchResults, isLoading: nameSearchLoading } = useNameSearch(searchQuery, 15); const hasInitialized = useRef(false); @@ -905,199 +906,203 @@ export const ChatDirect = ({ {isNewChat && ( <> - setSuggestionsOpen(false)}> - - { - setDirectToValue(e.target.value); - setSuggestionsOpen(true); - }} - onFocus={() => setSuggestionsOpen(true)} - onKeyDown={(e) => { - if ( - e.key === 'Enter' && - directToValue.trim() && - validateAddress(directToValue.trim()) - ) { - e.preventDefault(); - handleSelectNameOrAddress(directToValue.trim()); - setSuggestionsOpen(false); - } - }} - autoFocus - slotProps={{ - htmlInput: { - 'aria-label': t('auth:message.generic.name_address', { - postProcess: 'capitalizeFirstChar', - }), - }, + setSuggestionsOpen(false)}> + - - - ), - endAdornment: nameSearchLoading ? ( - - - - ) : null, - sx: { - backgroundColor: theme.palette.background.paper, - borderRadius: '14px', - fontFamily: 'Inter', - fontSize: '15px', - transition: 'box-shadow 0.2s ease, border-color 0.2s ease', - '& fieldset': { - borderColor: theme.palette.divider, - borderRadius: '14px', - transition: 'border-color 0.2s ease', - }, - '&:hover fieldset': { - borderColor: theme.palette.text.secondary, - }, - '&.Mui-focused fieldset': { - borderWidth: '2px', - borderColor: theme.palette.primary.main, - boxShadow: `0 0 0 3px ${theme.palette.mode === 'dark' ? 'rgba(25, 118, 210, 0.2)' : 'rgba(25, 118, 210, 0.12)'}`, + > + { + setDirectToValue(e.target.value); + setSuggestionsOpen(true); + }} + onFocus={() => setSuggestionsOpen(true)} + onKeyDown={(e) => { + if ( + e.key === 'Enter' && + directToValue.trim() && + validateAddress(directToValue.trim()) + ) { + e.preventDefault(); + handleSelectNameOrAddress(directToValue.trim()); + setSuggestionsOpen(false); + } + }} + autoFocus + slotProps={{ + htmlInput: { + 'aria-label': t('auth:message.generic.name_address', { + postProcess: 'capitalizeFirstChar', + }), }, - }, - }} - /> - {suggestionsOpen && (nameOptions.length > 0 || nameSearchLoading) && ( - + + + ), + endAdornment: nameSearchLoading ? ( + + + + ) : null, + sx: { + backgroundColor: theme.palette.background.paper, + borderRadius: '14px', + fontFamily: 'Inter', + fontSize: '15px', + transition: 'box-shadow 0.2s ease, border-color 0.2s ease', + '& fieldset': { + borderColor: theme.palette.divider, + borderRadius: '14px', + transition: 'border-color 0.2s ease', + }, + '&:hover fieldset': { + borderColor: theme.palette.text.secondary, + }, + '&.Mui-focused fieldset': { + borderWidth: '2px', + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 3px ${theme.palette.mode === 'dark' ? 'rgba(25, 118, 210, 0.2)' : 'rgba(25, 118, 210, 0.12)'}`, + }, }, }} - > - {nameSearchLoading && nameOptions.length === 0 ? ( - + {suggestionsOpen && + (nameOptions.length > 0 || nameSearchLoading) && ( + - - - {t('core:loading.generic', { - postProcess: 'capitalizeFirstChar', - })} - - - ) : ( - - {nameOptions.map((opt) => { - const label = - typeof opt === 'string' ? opt : opt.name; - const key = - typeof opt === 'string' ? opt : opt.address; - const initial = (label || '?').charAt(0).toUpperCase(); - return ( - - { - const valueToSet = - typeof opt === 'string' ? opt : opt.name; - setDirectToValue(valueToSet); - setSuggestionsOpen(false); - }} - sx={{ - borderRadius: '10px', - py: 1.25, - px: 1.5, - mx: 0.5, - transition: 'background-color 0.15s ease', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }} - > - - {initial} - - - - - ); - })} - + {nameSearchLoading && nameOptions.length === 0 ? ( + + + + {t('core:loading.generic', { + postProcess: 'capitalizeFirstChar', + })} + + + ) : ( + + {nameOptions.map((opt) => { + const label = + typeof opt === 'string' ? opt : opt.name; + const key = + typeof opt === 'string' ? opt : opt.address; + const initial = (label || '?') + .charAt(0) + .toUpperCase(); + return ( + + { + const valueToSet = + typeof opt === 'string' ? opt : opt.name; + setDirectToValue(valueToSet); + setSuggestionsOpen(false); + }} + sx={{ + borderRadius: '10px', + py: 1.25, + px: 1.5, + mx: 0.5, + transition: 'background-color 0.15s ease', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }} + > + + {initial} + + + + + ); + })} + + )} + )} - - )} + + + + + {t('auth:message.generic.insert_name_address', { + postProcess: 'capitalizeFirstChar', + })} + - - - - {t('auth:message.generic.insert_name_address', { - postProcess: 'capitalizeFirstChar', - })} - - )} diff --git a/src/components/Chat/ChatList.tsx b/src/components/Chat/ChatList.tsx index d901b581..2c04c20d 100644 --- a/src/components/Chat/ChatList.tsx +++ b/src/components/Chat/ChatList.tsx @@ -528,9 +528,7 @@ export const ChatList = ({ {showScrollButton && ( + {isOwner && 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', + })} + + + )} + - {/* 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/Desktop/CustomTitleBar.tsx b/src/components/Desktop/CustomTitleBar.tsx index 57568817..7f473dd5 100644 --- a/src/components/Desktop/CustomTitleBar.tsx +++ b/src/components/Desktop/CustomTitleBar.tsx @@ -25,6 +25,7 @@ import { Save } from '../Save/Save'; import { TaskManager } from '../TaskManager/TaskManager'; import { GlobalActions } from '../GlobalActions/GlobalActions'; import { ChatWidgetReopenIcon } from '../Profile/ChatWidgetReopenIcon'; +import { SubscriptionsIcon } from './SubscriptionsIcon'; const TITLE_BAR_HEIGHT = 32; /** Left offset so title-bar nav aligns with content vertical line (DesktopLeftSideBar width). */ @@ -478,6 +479,10 @@ export function CustomTitleBar(props?: { + {rightNav.extState === 'authenticated' && ( + + )} + {rightNav.desktopViewMode !== 'home' && ( s.status === 'payment-needed' + ).length; + + const totalManagedActions = managedSubscriptions.reduce( + (sum: number, entry: any) => sum + (entry.actions?.totalActions ?? 0), + 0 + ); + + const totalActions = totalNeedingPayment + totalManagedActions; + const hasActions = totalActions > 0; + const iconColor = hasActions + ? theme.palette.warning.main + : (controlColor ?? theme.palette.text.secondary); + + const tooltipLabel = hasActions + ? `${t('group:subscription.subscriptions', { postProcess: 'capitalizeFirstChar' })} · ${t('group:subscription.actions_needed', { count: totalActions })}` + : t('group:subscription.subscriptions', { + postProcess: 'capitalizeFirstChar', + defaultValue: 'Subscriptions', + }); + + return ( + + {tooltipLabel} + + } + placement="bottom" + arrow + slotProps={{ + tooltip: { + sx: { + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.paper, + }, + }, + arrow: { + sx: { color: theme.palette.text.primary }, + }, + }} + > + { + executeEvent('addTab', { + data: { service: 'APP', name: 'Subscriptions' }, + }); + executeEvent('open-apps-mode', {}); + }} + sx={{ ...navIconSx, color: iconColor, position: 'relative' }} + aria-label={tooltipLabel} + > + + {hasActions && ( + + {totalActions > 99 ? '99+' : totalActions} + + )} + + + ); +} diff --git a/src/components/GeneralNotifications.tsx b/src/components/GeneralNotifications.tsx index a1251187..db70dba4 100644 --- a/src/components/GeneralNotifications.tsx +++ b/src/components/GeneralNotifications.tsx @@ -1,38 +1,244 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { + alpha, + Avatar, Box, ButtonBase, Card, + Collapse, + Dialog, + DialogContent, + DialogTitle, + IconButton, + List, + ListItemButton, MenuItem, Popover, + Switch, Tooltip, Typography, useTheme, } from '@mui/material'; import NotificationsIcon from '@mui/icons-material/Notifications'; +import SettingsIcon from '@mui/icons-material/Settings'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import MailOutlineIcon from '@mui/icons-material/MailOutline'; +import AppsIcon from '@mui/icons-material/Apps'; +import CloseIcon from '@mui/icons-material/Close'; import { formatDate } from '../utils/time'; import { useHandlePaymentNotification } from '../hooks/useHandlePaymentNotification'; -import { executeEvent } from '../utils/events'; +import { + executeEvent, + subscribeToEvent, + unsubscribeFromEvent, +} from '../utils/events'; import { useTranslation } from 'react-i18next'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + paymentNotificationsAtom, + lastPaymentSeenTimestampAtom, + notificationSeenInAppKeysAtom, + customWebsocketSubscriptionsAtom, + getNotificationSeenKey, + getNotificationSeenPrefixKey, +} from '../atoms/global'; +import { + getAppsWithNotificationPermission, + getNotificationPermissionKey, + getNotificationOsPushDisabledMap, + setNotificationOsPushDisabled, + setPermission, +} from '../qortal/qortal-requests'; +import { checkDifference } from '../background/background'; +import { getBaseApiReact } from '../App'; +import LogoSelected from '../assets/svgs/LogoSelected.svg'; +import { extractComponents } from './Chat/MessageDisplay'; +import { Spacer } from '../common/Spacer'; + +const PAYMENT_EVENT = 'PAYMENT_RECEIVED'; +const RESOURCE_EVENT = 'RESOURCE_PUBLISHED'; + +function getGroupKey(notification) { + if (notification?.event === PAYMENT_EVENT) return PAYMENT_EVENT; + if (notification?.event === RESOURCE_EVENT) { + const appName = notification?.appName ?? 'Publishes'; + const appService = notification?.appService ?? 'APP'; + return `${RESOURCE_EVENT}-${appName}-${appService}`; + } + return notification?.event || 'other'; +} + +function getGroupLabel(notification) { + if (notification?.event === PAYMENT_EVENT) return 'Payments'; + if ( + notification?.event === RESOURCE_EVENT && + notification?.notificationId === 'q-mail-notification' + ) { + return notification?.appName || 'Q-Mail'; + } + if (notification?.event === RESOURCE_EVENT) + return notification?.appName || 'Publishes'; + return notification?.event || 'Other'; +} + +/** Normalize to ms (server may send seconds). */ +function toTimestampMs(v) { + if (v == null || typeof v !== 'number') return v ?? null; + return v < 1e12 ? v * 1000 : v; +} + +function getNotificationTimestamp(notification) { + const raw = + notification?.data?.created ?? + notification?.data?.timestamp ?? + notification?.timestamp; + return toTimestampMs(raw); +} -export const GeneralNotifications = ({ address, tooltipPlacement = 'left' }) => { +function isNotificationUnseen(notification, lastEnteredTimestampPayment) { + const ts = getNotificationTimestamp(notification); + if (ts == null || !checkDifference(ts)) return false; + if (!lastEnteredTimestampPayment) return true; + return ts > lastEnteredTimestampPayment; +} + +function isNotificationSeenInApp(notification, seenInAppKeysSet) { + if (!seenInAppKeysSet || !seenInAppKeysSet.size) return false; + const key = getNotificationSeenKey(notification); + const prefixKey = getNotificationSeenPrefixKey(notification); + return seenInAppKeysSet.has(key) || seenInAppKeysSet.has(prefixKey); +} + +/** Pick message in current language, else en, else first available. Reactive when lang/fallback change. */ +function getNotificationMessageReactive( + messageObj: Record | undefined, + currentLang: string, + fallback: string +): string { + if (!messageObj || typeof messageObj !== 'object') return fallback; + const lang = (currentLang || 'en').split('-')[0]; + const current = messageObj[lang]; + if (typeof current === 'string' && current.trim()) return current.trim(); + const en = messageObj.en; + if (typeof en === 'string' && en.trim()) return en.trim(); + const first = Object.values(messageObj).find( + (v) => typeof v === 'string' && (v as string).trim() + ); + return typeof first === 'string' ? (first as string).trim() : fallback; +} + +export const GeneralNotifications = ({ + address, + tooltipPlacement = 'left', +}: { + address: string; + tooltipPlacement?: + | 'left' + | 'right' + | 'top' + | 'bottom' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end' + | 'right-start' + | 'right-end'; +}) => { const [anchorEl, setAnchorEl] = useState(null); + const notifications = useAtomValue(paymentNotificationsAtom); + const lastEnteredTimestampPayment = useAtomValue( + lastPaymentSeenTimestampAtom + ); + const seenInAppKeys = useAtomValue(notificationSeenInAppKeysAtom); + const setSeenInAppKeys = useSetAtom(notificationSeenInAppKeysAtom); + const customSubscriptions = useAtomValue(customWebsocketSubscriptionsAtom); + const setCustomSubscriptions = useSetAtom(customWebsocketSubscriptionsAtom); + const [notificationSettingsModalOpen, setNotificationSettingsModalOpen] = + useState(false); + const [notificationSettingsApps, setNotificationSettingsApps] = useState< + string[] + >([]); + const [notificationOsPushDisabledMap, setNotificationOsPushDisabledMap] = + useState>({}); + const [notificationSettingsLoading, setNotificationSettingsLoading] = + useState(false); + + const seenInAppKeysSet = useMemo( + () => (Array.isArray(seenInAppKeys) ? new Set(seenInAppKeys) : new Set()), + [seenInAppKeys] + ); + + useEffect(() => { + const handler = (e) => { + const detail = e.detail; + if ( + detail && + typeof detail === 'object' && + 'address' in detail && + 'keys' in detail + ) { + setSeenInAppKeys({ address: detail.address, keys: detail.keys }); + } else if (Array.isArray(detail)) { + setSeenInAppKeys(detail); + } + }; + subscribeToEvent('notification-seen-in-app-updated', handler); + return () => + unsubscribeFromEvent('notification-seen-in-app-updated', handler); + }, [setSeenInAppKeys]); + const { - latestTx, getNameOrAddressOfSenderMiddle, - hasNewPayment, setLastEnteredTimestampPayment, nameAddressOfSender, } = useHandlePaymentNotification(address); + const latestTimestamp = useMemo(() => { + if (!notifications.length) return null; + return getNotificationTimestamp(notifications[0]); + }, [notifications]); + + const unseenCount = useMemo(() => { + const isUnseen = (n) => { + const ts = getNotificationTimestamp(n); + const unseenByTimestamp = !lastEnteredTimestampPayment + ? ts != null && checkDifference(ts) + : ts != null && checkDifference(ts) && ts > lastEnteredTimestampPayment; + if (!unseenByTimestamp) return false; + return !isNotificationSeenInApp(n, seenInAppKeysSet); + }; + + return notifications.filter(isUnseen).length; + }, [notifications, lastEnteredTimestampPayment, seenInAppKeysSet]); + + const hasNewNotifications = unseenCount > 0; + + const groups = useMemo(() => { + const map = new Map(); + for (const n of notifications) { + const key = getGroupKey(n); + if (!map.has(key)) { + map.set(key, { key, label: getGroupLabel(n), items: [] }); + } + map.get(key).items.push(n); + } + return Array.from(map.values()); + }, [notifications]); + + const [expandedGroup, setExpandedGroup] = useState(null); + const handlePopupClick = (event) => { - event.stopPropagation(); // Prevent parent onClick from firing + event.stopPropagation(); setAnchorEl(event.currentTarget); + setExpandedGroup(null); }; - const { t } = useTranslation([ + const { t, i18n } = useTranslation([ 'auth', 'core', 'group', @@ -48,7 +254,14 @@ export const GeneralNotifications = ({ address, tooltipPlacement = 'left' }) => onClick={(e) => { handlePopupClick(e); }} - style={{}} + sx={{ + position: 'relative', + minWidth: 32, + minHeight: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} > textTransform: 'uppercase', }} > - {t('core:payment_notification')} + {t('core:message.generic.notifications')} } placement={tooltipPlacement} @@ -82,110 +295,584 @@ export const GeneralNotifications = ({ address, tooltipPlacement = 'left' }) => > + {hasNewNotifications && unseenCount > 0 && ( + + {unseenCount > 99 ? '99+' : unseenCount} + + )} { - if (hasNewPayment) { + if (hasNewNotifications) { setLastEnteredTimestampPayment(Date.now()); } setAnchorEl(null); - }} // Close popover on click outside + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + PaperProps={{ + sx: { + left: '70px !important', + }, + }} > 0 ? 'flex-start' : 'center', display: 'flex', flexDirection: 'column', maxHeight: '60vh', maxWidth: '100%', overflow: 'auto', - padding: '5px', - width: '300px', + padding: 2, + position: 'relative', + width: 420, }} > - {!hasNewPayment && ( - + { + setNotificationSettingsModalOpen(true); + setNotificationSettingsLoading(true); + Promise.all([ + getAppsWithNotificationPermission(), + getNotificationOsPushDisabledMap(), + ]) + .then(([apps, disabledMap]) => { + setNotificationSettingsApps(apps); + setNotificationOsPushDisabledMap(disabledMap || {}); + }) + .finally(() => setNotificationSettingsLoading(false)); }} + sx={{ color: theme.palette.text.secondary }} > + + + + + {notifications.length === 0 && ( + {t('core:message.generic.no_notifications')} )} - {hasNewPayment && ( - { - setAnchorEl(null); - executeEvent('openWalletsApp', {}); - }} - > - - { + const isExpanded = expandedGroup === group.key; + const isPayment = group.key === PAYMENT_EVENT; + const groupUnseenCount = group.items.filter( + (n) => + isNotificationUnseen(n, lastEnteredTimestampPayment) && + !isNotificationSeenInApp(n, seenInAppKeysSet) + ).length; + + return ( + + + setExpandedGroup(isExpanded ? null : group.key) + } sx={{ - alignItems: 'center', - display: 'flex', - gap: '5px', - justifyContent: 'space-between', + borderRadius: 1.5, + py: 1.25, + px: 1.5, + '&:hover': { backgroundColor: 'action.hover' }, }} > - - {formatDate(latestTx?.timestamp)} - + > + {isPayment ? ( + + ) : group?.label === 'Q-Mail' ? ( + + ) : ( + + )} + + {group.label} + + {groupUnseenCount > 0 && ( + + {groupUnseenCount} + + )} + + {group.items.length} + + {isExpanded ? ( + + ) : ( + + )} + + - - {latestTx?.amount} - + + + {group.items.map((data) => { + const tx = data.data; + const eventTypePublish = data?.event === RESOURCE_EVENT; + const eventTypePayment = data?.event === PAYMENT_EVENT; + const itemKey = + tx?.identifier || + tx?.timestamp || + tx?.created || + `${data.event}-${group.items.indexOf(data)}`; + const isItemUnseen = + isNotificationUnseen( + data, + lastEnteredTimestampPayment + ) && !isNotificationSeenInApp(data, seenInAppKeysSet); - - {nameAddressOfSender.current[latestTx?.creatorAddress] || - getNameOrAddressOfSenderMiddle(latestTx?.creatorAddress)} - - - - )} + if (eventTypePublish) { + return ( + { + if (hasNewNotifications) { + setLastEnteredTimestampPayment(Date.now()); + } + setAnchorEl(null); + if (data?.link) { + const res = extractComponents(data.link); + if (res) { + const { service, name, identifier, path } = + res; + executeEvent('addTab', { + data: { + service, + name, + identifier, + path, + navigateIfAlreadyOpen: true, + }, + }); + executeEvent('open-apps-mode', {}); + } + } + }} + > + + + + app-icon + + + {formatDate(tx?.created ?? tx?.timestamp)} + + + + {getNotificationMessageReactive( + data?.message, + i18n.language ?? 'en', + t('core:message.generic.new_notification') + )} + + + {tx?.name || + nameAddressOfSender.current[tx?.sender] || + getNameOrAddressOfSenderMiddle(tx?.sender)} + + + + ); + } + if (eventTypePayment) { + return ( + { + if (hasNewNotifications) { + setLastEnteredTimestampPayment(Date.now()); + } + setAnchorEl(null); + executeEvent('openWalletsApp', {}); + }} + > + + + + + {formatDate(tx?.created ?? tx?.timestamp)} + + + + {tx?.amount} QORT + + + {nameAddressOfSender.current[tx?.sender] || + getNameOrAddressOfSenderMiddle(tx?.sender)} + + + + ); + } + return null; + })} + + + + ); + })} + + setNotificationSettingsModalOpen(false)} + maxWidth="sm" + fullWidth + > + + {t('core:message.generic.notification_settings', { + defaultValue: 'Notification settings', + })} + setNotificationSettingsModalOpen(false)} + size="small" + sx={{ ml: 1 }} + > + + + + + {notificationSettingsLoading ? ( + + {t('core:message.generic.loading', { + defaultValue: 'Loading...', + })} + + ) : notificationSettingsApps.length === 0 ? ( + + {t('core:message.generic.no_notification_apps', { + defaultValue: 'No apps have notification permission yet.', + })} + + ) : ( + + {notificationSettingsApps.map((appName) => { + const osPushDisabled = + notificationOsPushDisabledMap[appName] === true; + return ( + + {appName} + + + {t('core:message.generic.disable_os_push', { + defaultValue: 'Disable OS push', + })} + + { + await setNotificationOsPushDisabled(appName, checked); + setNotificationOsPushDisabledMap((prev) => ({ + ...prev, + [appName]: checked, + })); + }} + size="small" + /> + + { + const toRemove = (customSubscriptions ?? []).filter( + (s) => + s?.event === 'RESOURCE_PUBLISHED' && + s?.appName === appName + ); + const notificationIds = toRemove + .map((s) => s?.notificationId ?? '') + .filter(Boolean); + await setPermission( + getNotificationPermissionKey(appName), + false + ); + setCustomSubscriptions((prev) => + (prev ?? []).filter( + (s) => + !( + s?.event === 'RESOURCE_PUBLISHED' && + s?.appName === appName + ) + ) + ); + if (notificationIds.length > 0) { + executeEvent( + 'custom-ws-unsubscribe', + notificationIds + ); + } + executeEvent( + 'notifications-websocket-reconnect', + undefined + ); + setNotificationSettingsApps((prev) => + prev.filter((a) => a !== appName) + ); + setNotificationOsPushDisabledMap((prev) => { + const next = { ...prev }; + delete next[appName]; + return next; + }); + }} + > + {t('core:message.generic.revoke_permission', { + defaultValue: 'Revoke permission', + })} + + + ); + })} + + )} + + ); }; diff --git a/src/components/Group/Group.styles.ts b/src/components/Group/Group.styles.ts index 8383cf50..19df18db 100644 --- a/src/components/Group/Group.styles.ts +++ b/src/components/Group/Group.styles.ts @@ -45,12 +45,18 @@ export const InnerChatBox = styled(Box)({ 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', @@ -68,15 +74,26 @@ export const EncryptionKeyMessageDiv = styled('div')({ width: '100%', }); -export const NotPartGroupDiv = styled('div')({ - alignItems: 'flex-start', +export const NotPartGroupDiv = styled('div')(({ theme }) => ({ + alignItems: 'center', display: 'flex', flexDirection: 'column', height: `calc(100vh - ${70 + appHeighOffset}px)`, overflow: 'auto', - padding: '20px', + padding: theme.spacing(3), width: '100%', -}); +})); + +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), + maxWidth: 420, + overflow: 'hidden', + width: '100%', +})); export const NoSelectionTypography = styled(Typography)(({ theme }) => ({ fontSize: '14px', diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 2b7e7bc5..a44f772b 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, @@ -32,6 +32,7 @@ import { unsubscribeFromEvent, } from '../../utils/events'; import { WebSocketActive } from './WebsocketActive'; +import { WebSocketNotifications } from './WebsocketNotifications'; import { getGroupAdmins, getGroupMembers, @@ -79,9 +80,9 @@ import { TIME_DAYS_1_IN_MILLISECONDS, } from '../../constants/constants'; import { useWebsocketStatus } from './useWebsocketStatus'; -import { useQMailFetch } from '../../hooks/useQMailFetch'; import { DirectsSidebar } from './DirectsSidebar'; import { GlobalChatWidget } from './GlobalChatWidget'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import { AdminRowBox, CenterBox, @@ -93,6 +94,7 @@ import { MainContentBox, NewChatOverlay, NoSelectionTypography, + NotPartAdminListBox, NotPartGroupDiv, RootBox, SelectedDirectOverlay, @@ -302,11 +304,11 @@ export const Group = ({ 'question', 'tutorial', ]); + const theme = useTheme(); useWebsocketStatus(); 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); @@ -678,7 +680,11 @@ export const Group = ({ setTriedToFetchSecretKey(true); } } catch (error) { - if (error === 'Unable to decrypt data') { + console.log('error', error); + if ( + error === 'Unable to decrypt data' || + error === 'Unable to decrypt' + ) { setTriedToFetchSecretKey(true); settimeoutForRefetchSecretKey.current = setTimeout(() => { getSecretKey(); @@ -1668,6 +1674,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); @@ -1718,6 +1733,7 @@ export const Group = ({ myAddress={myAddress} setIsLoadingGroups={setIsLoadingGroups} /> + )} - {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} diff --git a/src/components/Group/HomeDesktop.tsx b/src/components/Group/HomeDesktop.tsx index 9d774b26..147c0218 100644 --- a/src/components/Group/HomeDesktop.tsx +++ b/src/components/Group/HomeDesktop.tsx @@ -1,7 +1,14 @@ -import { Box, Button, CircularProgress, IconButton, useTheme } from '@mui/material'; +import { + Box, + Button, + CircularProgress, + IconButton, + useTheme, +} from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useEffect, useMemo, useState } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; +import { triggerMemberGroupsFetch } from './SubscribedToActivity'; import { groupInvitesCacheAtom, joinRequestsCacheAtom, @@ -13,8 +20,12 @@ import { Spacer } from '../../common/Spacer'; import { GroupJoinRequests } from './GroupJoinRequests'; import { GroupInvites } from './GroupInvites'; import { ListOfGroupPromotions } from './ListOfGroupPromotions'; +import { SubscribedToActivity } from './SubscribedToActivity'; import { HomeProfileCard } from './HomeProfileCard'; -import { HomeGettingStarted, GETTING_STARTED_LS_KEY } from './HomeGettingStarted'; +import { + HomeGettingStarted, + GETTING_STARTED_LS_KEY, +} from './HomeGettingStarted'; import { HomeFeaturedApps } from './HomeFeaturedApps'; import { HomeFeaturedGroups } from './HomeFeaturedGroups'; import { HomeDeveloperTab } from './HomeDeveloperTab'; @@ -29,7 +40,7 @@ import { } from 'framer-motion'; type HomeTab = 'user' | 'developer'; -type ActivityTab = 'requests' | 'invites' | 'promotions'; +type ActivityTab = 'requests' | 'invites' | 'promotions' | 'subscribed'; // Temporarily hide User/Developer toggle — only User mode is shown (no option visible) const SHOW_USER_DEVELOPER_TOGGLE = false; @@ -60,6 +71,7 @@ export const HomeDesktop = ({ const [requestsCount, setRequestsCount] = useState(0); const [invitesCount, setInvitesCount] = useState(0); const [promotionsCount, setPromotionsCount] = useState(0); + const [subscriptionsPaymentCount, setSubscriptionsPaymentCount] = useState(0); const [checked1, setChecked1] = useState(false); const [checked2, setChecked2] = useState(false); const [showMostActiveGroups, setShowMostActiveGroups] = useState( @@ -77,6 +89,7 @@ export const HomeDesktop = ({ const handleRefreshGroupActivity = () => { setGroupInvitesCache(null); setJoinRequestsCache(null); + triggerMemberGroupsFetch(); }; useEffect(() => { @@ -193,9 +206,15 @@ export const HomeDesktop = ({ {/* ── USER TAB ── */} {activeTab === 'user' && ( <> - setShowMostActiveGroups(true)} /> + + setShowMostActiveGroups(true) + } + /> - {SHOW_MOST_ACTIVE_GROUPS && showMostActiveGroups && } + {SHOW_MOST_ACTIVE_GROUPS && showMostActiveGroups && ( + + )} {/* ── GROUP ACTIVITY SECTION ── */} {!isLoadingGroups && hasDoneNameAndBalanceAndIsLoaded && ( @@ -254,110 +273,133 @@ export const HomeDesktop = ({ padding: '4px', }} > - {( - [ - { - 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 }) => ( - - ))} + ) : ( + count > 0 && ( + + {count} + + ) + )} + + ) + )} {/* Tab content: mount all so each can report its count; hide inactive */} - + - + - + + + + )} - )} diff --git a/src/components/Group/ListOfMembers.tsx b/src/components/Group/ListOfMembers.tsx index 06b3ad37..9efca211 100644 --- a/src/components/Group/ListOfMembers.tsx +++ b/src/components/Group/ListOfMembers.tsx @@ -33,6 +33,7 @@ const ListOfMembers = ({ setOpenSnack, isAdmin, isOwner, + ownerAddress, show, }) => { const [popoverAnchor, setPopoverAnchor] = useState(null); // Track which list item the popover is anchored to @@ -403,16 +404,20 @@ const ListOfMembers = ({ id={member?.primaryName || member?.member} primary={member?.primaryName || member?.member} /> - {member?.isAdmin && ( + {(member?.isAdmin || member?.member === ownerAddress) && ( - {t('core:admin', { - postProcess: 'capitalizeFirstChar', - })} + {member?.member === ownerAddress + ? t('group:group.owner', { + postProcess: 'capitalizeFirstChar', + }) + : t('core:admin', { + postProcess: 'capitalizeFirstChar', + })} )} diff --git a/src/components/Group/ManageMembers.tsx b/src/components/Group/ManageMembers.tsx index ac2144a8..5a155890 100644 --- a/src/components/Group/ManageMembers.tsx +++ b/src/components/Group/ManageMembers.tsx @@ -393,6 +393,7 @@ export const ManageMembers = ({ setInfoSnack={setInfoSnack} isAdmin={isAdmin} isOwner={isOwner} + ownerAddress={groupInfo?.owner} show={show} /> diff --git a/src/components/Group/SubscribedToActivity.tsx b/src/components/Group/SubscribedToActivity.tsx new file mode 100644 index 00000000..7d856519 --- /dev/null +++ b/src/components/Group/SubscribedToActivity.tsx @@ -0,0 +1,446 @@ +import { + Box, + CircularProgress, + Divider, + Typography, + useTheme, +} from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + managedSubscriptionsAtom, + managedSubscriptionsLoadingAtom, + mySubscriptionsAtom, + subscriptionsLoadingAtom, +} from '../../atoms/global'; +import { + clearMemberGroupsPolling, + triggerMemberGroupsFetch, +} from '../../subscriptions/useInitializeMySubscriptions'; +import { executeEvent } from '../../utils/events'; + +// Re-export for backwards-compat (callers like useAppReset import these). +export { clearMemberGroupsPolling, triggerMemberGroupsFetch }; + +function useFormatTimeUntil() { + const { t } = useTranslation(['group']); + return (ts: number): string => { + const diff = ts - Date.now(); + if (diff <= 0) return t('group:subscription.time_soon'); + const mins = Math.floor(diff / 60_000); + if (mins < 60) + return t('group:subscription.time_in_mins', { count: mins }); + const hours = Math.floor(diff / 3_600_000); + if (hours < 24) + return t('group:subscription.time_in_hours', { count: hours }); + const days = Math.floor(diff / 86_400_000); + if (days < 30) + return t('group:subscription.time_in_days', { count: days }); + const months = Math.floor(days / 30); + return t('group:subscription.time_in_months', { count: months }); + }; +} + +export type SubscribedToActivityProps = { + compact?: boolean; + onPaymentCountChange?: (count: number) => void; +}; + +export function SubscribedToActivity({ + compact = false, + onPaymentCountChange, +}: SubscribedToActivityProps) { + const theme = useTheme(); + const { t } = useTranslation(['group']); + const formatTimeUntil = useFormatTimeUntil(); + + // Read subscription data from global atoms (populated by useInitializeMySubscriptions in the title bar). + const mySubscriptions = useAtomValue(mySubscriptionsAtom); + const managedSubscriptions = useAtomValue(managedSubscriptionsAtom); + const loading = useAtomValue(subscriptionsLoadingAtom); + const managedLoading = useAtomValue(managedSubscriptionsLoadingAtom); + + const totalManagedActions = useMemo( + () => + managedSubscriptions.reduce( + (sum, entry) => sum + (entry.actions?.totalActions ?? 0), + 0 + ), + [managedSubscriptions] + ); + + const totalNeedingPayment = mySubscriptions.filter( + (s) => s.status === 'payment-needed' + ).length; + + useEffect(() => { + if (!loading && !managedLoading) { + onPaymentCountChange?.(totalNeedingPayment + totalManagedActions); + } + }, [ + totalNeedingPayment, + totalManagedActions, + loading, + managedLoading, + onPaymentCountChange, + ]); + + if (loading || managedLoading) { + return ( + + + + ); + } + + if (mySubscriptions.length === 0 && managedSubscriptions.length === 0) { + return ( + + + {t('group:subscription.no_active')} + + + ); + } + + return ( + + + {t('group:subscription.auto_refresh')} + + {totalNeedingPayment > 0 && ( + + + + {t('group:subscription.needs_payment', { + count: totalNeedingPayment, + })} + + + )} + + {mySubscriptions.map((sub) => { + const needsPayment = sub.status === 'payment-needed'; + const statusColor = needsPayment + ? theme.palette.warning.main + : theme.palette.success.main; + + return ( + { + executeEvent('addTab', { + data: { service: 'APP', name: 'Subscriptions', path: sub.link }, + }); + executeEvent('open-apps-mode', {}); + }} + sx={{ + alignItems: 'center', + bgcolor: theme.palette.background.default, + border: `1px solid ${theme.palette.divider}`, + borderLeft: `3px solid ${statusColor}`, + borderRadius: '10px', + cursor: 'pointer', + display: 'flex', + gap: '12px', + padding: '12px 14px', + transition: 'background-color 0.15s ease', + '&:hover': { bgcolor: theme.palette.action.hover }, + }} + > + + + {sub.title} + + + {t('group:subscription.by_owner', { name: sub.ownerName })} + + + {sub.priceQort} QORT / {sub.billingInterval} + {!needsPayment && sub.nextPaymentDue != null && ( + <> · {t('group:subscription.expires', { when: formatTimeUntil(sub.nextPaymentDue) })} + )} + + + + + + + {needsPayment + ? t('group:subscription.status_due') + : t('group:subscription.status_active')} + + + + ); + })} + + {managedSubscriptions.length > 0 && ( + <> + {mySubscriptions.length > 0 && } + + + {totalManagedActions > 0 && ( + + )} + 0 + ? theme.palette.warning.main + : theme.palette.text.secondary, + fontWeight: 600, + lineHeight: 1, + fontSize: '14px', + }} + > + {totalManagedActions > 0 + ? t('group:subscription.actions_needed', { + count: totalManagedActions, + }) + : t('group:subscription.managed_subscriptions')} + + + + {managedSubscriptions.map((entry) => { + const { group, actions } = entry; + const hasActions = actions.totalActions > 0; + const accentColor = hasActions + ? theme.palette.warning.main + : theme.palette.info.main; + + return ( + { + executeEvent('addTab', { + data: { + service: 'APP', + name: 'Subscriptions', + path: entry.url, + }, + }); + executeEvent('open-apps-mode', {}); + }} + sx={{ + alignItems: 'center', + bgcolor: theme.palette.background.default, + border: `1px solid ${theme.palette.divider}`, + borderLeft: `3px solid ${accentColor}`, + borderRadius: '10px', + cursor: 'pointer', + display: 'flex', + gap: '12px', + padding: '12px 14px', + transition: 'background-color 0.15s ease', + '&:hover': { bgcolor: theme.palette.action.hover }, + }} + > + + + {group.groupName} + + {group.description && ( + + {group.description as string} + + )} + + {t('group:subscription.member', { + count: group.memberCount, + })} + {actions.pendingJoinRequests > 0 && ( + <> + {' '} + · {t('group:subscription.pending_join_request', { + count: actions.pendingJoinRequests, + })} + + )} + {actions.needsReEncryption && ( + <> · {t('group:subscription.re_encryption_needed')} + )} + + + + {hasActions && ( + + + + {t('group:subscription.actions_needed', { + count: actions.totalActions, + })} + + + )} + + ); + })} + + )} + + ); +} diff --git a/src/components/Group/WebsocketNotifications.tsx b/src/components/Group/WebsocketNotifications.tsx new file mode 100644 index 00000000..59843cc2 --- /dev/null +++ b/src/components/Group/WebsocketNotifications.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef, useState } from 'react'; +import { getBaseApiReact, getBaseApiReactSocket } from '../../App'; +import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events'; +import i18n, { supportedLanguages } from '../../i18n/i18n'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + extStateAtom, + paymentNotificationsAtom, + customWebsocketSubscriptionsAtom, + notificationSeenInAppKeysAtom, + filterSeenInAppKeysByRules, +} from '../../atoms/global'; +import { fireOsNotificationPayment } from '../../background/background'; +import { + getNotificationPermissionKey, + getPermission, +} from '../../qortal/qortal-requests'; + +/** Message object with "You got a new qmail" in all supported languages (for Q-Mail subscription). */ +function getNewQmailMessage(): Record { + const message: Record = {}; + for (const lng of Object.keys(supportedLanguages)) { + message[lng] = i18n.t('core:message.generic.new_qmail', { lng }); + } + return message; +} + +/** Picks message in current language, else en, else first available; not reactive. */ +function getNotificationMessage( + messageObj: Record | undefined +): string { + const fallback = 'New notification'; + if (!messageObj || typeof messageObj !== 'object') return fallback; + const lang = (i18n.language || 'en').split('-')[0]; + const current = messageObj[lang]; + if (typeof current === 'string' && current.trim()) return current.trim(); + const en = messageObj.en; + if (typeof en === 'string' && en.trim()) return en.trim(); + const first = Object.values(messageObj).find( + (v) => typeof v === 'string' && (v as string).trim() + ); + return typeof first === 'string' ? (first as string).trim() : fallback; +} + +export const WebSocketNotifications = ({ myAddress, userName }) => { + const extState = useAtomValue(extStateAtom); + const extStateRef = useRef(extState); + extStateRef.current = extState; + const myAddressRef = useRef(myAddress); + myAddressRef.current = myAddress; + const setPaymentNotifications = useSetAtom(paymentNotificationsAtom); + const customSubscriptions = useAtomValue(customWebsocketSubscriptionsAtom); + const setCustomSubscriptions = useSetAtom(customWebsocketSubscriptionsAtom); + const seenInAppKeys = useAtomValue(notificationSeenInAppKeysAtom); + const setSeenInAppKeys = useSetAtom(notificationSeenInAppKeysAtom); + + const [socketOpen, setSocketOpen] = useState(false); + const socketRef = useRef(null); + const timeoutIdRef = useRef(null); + const pingTimeoutRef = useRef(null); + const listOfMyNamesRef = useRef([]); + const initWebsocketRef = useRef<(() => Promise) | null>(null); + + const forceCloseWebSocket = () => { + if (socketRef.current) { + clearTimeout(timeoutIdRef.current); + clearTimeout(pingTimeoutRef.current); + socketRef.current.close(1000, 'forced'); + socketRef.current = null; + } + }; + + const logoutEventFunc = () => { + forceCloseWebSocket(); + }; + + useEffect(() => { + subscribeToEvent('logout-event', logoutEventFunc); + + return () => { + unsubscribeFromEvent('logout-event', logoutEventFunc); + }; + }, []); + + useEffect(() => { + const handler = () => { + forceCloseWebSocket(); + setSocketOpen(false); + if (initWebsocketRef.current) { + setTimeout(() => initWebsocketRef.current?.(), 0); + } + }; + subscribeToEvent('notifications-websocket-reconnect', handler); + return () => + unsubscribeFromEvent('notifications-websocket-reconnect', handler); + }, []); + + useEffect(() => { + const handler = (e) => setCustomSubscriptions(e.detail ?? []); + subscribeToEvent('custom-ws-subscriptions-updated', handler); + return () => + unsubscribeFromEvent('custom-ws-subscriptions-updated', handler); + }, [setCustomSubscriptions]); + + useEffect(() => { + const current = Array.isArray(seenInAppKeys) ? seenInAppKeys : []; + const filtered = filterSeenInAppKeysByRules( + current, + customSubscriptions ?? [] + ); + if (filtered.length !== current.length) { + setSeenInAppKeys(filtered); + } + }, [customSubscriptions, seenInAppKeys, setSeenInAppKeys]); + + useEffect(() => { + const handler = (e) => { + const notificationIds = e.detail; + if ( + !notificationIds?.length || + !socketRef.current || + socketRef.current.readyState !== WebSocket.OPEN + ) + return; + socketRef.current.send( + JSON.stringify({ action: 'unsubscribe', notificationIds }) + ); + }; + subscribeToEvent('custom-ws-unsubscribe', handler); + return () => unsubscribeFromEvent('custom-ws-unsubscribe', handler); + }, []); + + useEffect(() => { + if ( + !socketOpen || + !socketRef.current || + socketRef.current.readyState !== WebSocket.OPEN + ) + return; + if (!customSubscriptions?.length) return; + socketRef.current.send( + JSON.stringify({ + action: 'subscribe', + subscriptions: customSubscriptions, + }) + ); + }, [socketOpen, customSubscriptions]); + + useEffect(() => { + if (!myAddress || extState === 'not-authenticated' || !userName) return; + + /** Remove RESOURCE_PUBLISHED rules whose appName does not have qAPPNotification permission. */ + const filterSubscriptionsByNotificationPermission = async ( + subscriptions + ) => { + if (!Array.isArray(subscriptions)) return []; + const result = []; + for (const sub of subscriptions) { + if (sub?.event !== 'RESOURCE_PUBLISHED') { + result.push(sub); + continue; + } + const appName = sub?.appName; + if (!appName) continue; + const allowed = await getPermission( + getNotificationPermissionKey(appName) + ); + if (allowed === true) result.push(sub); + } + return result; + }; + + const pingHeads = () => { + try { + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send('ping'); + timeoutIdRef.current = setTimeout(() => { + if (socketRef.current) { + socketRef.current.close(); + clearTimeout(pingTimeoutRef.current); + } + }, 5000); + } + } catch (error) { + console.error('Error during ping (notifications):', error); + } + }; + + const initWebsocketNotifications = async () => { + forceCloseWebSocket(); + const currentAddress = myAddress; + if (extStateRef.current === 'not-authenticated') return; + if (currentAddress !== myAddressRef.current) return; + + try { + const getNamesUrl = `${getBaseApiReact()}/names/address/${currentAddress}?limit=0`; + const namesResponse = await fetch(getNamesUrl); + const namesData = await namesResponse.json(); + listOfMyNamesRef.current = namesData.map( + (n: { name: string }) => n.name + ); + const query = `qortal_qmail_${userName.slice(0, 20)}_${currentAddress.slice(-6)}_mail_`; + const socketLink = `${getBaseApiReactSocket()}/websockets/notifications`; + const NOTIFICATION_AGE_MS = 3 * 24 * 60 * 60 * 1000; + const getNotificationCreatorTimestamp = (n: { + data?: { created?: number; timestamp?: number }; + timestamp?: number; + }) => n?.data?.created ?? n?.data?.timestamp ?? n?.timestamp; + const trimNotificationsToLast3Days = < + T extends { + data?: { created?: number; timestamp?: number }; + timestamp?: number; + }, + >( + list: T[] + ): T[] => { + const cutoff = Date.now() - NOTIFICATION_AGE_MS; + return list.filter((n) => { + const ts = getNotificationCreatorTimestamp(n); + return ts == null || ts >= cutoff; + }) as T[]; + }; + socketRef.current = new WebSocket(socketLink); + + socketRef.current.onopen = () => { + setSocketOpen(true); + socketRef.current.send( + JSON.stringify({ + action: 'subscribe', + subscriptions: [ + { + event: 'PAYMENT_RECEIVED', + notificationId: 'payment-notification', + + filters: { + recipient: currentAddress, + }, + }, + { + event: 'RESOURCE_PUBLISHED', + resourceFilter: { + service: 'MAIL_PRIVATE', + identifier: query, // same variable you're using in the fetch + excludeBlocked: true, + mode: 'ALL', + }, + image: `/arbitrary/THUMBNAIL/Q-Mail/qortal_avatar?async=true`, + link: 'qortal://app/Q-Mail', + notificationId: 'q-mail-notification', + appName: 'Q-Mail', + appService: 'APP', + message: getNewQmailMessage(), + }, + ], + }) + ); + setTimeout(() => { + const after = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago (ms) + socketRef.current.send( + JSON.stringify({ + action: 'notification-history', + paymentReceivedLimit: 5, + after, + }) + ); + }, 1000); + setTimeout(pingHeads, 50); + }; + + socketRef.current.onmessage = (e) => { + try { + if (e.data === 'pong') { + clearTimeout(timeoutIdRef.current); + pingTimeoutRef.current = setTimeout(pingHeads, 20000); + } else { + const data = JSON.parse(e.data); + + if (data?.type === 'history' && data?.results) { + const filtered = data.results.filter( + (n) => + !( + n?.event === 'RESOURCE_PUBLISHED' && + listOfMyNamesRef.current.includes(n?.data?.name) + ) + ); + setPaymentNotifications(trimNotificationsToLast3Days(filtered)); + } + if (data?.event === 'PAYMENT_RECEIVED' && data?.data) { + const tx = data; + setPaymentNotifications((prev) => { + const trimmed = trimNotificationsToLast3Days(prev); + const alreadyExists = trimmed.some( + (n) => n.signature === tx.data?.signature + ); + if (alreadyExists) return trimmed; + return [tx, ...trimmed]; + }); + fireOsNotificationPayment( + tx, + i18n.t('core:message.generic.new_payment_received'), + i18n.t('core:message.generic.new_payment_body', { + amount: tx?.data?.amount ?? 0, + }), + `${getBaseApiReact()}/arbitrary/THUMBNAIL/Q-Wallets/qortal_avatar?async=true`, + tx?.link + ); + } + if (data?.event === 'RESOURCE_PUBLISHED' && data?.data) { + const tx = { ...data }; + if (listOfMyNamesRef.current.includes(tx?.data?.name)) return; + if (tx.data && tx.data.created == null) { + tx.data = { ...tx.data, created: Date.now() }; + } + setPaymentNotifications((prev) => { + const trimmed = trimNotificationsToLast3Days(prev); + const alreadyExists = trimmed.some( + (n) => + n?.event === 'RESOURCE_PUBLISHED' && + n?.data?.identifier === tx.data?.identifier + ); + if (alreadyExists) return trimmed; + return [tx, ...trimmed]; + }); + fireOsNotificationPayment( + tx, + i18n.t('core:message.generic.new_notification_from', { + appName: tx.appName ?? 'App', + }), + getNotificationMessage(tx.message), + `${getBaseApiReact()}${tx.image}`, + tx?.link + ); + } + } + } catch (error) { + console.error('Error parsing notifications message:', error); + } + }; + + socketRef.current.onclose = (event) => { + setSocketOpen(false); + clearTimeout(pingTimeoutRef.current); + clearTimeout(timeoutIdRef.current); + console.warn( + `Notifications WebSocket closed: ${event.reason || 'unknown reason'}` + ); + if (extStateRef.current === 'not-authenticated') return; + if (event.reason !== 'forced' && event.code !== 1000) { + setTimeout(() => initWebsocketNotifications(), 10000); + } + }; + + socketRef.current.onerror = (error) => { + console.error('Notifications WebSocket error:', error); + clearTimeout(pingTimeoutRef.current); + clearTimeout(timeoutIdRef.current); + if (socketRef.current) { + socketRef.current.close(); + } + }; + } catch (error) { + console.error('Error initializing notifications WebSocket:', error); + } + }; + + initWebsocketRef.current = initWebsocketNotifications; + + (async () => { + const filtered = await filterSubscriptionsByNotificationPermission( + customSubscriptions ?? [] + ); + setCustomSubscriptions(filtered); + initWebsocketNotifications(); + })(); + + return () => { + initWebsocketRef.current = null; + forceCloseWebSocket(); + }; + }, [myAddress, extState, userName]); + + return null; +}; diff --git a/src/components/QMailStatus.tsx b/src/components/QMailStatus.tsx index 61dca28c..76adefb8 100644 --- a/src/components/QMailStatus.tsx +++ b/src/components/QMailStatus.tsx @@ -1,11 +1,17 @@ import { useMemo } from 'react'; -import { mailsAtom, qMailLastEnteredTimestampAtom } from '../atoms/global'; -import { isLessThanOneWeekOld } from './Group/qmailUtils'; +import { + getNotificationSeenKey, + getNotificationSeenPrefixKey, + notificationSeenInAppKeysRecordAtom, + paymentNotificationsAtom, + qMailLastEnteredTimestampAtom, + userInfoAtom, +} from '../atoms/global'; import { ButtonBase, Tooltip, useTheme } from '@mui/material'; import { executeEvent } from '../utils/events'; import { Mail } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; -import { useAtom } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { const { t } = useTranslation([ @@ -20,31 +26,86 @@ export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { const [lastEnteredTimestamp, setLastEnteredTimestamp] = useAtom( qMailLastEnteredTimestampAtom ); - const [mails, setMails] = useAtom(mailsAtom); + const notifications = useAtomValue(paymentNotificationsAtom); + const seenInAppRecord = useAtomValue(notificationSeenInAppKeysRecordAtom); + + const address = useAtomValue(userInfoAtom)?.address; + + const qMailNotifications = useMemo( + () => + (notifications ?? []).filter( + (n) => + n?.event === 'RESOURCE_PUBLISHED' && + (n?.notificationId === 'q-mail-notification' || + n?.appName === 'Q-Mail') + ), + [notifications] + ); 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]); + const getNotificationTimestamp = (n) => { + const raw = n?.data?.created ?? n?.data?.timestamp ?? n?.timestamp; + const v = raw != null && typeof raw === 'number' ? raw : null; + if (v == null) return null; + return v < 1e12 ? v * 1000 : v; + }; + const record: Record< + string, + Record + > = typeof seenInAppRecord === 'string' + ? (() => { + try { + return JSON.parse(seenInAppRecord); + } catch { + return {}; + } + })() + : (seenInAppRecord ?? {}); + const byAddress = (address && record[address]) ?? {}; + const isUnseen = (n) => { + if ( + n?.notificationId !== 'q-mail-notification' && + n?.appName?.toLowerCase() !== 'q-mail' + ) { + return false; + } + const createdTs = getNotificationTimestamp(n); + + if (createdTs == null) return false; + const key = getNotificationSeenKey(n); + const prefixKey = getNotificationSeenPrefixKey(n); + const seenTs = Math.max( + (byAddress[key] as number) ?? 0, + (byAddress[prefixKey] as number) ?? 0 + ); + + return createdTs > seenTs; + }; + return qMailNotifications.filter(isUnseen).length > 0; + }, [qMailNotifications, seenInAppRecord, address]); const button = ( { - executeEvent('addTab', { data: { service: 'APP', name: 'q-mail' } }); + executeEvent('addTab', { + data: { + service: 'APP', + name: 'Q-Mail', + navigateIfAlreadyOpen: true, + }, + }); executeEvent('open-apps-mode', {}); setLastEnteredTimestamp(Date.now()); }} style={{ position: 'relative', - ...(compact && { width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }), + ...(compact && { + width: 32, + height: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }), }} > {hasNewMail && ( @@ -105,7 +166,16 @@ export const QMailStatus = ({ compact = false }: { compact?: boolean }) => { if (compact) { return ( -
+
{button}
); diff --git a/src/hooks/useAppMessageHandler.ts b/src/hooks/useAppMessageHandler.ts index 0fae16a9..43847563 100644 --- a/src/hooks/useAppMessageHandler.ts +++ b/src/hooks/useAppMessageHandler.ts @@ -1,6 +1,7 @@ import { useEffect, MutableRefObject } from 'react'; import { executeEvent } from '../utils/events'; import { handleGetFileFromIndexedDB } from '../utils/indexedDB'; +import { extractComponents } from '../components/Chat/MessageDisplay'; type PermissionHandler = (message: any, event: MessageEvent) => void | Promise; @@ -41,6 +42,23 @@ export function useAppMessageHandler( executeEvent('openThreadNewPost', { data: message.payload?.data, }); + } else if (message.action === 'NOTIFICATION_OPEN_APP') { + const payload = message.payload; + if (payload?.openWallets) { + executeEvent('openWalletsApp', {}); + } else if (payload?.link) { + const data = extractComponents(payload.link); + if (data) { + executeEvent('addTab', { data }); + executeEvent('open-apps-mode', {}); + } + } + } else if (message.action === 'NOTIFICATION_PERMISSION_REQUEST') { + executeEvent('show-notification-permission', { + requestId: message.requestId, + appInfo: message.appInfo, + payload: message.payload, + }); } else if ( message.action === 'QORTAL_REQUEST_PERMISSION' && message?.isFromExtension diff --git a/src/hooks/useAppReset.ts b/src/hooks/useAppReset.ts index 3376d134..4584649b 100644 --- a/src/hooks/useAppReset.ts +++ b/src/hooks/useAppReset.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useResetAtom } from 'jotai/utils'; +import { clearMemberGroupsPolling } from '../subscriptions/useInitializeMySubscriptions'; import { addressInfoControllerAtom, blobControllerAtom, @@ -19,10 +20,14 @@ import { isRunningPublicNodeAtom, joinRequestsCacheAtom, lastPaymentSeenTimestampAtom, - mailsAtom, + managedSubscriptionsAtom, + managedSubscriptionsLoadingAtom, memberGroupsAtom, mutedGroupsAtom, myGroupsWhereIAmAdminAtom, + myMemberGroupsAtom, + myMemberGroupsLastFetchedAtom, + mySubscriptionsAtom, navigationControllerAtom, oldPinnedAppsAtom, promotionTimeIntervalAtom, @@ -33,6 +38,7 @@ import { settingsLocalLastUpdatedAtom, settingsQDNLastUpdatedAtom, sortablePinnedAppsAtom, + subscriptionsLoadingAtom, timestampEnterDataAtom, txListAtom, isUsingImportExportSettingsAtom, @@ -73,10 +79,11 @@ export function useAppReset() { const resetAtomQMailLastEnteredTimestampAtom = useResetAtom( qMailLastEnteredTimestampAtom ); - const resetAtomMailsAtom = useResetAtom(mailsAtom); const resetGroupPropertiesAtom = useResetAtom(groupsPropertiesAtom); - const resetLastPaymentSeenTimestampAtom = useResetAtom( - lastPaymentSeenTimestampAtom + const setLastPaymentSeenTimestamp = useSetAtom(lastPaymentSeenTimestampAtom); + const resetLastPaymentSeenTimestampAtom = useCallback( + () => setLastPaymentSeenTimestamp(null), + [setLastPaymentSeenTimestamp] ); const resetGroupsOwnerNamesAtom = useResetAtom(groupsOwnerNamesAtom); const resetGroupAnnouncementsAtom = useResetAtom(groupAnnouncementsAtom); @@ -88,6 +95,16 @@ export function useAppReset() { const resetMyGroupsWhereIAmAdminAtom = useResetAtom( myGroupsWhereIAmAdminAtom ); + const resetMyMemberGroupsAtom = useResetAtom(myMemberGroupsAtom); + const resetMyMemberGroupsLastFetchedAtom = useResetAtom( + myMemberGroupsLastFetchedAtom + ); + const resetMySubscriptionsAtom = useResetAtom(mySubscriptionsAtom); + const resetManagedSubscriptionsAtom = useResetAtom(managedSubscriptionsAtom); + const resetSubscriptionsLoadingAtom = useResetAtom(subscriptionsLoadingAtom); + const resetManagedSubscriptionsLoadingAtom = useResetAtom( + managedSubscriptionsLoadingAtom + ); const resetResourceDownloadControllerAtom = useResetAtom( resourceDownloadControllerAtom ); @@ -132,7 +149,6 @@ export function useAppReset() { resetAtomOldPinnedAppsAtom(); resetAtomIsUsingImportExportSettingsAtom(); resetAtomQMailLastEnteredTimestampAtom(); - resetAtomMailsAtom(); resetGroupPropertiesAtom(); resetLastPaymentSeenTimestampAtom(); resetGroupsOwnerNamesAtom(); @@ -143,6 +159,13 @@ export function useAppReset() { resettxListAtomAtom(); resetmemberGroupsAtomAtom(); resetMyGroupsWhereIAmAdminAtom(); + resetMyMemberGroupsAtom(); + resetMyMemberGroupsLastFetchedAtom(); + resetMySubscriptionsAtom(); + resetManagedSubscriptionsAtom(); + resetSubscriptionsLoadingAtom(); + resetManagedSubscriptionsLoadingAtom(); + clearMemberGroupsPolling(); resetResourceDownloadControllerAtom(); resetGlobalDownloadsAtom(); resetAddressInfoControllerAtom(); @@ -174,7 +197,6 @@ export function useAppReset() { resetAtomOldPinnedAppsAtom, resetAtomIsUsingImportExportSettingsAtom, resetAtomQMailLastEnteredTimestampAtom, - resetAtomMailsAtom, resetGroupPropertiesAtom, resetLastPaymentSeenTimestampAtom, resetGroupsOwnerNamesAtom, @@ -185,6 +207,12 @@ export function useAppReset() { resettxListAtomAtom, resetmemberGroupsAtomAtom, resetMyGroupsWhereIAmAdminAtom, + resetMyMemberGroupsAtom, + resetMyMemberGroupsLastFetchedAtom, + resetMySubscriptionsAtom, + resetManagedSubscriptionsAtom, + resetSubscriptionsLoadingAtom, + resetManagedSubscriptionsLoadingAtom, resetResourceDownloadControllerAtom, resetGlobalDownloadsAtom, resetAddressInfoControllerAtom, diff --git a/src/hooks/useHandlePaymentNotification.tsx b/src/hooks/useHandlePaymentNotification.tsx index e25fae27..dff48f56 100644 --- a/src/hooks/useHandlePaymentNotification.tsx +++ b/src/hooks/useHandlePaymentNotification.tsx @@ -1,10 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getBaseApiReact } from '../App'; -import { getData, storeData } from '../utils/chromeStorage'; -import { - checkDifference, - getNameInfoForOthers, -} from '../background/background.ts'; +import { useCallback, useEffect, useRef } from 'react'; +import { getNameInfoForOthers } from '../background/background.ts'; import { lastPaymentSeenTimestampAtom } from '../atoms/global'; import { subscribeToEvent, unsubscribeFromEvent } from '../utils/events'; import { useAtom } from 'jotai'; @@ -12,21 +7,12 @@ import { useAtom } from 'jotai'; export const useHandlePaymentNotification = (address) => { const nameAddressOfSender = useRef({}); const isFetchingName = useRef({}); - const [latestTx, setLatestTx] = useState(null); const [lastEnteredTimestampPayment, setLastEnteredTimestampPayment] = useAtom( lastPaymentSeenTimestampAtom ); - useEffect(() => { - if (lastEnteredTimestampPayment && address) { - storeData(`last-seen-payment-${address}`, Date.now()).catch((error) => { - console.error(error); - }); - } - }, [lastEnteredTimestampPayment, address]); - const getNameOrAddressOfSender = useCallback(async (senderAddress) => { - if (isFetchingName.current[senderAddress]) return senderAddress; + if (isFetchingName.current[senderAddress]) return; try { isFetchingName.current[senderAddress] = true; const res = await getNameInfoForOthers(senderAddress); @@ -39,81 +25,16 @@ export const useHandlePaymentNotification = (address) => { }, []); const getNameOrAddressOfSenderMiddle = useCallback( - async (senderAddress) => { + (senderAddress) => { getNameOrAddressOfSender(senderAddress); return senderAddress; }, [getNameOrAddressOfSender] ); - const hasNewPayment = useMemo(() => { - if (!latestTx) return false; - if (!checkDifference(latestTx?.timestamp)) return false; - if ( - !lastEnteredTimestampPayment || - lastEnteredTimestampPayment < latestTx?.timestamp - ) - return true; - - return false; - }, [lastEnteredTimestampPayment, latestTx]); - - const getLastSeenData = useCallback(async () => { - try { - if (!address) return; - const key = `last-seen-payment-${address}`; - - const res = await getData(key).catch(() => null); - - if (res) { - setLastEnteredTimestampPayment(res); - } - - const response = await fetch( - `${getBaseApiReact()}/transactions/search?txType=PAYMENT&address=${address}&confirmationStatus=CONFIRMED&limit=5&reverse=true` - ); - - 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 - } - - setLatestTx(latestTx); - } catch (error) { - console.error(error); - } - }, [address, setLastEnteredTimestampPayment]); - - useEffect(() => { - getLastSeenData(); - // Handler function for incoming messages - const messageHandler = (event) => { - if (event.origin !== window.location.origin) { - return; - } - const message = event.data; - if (message?.action === 'SET_PAYMENT_ANNOUNCEMENT' && message?.payload) { - setLatestTx(message.payload); - } - }; - - // Attach the event listener - window.addEventListener('message', messageHandler); - - // Clean up the event listener on component unmount - return () => { - window.removeEventListener('message', messageHandler); - }; - }, [getLastSeenData]); - const setLastEnteredTimestampPaymentEventFunc = useCallback( - (e) => { - setLastEnteredTimestampPayment(Date.now); + () => { + setLastEnteredTimestampPayment(Date.now()); }, [setLastEnteredTimestampPayment] ); @@ -133,9 +54,8 @@ export const useHandlePaymentNotification = (address) => { }, [setLastEnteredTimestampPaymentEventFunc]); return { - latestTx, getNameOrAddressOfSenderMiddle, - hasNewPayment, + lastEnteredTimestampPayment, setLastEnteredTimestampPayment, nameAddressOfSender, }; diff --git a/src/hooks/useQMailFetch.ts b/src/hooks/useQMailFetch.ts deleted file mode 100644 index c30a6f38..00000000 --- a/src/hooks/useQMailFetch.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useSetAtom } from 'jotai'; -import { mailsAtom, qMailLastEnteredTimestampAtom } from '../atoms/global'; -import { getBaseApiReact } from '../App'; - -const QMAIL_POLL_INTERVAL_MS = 150000; // 2.5 minutes - -/** - * Fetches Q-mail list and last-entered timestamp, stores in global atoms, - * and polls on an interval. Call from a parent that has user identity (e.g. Group). - */ -export function useQMailFetch(userName: string | undefined, userAddress: string | undefined) { - const setMails = useSetAtom(mailsAtom); - const setLastEnteredTimestamp = useSetAtom(qMailLastEnteredTimestampAtom); - - const getMails = useCallback(async () => { - if (!userName || !userAddress) return; - try { - const query = `qortal_qmail_${userName.slice(0, 20)}_${userAddress.slice(-6)}_mail_`; - const response = await fetch( - `${getBaseApiReact()}/arbitrary/resources/search?service=MAIL_PRIVATE&query=${query}&limit=10&includemetadata=false&offset=0&reverse=true&excludeblocked=true&mode=ALL` - ); - const mailData = await response.json(); - setMails(mailData ?? []); - } catch (error) { - console.error(error); - } - }, [userName, userAddress, setMails]); - - const getTimestamp = useCallback(async () => { - try { - const response = await window.sendMessage('getEnteredQmailTimestamp'); - if (!response?.error && response?.timestamp) { - setLastEnteredTimestamp(response.timestamp); - } - } catch (error) { - console.error(error); - } - }, [setLastEnteredTimestamp]); - - useEffect(() => { - getTimestamp(); - if (!userName || !userAddress) return; - getMails(); - const interval = setInterval(() => { - getTimestamp(); - getMails(); - }, QMAIL_POLL_INTERVAL_MS); - return () => clearInterval(interval); - }, [getMails, getTimestamp, userName, userAddress]); -} diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index c0500423..1eb044da 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -242,6 +242,12 @@ export const listOfAllQortalRequests = [ 'LIST_QDN_RESOURCES', 'LOCK_TAB', 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'NOTIFICATION_ADD', + 'NOTIFICATION_GET', + 'NOTIFICATION_MARK_SEEN', + 'NOTIFICATION_PERMISSION', + 'NOTIFICATION_HAS_PERMISSION', + 'NOTIFICATION_REMOVE', 'OPEN_NEW_TAB', 'PLAY_ENCRYPTED_MEDIA', 'PUBLISH_MULTIPLE_QDN_RESOURCES', @@ -270,6 +276,7 @@ export const listOfAllQortalRequests = [ 'UPDATE_FOREIGN_FEE', 'UPDATE_GROUP', 'UPDATE_NAME', + 'UPDATE_SUBSCRIPTIONS', 'VOTE_ON_POLL', 'WHICH_UI', ]; @@ -320,6 +327,12 @@ export const UIQortalRequests = [ 'LEAVE_GROUP', 'LOCK_TAB', 'MULTI_ASSET_PAYMENT_WITH_PRIVATE_DATA', + 'NOTIFICATION_ADD', + 'NOTIFICATION_GET', + 'NOTIFICATION_MARK_SEEN', + 'NOTIFICATION_PERMISSION', + 'NOTIFICATION_HAS_PERMISSION', + 'NOTIFICATION_REMOVE', 'OPEN_NEW_TAB', 'PLAY_ENCRYPTED_MEDIA', 'REENCRYPT_GROUP_KEYS', @@ -341,6 +354,7 @@ export const UIQortalRequests = [ 'UPDATE_FOREIGN_FEE', 'UPDATE_GROUP', 'UPDATE_NAME', + 'UPDATE_SUBSCRIPTIONS', 'VOTE_ON_POLL', 'WHICH_UI', ]; @@ -618,6 +632,7 @@ export const useQortalMessageListener = ( 'GET_USER_WALLET', 'GET_WALLET_BALANCE', 'IS_USING_PUBLIC_NODE', + 'NOTIFICATION_HAS_PERMISSION', 'LIST_ATS', 'LIST_GROUPS', 'LIST_QDN_RESOURCES', diff --git a/src/i18n/locales/ar/auth.json b/src/i18n/locales/ar/auth.json index 5f892b7b..949ae9cd 100644 --- a/src/i18n/locales/ar/auth.json +++ b/src/i18n/locales/ar/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "تصدير عبارة الاسترداد", "insert_name_address": "الرجاء إدخال اسم أو عنوان", "publish_admin_secret_key": "نشر المفتاح السري للإداري", + "only_owner": "المالك فقط", "publish_group_secret_key": "نشر المفتاح السري للمجموعة", "reencrypt_key": "إعادة تشفير المفتاح", "return_to_list": "العودة إلى القائمة", diff --git a/src/i18n/locales/ar/core.json b/src/i18n/locales/ar/core.json index 1e7c0eba..30ae475f 100644 --- a/src/i18n/locales/ar/core.json +++ b/src/i18n/locales/ar/core.json @@ -276,7 +276,18 @@ "no_messages": "لا توجد رسائل", "no_message": "لا توجد رسالة", "no_minting_details": "لا يمكن عرض تفاصيل التعدين على البوابة", + "notifications": "الإشعارات", + "new_notification": "إشعار جديد", + "new_payment_received": "تم استلام دفعة جديدة", + "new_payment_body": "لقد استلمت دفعة جديدة بقيمة {{amount}} QORT", + "new_notification_from": "إشعار جديد من {{appName}}", + "new_qmail": "لديك بريد qmail جديد", "no_notifications": "لا توجد إشعارات جديدة", + "no_notification_apps": "لا توجد تطبيقات لديها إذن إشعار حتى الآن.", + "notification_settings": "إعدادات الإشعارات", + "loading": "جاري التحميل...", + "disable_os_push": "تعطيل إشعارات النظام", + "revoke_permission": "إلغاء الإذن", "no_payments": "لا توجد مدفوعات", "no_pinned_changes": "ليس لديك أي تغييرات على تطبيقاتك المثبتة", "no_results": "لا توجد نتائج", diff --git a/src/i18n/locales/ar/group.json b/src/i18n/locales/ar/group.json index c067f226..4555a3f5 100644 --- a/src/i18n/locales/ar/group.json +++ b/src/i18n/locales/ar/group.json @@ -86,6 +86,8 @@ "invited_you": "دعاك للجروب", "join_request_from": "طلب انضمام من", "accept_join_request_confirm": "اقبل الطلب ده عشان يتحط في الجروب.", + "add_new_key_option": "إضافة مفتاح جديد (يزيد حجم مفاتيح الجروب)", + "add_new_key_sparingly": "يفضل إضافة مفتاح جديد بحذر علشان حجم مفاتيح الجروب مايكبرش.", "group_key_created": "أول مفتاح جروب اتعمل", "group_member_list_changed": "قائمة الأعضاء اتغيرت، لازم تشفر المفتاح السري تاني", "group_no_secret_key": "مفيش مفتاح سري للجروب، كن أول أدمن ينشره", @@ -171,5 +173,33 @@ "user_joined": "تم انضمام المستخدم بنجاح!" } }, + "subscription": { + "subscriptions": "الاشتراكات", + "auto_refresh": "يتجدد تلقائياً كل 5 دقائق", + "no_active": "لا توجد اشتراكات نشطة.", + "needs_payment_one": "{{count}} اشتراك يحتاج إلى دفع", + "needs_payment_other": "{{count}} اشتراكات تحتاج إلى دفع", + "by_owner": "بواسطة {{name}}", + "expires": "تنتهي {{when}}", + "status_due": "مستحق", + "status_active": "نشط", + "managed_subscriptions": "الاشتراكات المُدارة", + "actions_needed_one": "{{count}} إجراء مطلوب", + "actions_needed_other": "{{count}} إجراءات مطلوبة", + "member_one": "{{count}} عضو", + "member_other": "{{count}} أعضاء", + "pending_join_request_one": "{{count}} طلب انضمام معلّق", + "pending_join_request_other": "{{count}} طلبات انضمام معلّقة", + "re_encryption_needed": "إعادة التشفير مطلوبة", + "time_soon": "قريباً", + "time_in_mins_one": "خلال {{count}} دقيقة", + "time_in_mins_other": "خلال {{count}} دقائق", + "time_in_hours_one": "خلال {{count}} ساعة", + "time_in_hours_other": "خلال {{count}} ساعات", + "time_in_days_one": "خلال {{count}} يوم", + "time_in_days_other": "خلال {{count}} أيام", + "time_in_months_one": "خلال {{count}} شهر", + "time_in_months_other": "خلال {{count}} أشهر" + }, "thread_posts": "بُوستات جديدة في الموضوع" } diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index a2a3a8a6..1d1ddfb1 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -178,6 +178,10 @@ "update_group_detail": "المالك الجديد: {{ owner }}", "update_group": "هل تسمح للتطبيق بتحديث هذا الجروب؟", "lock_tab": "هل تمنح الإذن بقفل علامة تبويب هذا التطبيق؟", + "notification": "السماح لهذا التطبيق بإرسال إشعارات Hub لك؟", + "notification_title": "{{appName}} يريد إرسال إشعارات", + "notification_allow": "السماح", + "notification_dont_allow": "عدم السماح", "session_permissions": "{{appName}} يطلب أذونات الجلسة", "session_permissions_description": "سيتم منح الأذونات التالية تلقائيًا لهذه الجلسة:", "session_understand": "أثق بهذا التطبيق وأفهم أن هذه الأذونات ستُنفذ تلقائيًا", diff --git a/src/i18n/locales/de/auth.json b/src/i18n/locales/de/auth.json index 469a4245..0af6a424 100644 --- a/src/i18n/locales/de/auth.json +++ b/src/i18n/locales/de/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "Seedphrase exportieren", "insert_name_address": "Bitte gib einen Namen oder eine Adresse ein", "publish_admin_secret_key": "Admin-Secret-Key veröffentlichen", + "only_owner": "nur Eigentümer", "publish_group_secret_key": "Gruppen-Secret-Key veröffentlichen", "reencrypt_key": "Schlüssel neu verschlüsseln", "return_to_list": "Zur Liste zurückkehren", diff --git a/src/i18n/locales/de/core.json b/src/i18n/locales/de/core.json index 9dd22e8e..f1495f84 100644 --- a/src/i18n/locales/de/core.json +++ b/src/i18n/locales/de/core.json @@ -275,7 +275,18 @@ "no_messages": "Keine Nachrichten", "no_message": "Keine Nachricht", "no_minting_details": "Müngungsdetails auf dem Gateway können nicht angezeigt werden", + "notifications": "Benachrichtigungen", + "new_notification": "Neue Benachrichtigung", + "new_payment_received": "Neue Zahlung erhalten", + "new_payment_body": "Sie haben eine neue Zahlung von {{amount}} QORT erhalten", + "new_notification_from": "Neue Benachrichtigung von {{appName}}", + "new_qmail": "Du hast eine neue Q-Mail", "no_notifications": "Keine neuen Benachrichtigungen", + "no_notification_apps": "Noch keine Apps mit Benachrichtigungsberechtigung.", + "notification_settings": "Benachrichtigungseinstellungen", + "loading": "Laden...", + "disable_os_push": "System-Benachrichtigungen deaktivieren", + "revoke_permission": "Berechtigung widerrufen", "no_payments": "Keine Zahlungen", "no_pinned_changes": "Sie haben derzeit keine Änderungen an Ihren angestellten Apps", "no_results": "Keine Ergebnisse", diff --git a/src/i18n/locales/de/group.json b/src/i18n/locales/de/group.json index 07300fe5..cd0c8bb7 100644 --- a/src/i18n/locales/de/group.json +++ b/src/i18n/locales/de/group.json @@ -86,6 +86,8 @@ "invited_you": "hat dich eingeladen", "join_request_from": "Beitrittsanfrage von", "accept_join_request_confirm": "Anfrage annehmen, um sie zur Gruppe hinzuzufügen.", + "add_new_key_option": "Neuen Schlüssel hinzufügen (erhöht die Größe der Gruppenschlüssel)", + "add_new_key_sparingly": "Einen neuen Schlüssel nur sparsam hinzufügen, um die Größe der Gruppenschlüssel nicht zu erhöhen.", "group_key_created": "Erster Gruppenschlüssel erstellt.", "group_member_list_changed": "Mitgliederliste hat sich geändert. Bitte Verschlüsselungsschlüssel neu verschlüsseln.", "group_no_secret_key": "Es gibt keinen geheimen Gruppenschlüssel. Sei der erste Admin, der einen veröffentlicht!", @@ -170,5 +172,33 @@ "user_joined": "Benutzer ist erfolgreich beigetreten!" } }, + "subscription": { + "subscriptions": "Abonnements", + "auto_refresh": "Wird alle 5 Minuten automatisch aktualisiert", + "no_active": "Keine aktiven Abonnements.", + "needs_payment_one": "{{count}} Abonnement benötigt Zahlung", + "needs_payment_other": "{{count}} Abonnements benötigen Zahlung", + "by_owner": "von {{name}}", + "expires": "Läuft ab {{when}}", + "status_due": "Fällig", + "status_active": "Aktiv", + "managed_subscriptions": "Verwaltete Abonnements", + "actions_needed_one": "{{count}} Aktion erforderlich", + "actions_needed_other": "{{count}} Aktionen erforderlich", + "member_one": "{{count}} Mitglied", + "member_other": "{{count}} Mitglieder", + "pending_join_request_one": "{{count}} ausstehende Beitrittsanfrage", + "pending_join_request_other": "{{count}} ausstehende Beitrittsanfragen", + "re_encryption_needed": "Neuverschlüsselung erforderlich", + "time_soon": "bald", + "time_in_mins_one": "in {{count}} Minute", + "time_in_mins_other": "in {{count}} Minuten", + "time_in_hours_one": "in {{count}} Stunde", + "time_in_hours_other": "in {{count}} Stunden", + "time_in_days_one": "in {{count}} Tag", + "time_in_days_other": "in {{count}} Tagen", + "time_in_months_one": "in {{count}} Monat", + "time_in_months_other": "in {{count}} Monaten" + }, "thread_posts": "Neue Thread-Beiträge" } diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index be03c0cd..ad41d655 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -178,6 +178,10 @@ "update_group_detail": "neuer Besitzer: {{ owner }}", "update_group": "Möchten Sie dieser Anwendung die Berechtigung geben, diese Gruppe zu aktualisieren?", "lock_tab": "Erlauben Sie, dass der Tab dieser App gesperrt wird?", + "notification": "Dieser App erlauben, dir Hub-Benachrichtigungen zu senden?", + "notification_title": "{{appName}} möchte Benachrichtigungen senden", + "notification_allow": "Erlauben", + "notification_dont_allow": "Nicht erlauben", "session_permissions": "{{appName}} fordert Sitzungsberechtigungen an", "session_permissions_description": "Die folgenden Berechtigungen werden für diese Sitzung automatisch erteilt:", "session_understand": "Ich vertraue dieser App und verstehe, dass diese Berechtigungen automatisch ausgeführt werden", diff --git a/src/i18n/locales/en/auth.json b/src/i18n/locales/en/auth.json index 9a8e5072..cbfc9ba9 100644 --- a/src/i18n/locales/en/auth.json +++ b/src/i18n/locales/en/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "export Seedphrase", "insert_name_address": "please insert a name or address", "publish_admin_secret_key": "publish admin secret key", + "only_owner": "only owner", "publish_group_secret_key": "publish group secret key", "reencrypt_key": "re-encrypt key", "return_to_list": "return to list", diff --git a/src/i18n/locales/en/core.json b/src/i18n/locales/en/core.json index bcdd632c..961cbf04 100644 --- a/src/i18n/locales/en/core.json +++ b/src/i18n/locales/en/core.json @@ -277,7 +277,18 @@ "no_messages": "no messages", "no_message": "no message", "no_minting_details": "cannot view minting details on the gateway", + "notifications": "Notifications", + "new_notification": "New notification", + "new_payment_received": "New Payment Received", + "new_payment_body": "You have received a new payment of {{amount}} QORT", + "new_notification_from": "New notification from {{appName}}", + "new_qmail": "You got a new qmail", "no_notifications": "no new notifications", + "no_notification_apps": "No apps have notification permission yet.", + "notification_settings": "Notification settings", + "loading": "Loading...", + "disable_os_push": "Disable OS push", + "revoke_permission": "Revoke permission", "no_payments": "no payments", "no_pinned_changes": "you currently do not have any changes to your pinned apps", "no_results": "no results", diff --git a/src/i18n/locales/en/group.json b/src/i18n/locales/en/group.json index 645b9801..47d4efe6 100644 --- a/src/i18n/locales/en/group.json +++ b/src/i18n/locales/en/group.json @@ -87,6 +87,8 @@ "invited_you": "has invited you", "join_request_from": "Join request from", "accept_join_request_confirm": "Accept this request to add them to the group.", + "add_new_key_option": "Add a new key (increases group key size)", + "add_new_key_sparingly": "Adding a new key should be done sparingly to avoid increasing the size of the group keys.", "group_key_created": "first group key created.", "group_member_list_changed": "the group member list has changed. Please re-encrypt the secret key.", "group_no_secret_key": "there is no group secret key. Be the first admin to publish one!", @@ -171,5 +173,33 @@ "user_joined": "user successfully joined!" } }, + "subscription": { + "subscriptions": "subscriptions", + "auto_refresh": "Auto-refreshes every 5 mins", + "no_active": "No active subscriptions.", + "needs_payment_one": "{{count}} subscription needs payment", + "needs_payment_other": "{{count}} subscriptions need payment", + "by_owner": "by {{name}}", + "expires": "Expires {{when}}", + "status_due": "Due", + "status_active": "Active", + "managed_subscriptions": "Managed subscriptions", + "actions_needed_one": "{{count}} action needed", + "actions_needed_other": "{{count}} actions needed", + "member_one": "{{count}} member", + "member_other": "{{count}} members", + "pending_join_request_one": "{{count}} pending join request", + "pending_join_request_other": "{{count}} pending join requests", + "re_encryption_needed": "Re-encryption needed", + "time_soon": "soon", + "time_in_mins_one": "in {{count}} min", + "time_in_mins_other": "in {{count}} mins", + "time_in_hours_one": "in {{count}} hour", + "time_in_hours_other": "in {{count}} hours", + "time_in_days_one": "in {{count}} day", + "time_in_days_other": "in {{count}} days", + "time_in_months_one": "in {{count}} month", + "time_in_months_other": "in {{count}} months" + }, "thread_posts": "new thread posts" } diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index dbb0743a..573ad521 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -178,6 +178,10 @@ "update_group_detail": "new owner: {{ owner }}", "update_group": "do you give this application permission to update this group?", "lock_tab": "do you give permission for this app's tab to be locked?", + "notification": "Allow this app to send you Hub notifications?", + "notification_title": "{{appName}} wants to send notifications", + "notification_allow": "Allow", + "notification_dont_allow": "Don't allow", "session_permissions": "{{appName}} is requesting session permissions", "session_permissions_description": "The following permissions will be automatically granted for this session:", "session_understand": "I trust this app and understand these permissions will auto-execute", diff --git a/src/i18n/locales/es/auth.json b/src/i18n/locales/es/auth.json index 44eb95fe..f326a617 100644 --- a/src/i18n/locales/es/auth.json +++ b/src/i18n/locales/es/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "exportar frase semilla", "insert_name_address": "por favor ingresa un nombre o dirección", "publish_admin_secret_key": "publicar clave secreta de administrador", + "only_owner": "solo propietario", "publish_group_secret_key": "publicar clave secreta del grupo", "reencrypt_key": "reencriptar clave", "return_to_list": "volver a la lista", diff --git a/src/i18n/locales/es/core.json b/src/i18n/locales/es/core.json index 22a43d5f..1809bc1d 100644 --- a/src/i18n/locales/es/core.json +++ b/src/i18n/locales/es/core.json @@ -276,7 +276,18 @@ "no_messages": "Sin mensajes", "no_message": "sin mensaje", "no_minting_details": "No se puede ver los detalles de acuñado en la puerta de enlace", + "notifications": "Notificaciones", + "new_notification": "Nueva notificación", + "new_payment_received": "Nuevo pago recibido", + "new_payment_body": "Has recibido un nuevo pago de {{amount}} QORT", + "new_notification_from": "Nueva notificación de {{appName}}", + "new_qmail": "Tienes un nuevo qmail", "no_notifications": "No hay nuevas notificaciones", + "no_notification_apps": "Ninguna aplicación tiene permiso de notificación aún.", + "notification_settings": "Configuración de notificaciones", + "loading": "Cargando...", + "disable_os_push": "Desactivar notificaciones del sistema", + "revoke_permission": "Revocar permiso", "no_payments": "Sin pagos", "no_pinned_changes": "Actualmente no tiene ningún cambio en sus aplicaciones fijadas", "no_results": "Sin resultados", diff --git a/src/i18n/locales/es/group.json b/src/i18n/locales/es/group.json index 8496f8ad..c285e44a 100644 --- a/src/i18n/locales/es/group.json +++ b/src/i18n/locales/es/group.json @@ -86,6 +86,8 @@ "invited_you": "te ha invitado", "join_request_from": "Solicitud de unión de", "accept_join_request_confirm": "Acepta esta solicitud para añadirlos al grupo.", + "add_new_key_option": "Añadir una nueva clave (aumenta el tamaño de las claves del grupo)", + "add_new_key_sparingly": "Añadir una nueva clave solo cuando sea necesario para no aumentar el tamaño de las claves del grupo.", "group_key_created": "primera clave de grupo creada.", "group_member_list_changed": "la lista de miembros ha cambiado. Vuelve a cifrar la clave secreta.", "group_no_secret_key": "no hay clave secreta del grupo. ¡Sé el primer administrador en publicarla!", @@ -170,5 +172,33 @@ "user_joined": "¡usuario unido con éxito!" } }, + "subscription": { + "subscriptions": "suscripciones", + "auto_refresh": "Se actualiza automáticamente cada 5 minutos", + "no_active": "No hay suscripciones activas.", + "needs_payment_one": "{{count}} suscripción necesita pago", + "needs_payment_other": "{{count}} suscripciones necesitan pago", + "by_owner": "por {{name}}", + "expires": "Expira {{when}}", + "status_due": "Vencido", + "status_active": "Activo", + "managed_subscriptions": "Suscripciones administradas", + "actions_needed_one": "{{count}} acción requerida", + "actions_needed_other": "{{count}} acciones requeridas", + "member_one": "{{count}} miembro", + "member_other": "{{count}} miembros", + "pending_join_request_one": "{{count}} solicitud de unión pendiente", + "pending_join_request_other": "{{count}} solicitudes de unión pendientes", + "re_encryption_needed": "Se requiere reencriptación", + "time_soon": "pronto", + "time_in_mins_one": "en {{count}} minuto", + "time_in_mins_other": "en {{count}} minutos", + "time_in_hours_one": "en {{count}} hora", + "time_in_hours_other": "en {{count}} horas", + "time_in_days_one": "en {{count}} día", + "time_in_days_other": "en {{count}} días", + "time_in_months_one": "en {{count}} mes", + "time_in_months_other": "en {{count}} meses" + }, "thread_posts": "nuevas publicaciones del hilo" } diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 8cfec088..4b6806be 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -178,6 +178,10 @@ "update_group_detail": "nuevo propietario: {{ owner }}", "update_group": "¿Deseas permitir que esta aplicación actualice este grupo?", "lock_tab": "¿Das permiso para que la pestaña de esta aplicación sea bloqueada?", + "notification": "¿Permitir que esta aplicación te envíe notificaciones de Hub?", + "notification_title": "{{appName}} quiere enviar notificaciones", + "notification_allow": "Permitir", + "notification_dont_allow": "No permitir", "session_permissions": "{{appName}} solicita permisos de sesión", "session_permissions_description": "Los siguientes permisos se concederán automáticamente para esta sesión:", "session_understand": "Confío en esta aplicación y entiendo que estos permisos se ejecutarán automáticamente", diff --git a/src/i18n/locales/et/auth.json b/src/i18n/locales/et/auth.json index 05c2b801..0e46f547 100644 --- a/src/i18n/locales/et/auth.json +++ b/src/i18n/locales/et/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "ekspordi taastamisfraas", "insert_name_address": "palun sisesta nimi või aadress", "publish_admin_secret_key": "avalda administraatori salavõti", + "only_owner": "ainult omanik", "publish_group_secret_key": "avalda grupi salavõti", "reencrypt_key": "krüpteeri võti uuesti", "return_to_list": "tagasi loendisse", diff --git a/src/i18n/locales/et/core.json b/src/i18n/locales/et/core.json index ff4c6c91..e62293d9 100644 --- a/src/i18n/locales/et/core.json +++ b/src/i18n/locales/et/core.json @@ -276,7 +276,18 @@ "no_messages": "sõnumeid pole", "no_message": "sõnum puudub", "no_minting_details": "mintimise andmeid pole võimalik värava kaudu vaadata", + "notifications": "Teavitused", + "new_notification": "Uus teade", + "new_payment_received": "Uus makse saadud", + "new_payment_body": "Olete saanud uue makse summas {{amount}} QORT", + "new_notification_from": "Uus teade rakenduselt {{appName}}", + "new_qmail": "Sa said uue qmaili", "no_notifications": "teavitusi pole", + "no_notification_apps": "Ühelgi rakendusel pole veel teavituste luba.", + "notification_settings": "Teavituste seaded", + "loading": "Laadimine...", + "disable_os_push": "Keela süsteemi teavitused", + "revoke_permission": "Tühista luba", "no_payments": "makseid pole", "no_pinned_changes": "sul pole praegu kinnitatud rakenduste muudatusi", "no_results": "tulemusi pole", diff --git a/src/i18n/locales/et/group.json b/src/i18n/locales/et/group.json index 80160c06..b29a5fa4 100644 --- a/src/i18n/locales/et/group.json +++ b/src/i18n/locales/et/group.json @@ -86,6 +86,8 @@ "invited_you": "kutsus sind", "join_request_from": "Liitumistaotlus kasutajalt", "accept_join_request_confirm": "Nõustu selle taotlusega, et nad rühma lisada.", + "add_new_key_option": "Lisa uus võti (suurendab rühma võtmete mahtu)", + "add_new_key_sparingly": "Uue võtme lisamine tuleks teha säästlikult, et rühma võtmete maht ei kasvaks.", "group_key_created": "esimene rühma võti on loodud.", "group_member_list_changed": "rühma liikmete nimekiri on muutunud. Palun krüpteeri salavõti uuesti.", "group_no_secret_key": "rühma salavõtit ei ole. Ole esimene administraator, kes selle avaldab!", @@ -170,5 +172,33 @@ "user_joined": "kasutaja liitumine õnnestus!" } }, + "subscription": { + "subscriptions": "tellimused", + "auto_refresh": "Värskendatakse automaatselt iga 5 minuti järel", + "no_active": "Aktiivseid tellimusi pole.", + "needs_payment_one": "{{count}} tellimus vajab makset", + "needs_payment_other": "{{count}} tellimust vajavad makset", + "by_owner": "autori {{name}} poolt", + "expires": "Aegub {{when}}", + "status_due": "Tähtaeg", + "status_active": "Aktiivne", + "managed_subscriptions": "Hallatud tellimused", + "actions_needed_one": "{{count}} toiming on vajalik", + "actions_needed_other": "{{count}} toimingut on vajalikud", + "member_one": "{{count}} liige", + "member_other": "{{count}} liiget", + "pending_join_request_one": "{{count}} ootel liitumistaotlus", + "pending_join_request_other": "{{count}} ootel liitumistaotlust", + "re_encryption_needed": "Vajalik on uuesti krüpteerimine", + "time_soon": "peagi", + "time_in_mins_one": "{{count}} minuti pärast", + "time_in_mins_other": "{{count}} minuti pärast", + "time_in_hours_one": "{{count}} tunni pärast", + "time_in_hours_other": "{{count}} tunni pärast", + "time_in_days_one": "{{count}} päeva pärast", + "time_in_days_other": "{{count}} päeva pärast", + "time_in_months_one": "{{count}} kuu pärast", + "time_in_months_other": "{{count}} kuu pärast" + }, "thread_posts": "uued teemapostitused" } diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index eca219b0..c6d06609 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -178,6 +178,10 @@ "update_group_detail": "uus omanik: {{ owner }}", "update_group": "kas sa annad sellele rakendusele loa seda rühma uuendada?", "lock_tab": "Kas annad loa selle rakenduse vahelehe lukustamiseks?", + "notification": "Kas lubada sellel rakendusel saata teile Hub teavitusi?", + "notification_title": "{{appName}} soovib saata teavitusi", + "notification_allow": "Luba", + "notification_dont_allow": "Ära luba", "session_permissions": "{{appName}} taotleb seansi õigusi", "session_permissions_description": "Järgmised õigused antakse selle seansi jaoks automaatselt:", "session_understand": "Ma usaldan seda rakendust ja mõistan, et need õigused käivitatakse automaatselt", diff --git a/src/i18n/locales/fi/auth.json b/src/i18n/locales/fi/auth.json index b70e37b7..eb3e7e71 100644 --- a/src/i18n/locales/fi/auth.json +++ b/src/i18n/locales/fi/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "vie siemenlause", "insert_name_address": "kirjoita nimi tai osoite", "publish_admin_secret_key": "julkaise järjestelmänvalvojan salainen avain", + "only_owner": "vain omistaja", "publish_group_secret_key": "julkaise ryhmän salainen avain", "reencrypt_key": "salaa avain uudelleen", "return_to_list": "palaa luetteloon", diff --git a/src/i18n/locales/fi/core.json b/src/i18n/locales/fi/core.json index 327f6279..0b415398 100644 --- a/src/i18n/locales/fi/core.json +++ b/src/i18n/locales/fi/core.json @@ -255,6 +255,18 @@ "no_data_image": "ei tietoja kuvalle", "no_description": "ei kuvausta", "no_messages": "ei viestejä", + "notifications": "Ilmoitukset", + "new_notification": "Uusi ilmoitus", + "new_payment_received": "Uusi maksu vastaanotettu", + "new_payment_body": "Olet saanut uuden maksun {{amount}} QORT", + "new_notification_from": "Uusi ilmoitus sovellukselta {{appName}}", + "new_qmail": "Sinulla on uusi qmail", + "no_notifications": "ei uusia ilmoituksia", + "no_notification_apps": "Millään sovelluksella ei ole vielä ilmoitusoikeutta.", + "notification_settings": "Ilmoitusasetukset", + "loading": "Ladataan...", + "disable_os_push": "Poista käytöstä järjestelmän ilmoitukset", + "revoke_permission": "Peruuta lupa", "no_payments": "ei maksuja", "no_results": "ei tuloksia", "opened": "avattu", diff --git a/src/i18n/locales/fi/group.json b/src/i18n/locales/fi/group.json index aaa285ad..0a198a66 100644 --- a/src/i18n/locales/fi/group.json +++ b/src/i18n/locales/fi/group.json @@ -86,6 +86,8 @@ "invited_you": "on kutsunut sinut", "join_request_from": "Liittymispyyntö lähettäjältä", "accept_join_request_confirm": "Hyväksy tämä pyyntö lisätäksesi heidät ryhmään.", + "add_new_key_option": "Lisää uusi avain (kasvattaa ryhmän avainten kokoa)", + "add_new_key_sparingly": "Uuden avaimen lisääminen tulee tehdä säästeliäästi, jotta ryhmän avainten kokoa ei kasva.", "group_key_created": "ensimmäinen ryhmäavain luotu.", "group_member_list_changed": "ryhmän jäsenlista on muuttunut. Salaa salainen avain uudelleen.", "group_no_secret_key": "ryhmällä ei ole salaista avainta. Julkaise sellainen ensimmäisenä ylläpitäjänä!", @@ -170,5 +172,33 @@ "user_joined": "käyttäjä liittyi onnistuneesti!" } }, + "subscription": { + "subscriptions": "tilaukset", + "auto_refresh": "Päivittyy automaattisesti 5 minuutin välein", + "no_active": "Ei aktiivisia tilauksia.", + "needs_payment_one": "{{count}} tilaus vaatii maksun", + "needs_payment_other": "{{count}} tilausta vaatii maksun", + "by_owner": "tekijältä {{name}}", + "expires": "Vanhenee {{when}}", + "status_due": "Erääntynyt", + "status_active": "Aktiivinen", + "managed_subscriptions": "Hallitut tilaukset", + "actions_needed_one": "{{count}} toiminto vaaditaan", + "actions_needed_other": "{{count}} toimintoa vaaditaan", + "member_one": "{{count}} jäsen", + "member_other": "{{count}} jäsentä", + "pending_join_request_one": "{{count}} odottava liittymispyyntö", + "pending_join_request_other": "{{count}} odottavaa liittymispyyntöä", + "re_encryption_needed": "Uudelleensalaus tarvitaan", + "time_soon": "pian", + "time_in_mins_one": "{{count}} minuutin päästä", + "time_in_mins_other": "{{count}} minuutin päästä", + "time_in_hours_one": "{{count}} tunnin päästä", + "time_in_hours_other": "{{count}} tunnin päästä", + "time_in_days_one": "{{count}} päivän päästä", + "time_in_days_other": "{{count}} päivän päästä", + "time_in_months_one": "{{count}} kuukauden päästä", + "time_in_months_other": "{{count}} kuukauden päästä" + }, "thread_posts": "uudet ketjun julkaisut" } diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 699805a6..623ebd3c 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -178,6 +178,10 @@ "update_group_detail": "uusi omistaja: {{ owner }}", "update_group": "annatko tälle sovellukselle luvan päivittää tämän ryhmän?", "lock_tab": "Sallitko tämän sovelluksen välilehden lukitsemisen?", + "notification": "Sallitaanko tämän sovelluksen lähettää sinulle Hub-ilmoituksia?", + "notification_title": "{{appName}} haluaa lähettää ilmoituksia", + "notification_allow": "Salli", + "notification_dont_allow": "Älä salli", "session_permissions": "{{appName}} pyytää istuntokohtaisia oikeuksia", "session_permissions_description": "Seuraavat oikeudet myönnetään automaattisesti tälle istunnolle:", "session_understand": "Luotan tähän sovellukseen ja ymmärrän, että nämä oikeudet suoritetaan automaattisesti", diff --git a/src/i18n/locales/fr/auth.json b/src/i18n/locales/fr/auth.json index 4f466e36..03d9c490 100644 --- a/src/i18n/locales/fr/auth.json +++ b/src/i18n/locales/fr/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "exporter la phrase de récupération", "insert_name_address": "veuillez entrer un nom ou une adresse", "publish_admin_secret_key": "publier la clé secrète administrateur", + "only_owner": "propriétaire uniquement", "publish_group_secret_key": "publier la clé secrète du groupe", "reencrypt_key": "re-chiffrer la clé", "return_to_list": "retourner à la liste", diff --git a/src/i18n/locales/fr/core.json b/src/i18n/locales/fr/core.json index 5c16952c..6252af33 100644 --- a/src/i18n/locales/fr/core.json +++ b/src/i18n/locales/fr/core.json @@ -276,7 +276,18 @@ "no_messages": "pas de messages", "no_message": "pas de message", "no_minting_details": "Impossible d'afficher les détails de la passerelle sur la passerelle", + "notifications": "Notifications", + "new_notification": "Nouvelle notification", + "new_payment_received": "Nouveau paiement reçu", + "new_payment_body": "Vous avez reçu un nouveau paiement de {{amount}} QORT", + "new_notification_from": "Nouvelle notification de {{appName}}", + "new_qmail": "Vous avez reçu un nouveau qmail", "no_notifications": "pas de nouvelles notifications", + "no_notification_apps": "Aucune application n'a encore la permission de notification.", + "notification_settings": "Paramètres des notifications", + "loading": "Chargement...", + "disable_os_push": "Désactiver les notifications système", + "revoke_permission": "Révoquer l'autorisation", "no_payments": "Aucun paiement", "no_pinned_changes": "Vous n'avez actuellement aucune modification à vos applications épinglées", "no_results": "Aucun résultat", diff --git a/src/i18n/locales/fr/group.json b/src/i18n/locales/fr/group.json index a10753b4..57bc6b18 100644 --- a/src/i18n/locales/fr/group.json +++ b/src/i18n/locales/fr/group.json @@ -86,6 +86,8 @@ "invited_you": "vous a invité", "join_request_from": "Demande d’adhésion de", "accept_join_request_confirm": "Acceptez cette demande pour les ajouter au groupe.", + "add_new_key_option": "Ajouter une nouvelle clé (augmente la taille des clés du groupe)", + "add_new_key_sparingly": "Ajoutez une nouvelle clé avec parcimonie pour ne pas augmenter la taille des clés du groupe.", "group_key_created": "première clé de groupe créée.", "group_member_list_changed": "la liste des membres a changé. Veuillez rechiffrer la clé secrète.", "group_no_secret_key": "aucune clé secrète pour le groupe. Soyez le premier admin à en publier une !", @@ -170,5 +172,33 @@ "user_joined": "utilisateur ajouté avec succès !" } }, + "subscription": { + "subscriptions": "abonnements", + "auto_refresh": "Actualisation automatique toutes les 5 minutes", + "no_active": "Aucun abonnement actif.", + "needs_payment_one": "{{count}} abonnement nécessite un paiement", + "needs_payment_other": "{{count}} abonnements nécessitent un paiement", + "by_owner": "par {{name}}", + "expires": "Expire {{when}}", + "status_due": "Dû", + "status_active": "Actif", + "managed_subscriptions": "Abonnements gérés", + "actions_needed_one": "{{count}} action requise", + "actions_needed_other": "{{count}} actions requises", + "member_one": "{{count}} membre", + "member_other": "{{count}} membres", + "pending_join_request_one": "{{count}} demande d'adhésion en attente", + "pending_join_request_other": "{{count}} demandes d'adhésion en attente", + "re_encryption_needed": "Re-chiffrement nécessaire", + "time_soon": "bientôt", + "time_in_mins_one": "dans {{count}} minute", + "time_in_mins_other": "dans {{count}} minutes", + "time_in_hours_one": "dans {{count}} heure", + "time_in_hours_other": "dans {{count}} heures", + "time_in_days_one": "dans {{count}} jour", + "time_in_days_other": "dans {{count}} jours", + "time_in_months_one": "dans {{count}} mois", + "time_in_months_other": "dans {{count}} mois" + }, "thread_posts": "nouveaux messages du fil" } diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index 3d5a6560..c3f39671 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -178,6 +178,10 @@ "update_group_detail": "nouveau propriétaire : {{ owner }}", "update_group": "Souhaitez-vous autoriser cette application à mettre à jour ce groupe ?", "lock_tab": "Autorisez-vous le verrouillage de l’onglet de cette application ?", + "notification": "Autoriser cette application à vous envoyer des notifications Hub ?", + "notification_title": "{{appName}} souhaite envoyer des notifications", + "notification_allow": "Autoriser", + "notification_dont_allow": "Ne pas autoriser", "session_permissions": "{{appName}} demande des autorisations de session", "session_permissions_description": "Les autorisations suivantes seront automatiquement accordées pour cette session :", "session_understand": "Je fais confiance à cette application et comprends que ces autorisations seront exécutées automatiquement", diff --git a/src/i18n/locales/it/auth.json b/src/i18n/locales/it/auth.json index 6808a619..2b116b39 100644 --- a/src/i18n/locales/it/auth.json +++ b/src/i18n/locales/it/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "esporta seed phrase", "insert_name_address": "si prega di inserire un nome o un indirizzo", "publish_admin_secret_key": "pubblica la chiave segreta dell'amministratore", + "only_owner": "solo proprietario", "publish_group_secret_key": "pubblica chiave segreta di gruppo", "reencrypt_key": "recripta chiave", "return_to_list": "torna all'elenco", diff --git a/src/i18n/locales/it/core.json b/src/i18n/locales/it/core.json index 1f9413e4..c71ce5ea 100644 --- a/src/i18n/locales/it/core.json +++ b/src/i18n/locales/it/core.json @@ -275,7 +275,18 @@ "no_description": "nessuna descrizione", "no_messages": "nessun messaggio", "no_minting_details": "impossibile visualizzare i dettagli di minting sul gateway", + "notifications": "Notifiche", + "new_notification": "Nuova notifica", + "new_payment_received": "Nuovo pagamento ricevuto", + "new_payment_body": "Hai ricevuto un nuovo pagamento di {{amount}} QORT", + "new_notification_from": "Nuova notifica da {{appName}}", + "new_qmail": "Hai ricevuto un nuovo qmail", "no_notifications": "nessuna nuova notifica", + "no_notification_apps": "Nessuna app ha ancora il permesso per le notifiche.", + "notification_settings": "Impostazioni notifiche", + "loading": "Caricamento...", + "disable_os_push": "Disattiva notifiche di sistema", + "revoke_permission": "Revoca permesso", "no_payments": "nessun pagamento", "no_pinned_changes": "per ora non ci sono modifiche alle app bloccate", "no_results": "nessun risultato", diff --git a/src/i18n/locales/it/group.json b/src/i18n/locales/it/group.json index 9c1275d7..8de99551 100644 --- a/src/i18n/locales/it/group.json +++ b/src/i18n/locales/it/group.json @@ -86,6 +86,8 @@ "invited_you": "ti ha invitato", "join_request_from": "Richiesta di adesione da", "accept_join_request_confirm": "Accetta questa richiesta per aggiungerli al gruppo.", + "add_new_key_option": "Aggiungi una nuova chiave (aumenta la dimensione delle chiavi del gruppo)", + "add_new_key_sparingly": "Aggiungere una nuova chiave con parsimonia per non aumentare la dimensione delle chiavi del gruppo.", "group_key_created": "creata la prima chiave di gruppo.", "group_member_list_changed": "l'elenco dei membri del gruppo è cambiato. Si prega di recriptare nuovamente la chiave segreta.", "group_no_secret_key": "non esiste una chiave segreta di gruppo. Potresti essere il primo amministratore a pubblicarne una!", @@ -170,5 +172,33 @@ "user_joined": "l'utente si è unito con successo!" } }, + "subscription": { + "subscriptions": "abbonamenti", + "auto_refresh": "Aggiornamento automatico ogni 5 minuti", + "no_active": "Nessun abbonamento attivo.", + "needs_payment_one": "{{count}} abbonamento richiede pagamento", + "needs_payment_other": "{{count}} abbonamenti richiedono pagamento", + "by_owner": "di {{name}}", + "expires": "Scade {{when}}", + "status_due": "Scaduto", + "status_active": "Attivo", + "managed_subscriptions": "Abbonamenti gestiti", + "actions_needed_one": "{{count}} azione richiesta", + "actions_needed_other": "{{count}} azioni richieste", + "member_one": "{{count}} membro", + "member_other": "{{count}} membri", + "pending_join_request_one": "{{count}} richiesta di adesione in attesa", + "pending_join_request_other": "{{count}} richieste di adesione in attesa", + "re_encryption_needed": "Ri-cifratura necessaria", + "time_soon": "presto", + "time_in_mins_one": "tra {{count}} minuto", + "time_in_mins_other": "tra {{count}} minuti", + "time_in_hours_one": "tra {{count}} ora", + "time_in_hours_other": "tra {{count}} ore", + "time_in_days_one": "tra {{count}} giorno", + "time_in_days_other": "tra {{count}} giorni", + "time_in_months_one": "tra {{count}} mese", + "time_in_months_other": "tra {{count}} mesi" + }, "thread_posts": "nuovi post di thread" } diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index c8c26823..d8328c4b 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -178,6 +178,10 @@ "update_group_detail": "nuovo proprietario: {{ owner }}", "update_group": "consenti a questa applicazione di aggiornare questo gruppo?", "lock_tab": "Dai il permesso di bloccare la scheda di questa applicazione?", + "notification": "Consentire a questa app di inviarti notifiche Hub?", + "notification_title": "{{appName}} vuole inviare notifiche", + "notification_allow": "Consenti", + "notification_dont_allow": "Non consentire", "session_permissions": "{{appName}} richiede autorizzazioni di sessione", "session_permissions_description": "Le seguenti autorizzazioni verranno concesse automaticamente per questa sessione:", "session_understand": "Mi fido di questa applicazione e comprendo che queste autorizzazioni verranno eseguite automaticamente", diff --git a/src/i18n/locales/ja/auth.json b/src/i18n/locales/ja/auth.json index 205353c9..9dad9328 100644 --- a/src/i18n/locales/ja/auth.json +++ b/src/i18n/locales/ja/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "シードフレーズをエクスポート", "insert_name_address": "名前またはアドレスを入力してください", "publish_admin_secret_key": "管理者の秘密鍵を公開", + "only_owner": "オーナーのみ", "publish_group_secret_key": "グループの秘密鍵を公開", "reencrypt_key": "キーを再暗号化", "return_to_list": "リストに戻る", diff --git a/src/i18n/locales/ja/core.json b/src/i18n/locales/ja/core.json index 0cd24735..df659ab7 100644 --- a/src/i18n/locales/ja/core.json +++ b/src/i18n/locales/ja/core.json @@ -276,7 +276,18 @@ "no_messages": "メッセージはありません", "no_message": "メッセージはありません", "no_minting_details": "ゲートウェイでミントの詳細を表示できません", + "notifications": "通知", + "new_notification": "新しい通知", + "new_payment_received": "新しい支払いを受信しました", + "new_payment_body": "{{amount}} QORTの新しい支払いを受信しました", + "new_notification_from": "{{appName}}からの新しい通知", + "new_qmail": "新しいqmailが届きました", "no_notifications": "新しい通知はありません", + "no_notification_apps": "通知の許可があるアプリはまだありません。", + "notification_settings": "通知設定", + "loading": "読み込み中...", + "disable_os_push": "OSプッシュを無効にする", + "revoke_permission": "許可を取り消す", "no_payments": "支払いなし", "no_pinned_changes": "現在、ピン留めアプリに変更がありません", "no_results": "結果はありません", diff --git a/src/i18n/locales/ja/group.json b/src/i18n/locales/ja/group.json index c84ddcb8..1ec24ba8 100644 --- a/src/i18n/locales/ja/group.json +++ b/src/i18n/locales/ja/group.json @@ -86,6 +86,8 @@ "invited_you": "があなたを招待しました", "join_request_from": "参加リクエスト送信者", "accept_join_request_confirm": "このリクエストを受け入れてグループに追加します。", + "add_new_key_option": "新しい鍵を追加する(グループ鍵のサイズが増えます)", + "add_new_key_sparingly": "新しい鍵の追加は控えめにし、グループ鍵のサイズが増えすぎないようにしてください。", "group_key_created": "最初のグループ鍵が作成されました。", "group_member_list_changed": "メンバーリストが変更されました。秘密鍵を再暗号化してください。", "group_no_secret_key": "グループの秘密鍵が存在しません。最初の管理者として公開してください!", @@ -170,5 +172,33 @@ "user_joined": "ユーザーが正常に参加しました!" } }, + "subscription": { + "subscriptions": "サブスクリプション", + "auto_refresh": "5分ごとに自動更新", + "no_active": "アクティブなサブスクリプションはありません。", + "needs_payment_one": "{{count}}件のサブスクリプションの支払いが必要です", + "needs_payment_other": "{{count}}件のサブスクリプションの支払いが必要です", + "by_owner": "{{name}} 提供", + "expires": "{{when}}に期限切れ", + "status_due": "支払い期限", + "status_active": "アクティブ", + "managed_subscriptions": "管理中のサブスクリプション", + "actions_needed_one": "{{count}}件のアクションが必要", + "actions_needed_other": "{{count}}件のアクションが必要", + "member_one": "{{count}}人のメンバー", + "member_other": "{{count}}人のメンバー", + "pending_join_request_one": "{{count}}件の参加リクエスト保留中", + "pending_join_request_other": "{{count}}件の参加リクエスト保留中", + "re_encryption_needed": "再暗号化が必要", + "time_soon": "まもなく", + "time_in_mins_one": "{{count}}分後", + "time_in_mins_other": "{{count}}分後", + "time_in_hours_one": "{{count}}時間後", + "time_in_hours_other": "{{count}}時間後", + "time_in_days_one": "{{count}}日後", + "time_in_days_other": "{{count}}日後", + "time_in_months_one": "{{count}}ヶ月後", + "time_in_months_other": "{{count}}ヶ月後" + }, "thread_posts": "新しいスレッド投稿" } diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index ff9d04fc..2ff8c6c1 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -178,6 +178,10 @@ "update_group_detail": "新しい所有者:{{ owner }}", "update_group": "このアプリにこのグループを更新することを許可しますか?", "lock_tab": "このアプリのタブをロックすることを許可しますか?", + "notification": "このアプリにHub通知を送信することを許可しますか?", + "notification_title": "{{appName}}は通知を送信したいと考えています", + "notification_allow": "許可", + "notification_dont_allow": "許可しない", "session_permissions": "{{appName}} がセッション権限を要求しています", "session_permissions_description": "以下の権限はこのセッション中に自動的に付与されます:", "session_understand": "このアプリを信頼しており、これらの権限が自動的に実行されることを理解しています", diff --git a/src/i18n/locales/pt/auth.json b/src/i18n/locales/pt/auth.json index d47515c9..2e16165b 100644 --- a/src/i18n/locales/pt/auth.json +++ b/src/i18n/locales/pt/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "exportar frase-semente", "insert_name_address": "favor inserir um nome ou endereço", "publish_admin_secret_key": "mostrar chave secreta do administrador", + "only_owner": "apenas o proprietário", "publish_group_secret_key": "mostrar chave secreta do grupo", "reencrypt_key": "recodificar chave", "return_to_list": "retornar para a lista", diff --git a/src/i18n/locales/pt/core.json b/src/i18n/locales/pt/core.json index 9f0ff845..d587412e 100644 --- a/src/i18n/locales/pt/core.json +++ b/src/i18n/locales/pt/core.json @@ -276,7 +276,18 @@ "no_messages": "sem mensagens", "no_message": "nenhuma mensagem", "no_minting_details": "não é possível visualizar os detalhes de cunhagem no gateway", + "notifications": "Notificações", + "new_notification": "Nova notificação", + "new_payment_received": "Novo pagamento recebido", + "new_payment_body": "Você recebeu um novo pagamento de {{amount}} QORT", + "new_notification_from": "Nova notificação de {{appName}}", + "new_qmail": "Você recebeu um novo qmail", "no_notifications": "nenhuma nova notificação", + "no_notification_apps": "Nenhum aplicativo tem permissão de notificação ainda.", + "notification_settings": "Configurações de notificação", + "loading": "Carregando...", + "disable_os_push": "Desativar notificações do sistema", + "revoke_permission": "Revogar permissão", "no_payments": "nenhum pagamento", "no_pinned_changes": "você atualmente não tem alterações nos seus aplicativos fixados", "no_results": "nenhum resultado", diff --git a/src/i18n/locales/pt/group.json b/src/i18n/locales/pt/group.json index 61c84086..4786006d 100644 --- a/src/i18n/locales/pt/group.json +++ b/src/i18n/locales/pt/group.json @@ -86,6 +86,8 @@ "invited_you": "convidou você", "join_request_from": "Pedido de adesão de", "accept_join_request_confirm": "Aceite este pedido para adicioná-los ao grupo.", + "add_new_key_option": "Adicionar uma nova chave (aumenta o tamanho das chaves do grupo)", + "add_new_key_sparingly": "Adicione uma nova chave com moderação para não aumentar o tamanho das chaves do grupo.", "group_key_created": "primeira chave do grupo criada.", "group_member_list_changed": "a lista de membros do grupo foi alterada. Por favor, recriptografe a chave secreta.", "group_no_secret_key": "não há chave secreta do grupo. Seja o primeiro administrador a publicar uma!", @@ -171,5 +173,33 @@ "user_joined": "usuário entrou com sucesso!" } }, + "subscription": { + "subscriptions": "assinaturas", + "auto_refresh": "Atualiza automaticamente a cada 5 minutos", + "no_active": "Nenhuma assinatura ativa.", + "needs_payment_one": "{{count}} assinatura precisa de pagamento", + "needs_payment_other": "{{count}} assinaturas precisam de pagamento", + "by_owner": "por {{name}}", + "expires": "Expira {{when}}", + "status_due": "Vencido", + "status_active": "Ativo", + "managed_subscriptions": "Assinaturas gerenciadas", + "actions_needed_one": "{{count}} ação necessária", + "actions_needed_other": "{{count}} ações necessárias", + "member_one": "{{count}} membro", + "member_other": "{{count}} membros", + "pending_join_request_one": "{{count}} solicitação de adesão pendente", + "pending_join_request_other": "{{count}} solicitações de adesão pendentes", + "re_encryption_needed": "Re-criptografia necessária", + "time_soon": "em breve", + "time_in_mins_one": "em {{count}} minuto", + "time_in_mins_other": "em {{count}} minutos", + "time_in_hours_one": "em {{count}} hora", + "time_in_hours_other": "em {{count}} horas", + "time_in_days_one": "em {{count}} dia", + "time_in_days_other": "em {{count}} dias", + "time_in_months_one": "em {{count}} mês", + "time_in_months_other": "em {{count}} meses" + }, "thread_posts": "novas postagens do tópico" } diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index 27b494a3..b37600a0 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -179,6 +179,10 @@ "update_group_detail": "novo proprietário: {{ owner }}", "update_group": "você dá permissão para que este aplicativo atualize este grupo?", "lock_tab": "Você concede permissão para que a aba deste aplicativo seja bloqueada?", + "notification": "Permitir que este aplicativo envie notificações Hub para você?", + "notification_title": "{{appName}} quer enviar notificações", + "notification_allow": "Permitir", + "notification_dont_allow": "Não permitir", "session_permissions": "{{appName}} está solicitando permissões de sessão", "session_permissions_description": "As seguintes permissões serão concedidas automaticamente para esta sessão:", "session_understand": "Confio neste aplicativo e entendo que essas permissões serão executadas automaticamente", diff --git a/src/i18n/locales/ru/auth.json b/src/i18n/locales/ru/auth.json index e136d7d7..b9eb9fc6 100644 --- a/src/i18n/locales/ru/auth.json +++ b/src/i18n/locales/ru/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "экспорт сид-фразы", "insert_name_address": "введите имя или адрес", "publish_admin_secret_key": "опубликовать секретный ключ администратора", + "only_owner": "только владелец", "publish_group_secret_key": "опубликовать секретный ключ группы", "reencrypt_key": "повторно зашифровать ключ", "return_to_list": "вернуться к списку", diff --git a/src/i18n/locales/ru/core.json b/src/i18n/locales/ru/core.json index f9eb1256..0c40835e 100644 --- a/src/i18n/locales/ru/core.json +++ b/src/i18n/locales/ru/core.json @@ -276,7 +276,18 @@ "no_messages": "Нет сообщений", "no_message": "Нет сообщения", "no_minting_details": "Не могу просматривать детали маттинга на шлюзе", + "notifications": "Уведомления", + "new_notification": "Новое уведомление", + "new_payment_received": "Получен новый платёж", + "new_payment_body": "Вы получили новый платёж на сумму {{amount}} QORT", + "new_notification_from": "Новое уведомление от {{appName}}", + "new_qmail": "У вас новое qmail-письмо", "no_notifications": "Нет новых уведомлений", + "no_notification_apps": "У пока ни одного приложения нет разрешения на уведомления.", + "notification_settings": "Настройки уведомлений", + "loading": "Загрузка...", + "disable_os_push": "Отключить системные уведомления", + "revoke_permission": "Отозвать разрешение", "no_payments": "Нет платежей", "no_pinned_changes": "В настоящее время у вас нет никаких изменений в ваших приложениях", "no_results": "Нет результатов", diff --git a/src/i18n/locales/ru/group.json b/src/i18n/locales/ru/group.json index 6bcf4358..70ce2958 100644 --- a/src/i18n/locales/ru/group.json +++ b/src/i18n/locales/ru/group.json @@ -86,6 +86,8 @@ "invited_you": "пригласил(а) вас", "join_request_from": "Запрос на вступление от", "accept_join_request_confirm": "Примите этот запрос, чтобы добавить их в группу.", + "add_new_key_option": "Добавить новый ключ (увеличивает размер ключей группы)", + "add_new_key_sparingly": "Добавляйте новый ключ лишь при необходимости, чтобы не увеличивать размер ключей группы.", "group_key_created": "первичный ключ группы создан", "group_member_list_changed": "список участников изменился. Перешифруйте секретный ключ.", "group_no_secret_key": "секретного ключа группы нет. Станьте первым админом, кто его опубликует!", @@ -170,5 +172,33 @@ "user_joined": "пользователь успешно присоединился!" } }, + "subscription": { + "subscriptions": "подписки", + "auto_refresh": "Автоматическое обновление каждые 5 минут", + "no_active": "Нет активных подписок.", + "needs_payment_one": "{{count}} подписка требует оплаты", + "needs_payment_other": "{{count}} подписок требуют оплаты", + "by_owner": "от {{name}}", + "expires": "Истекает {{when}}", + "status_due": "К оплате", + "status_active": "Активна", + "managed_subscriptions": "Управляемые подписки", + "actions_needed_one": "{{count}} действие требуется", + "actions_needed_other": "{{count}} действий требуется", + "member_one": "{{count}} участник", + "member_other": "{{count}} участников", + "pending_join_request_one": "{{count}} ожидающий запрос на вступление", + "pending_join_request_other": "{{count}} ожидающих запросов на вступление", + "re_encryption_needed": "Требуется повторное шифрование", + "time_soon": "скоро", + "time_in_mins_one": "через {{count}} минуту", + "time_in_mins_other": "через {{count}} минут", + "time_in_hours_one": "через {{count}} час", + "time_in_hours_other": "через {{count}} часов", + "time_in_days_one": "через {{count}} день", + "time_in_days_other": "через {{count}} дней", + "time_in_months_one": "через {{count}} месяц", + "time_in_months_other": "через {{count}} месяцев" + }, "thread_posts": "новые сообщения темы" } diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 75c6cdf2..4873b99f 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -178,6 +178,10 @@ "update_group_detail": "новый владелец: {{ owner }}", "update_group": "Разрешить этому приложению обновить эту группу?", "lock_tab": "Вы даёте разрешение на блокировку вкладки этого приложения?", + "notification": "Разрешить этому приложению отправлять вам уведомления Hub?", + "notification_title": "{{appName}} хочет отправлять уведомления", + "notification_allow": "Разрешить", + "notification_dont_allow": "Не разрешать", "session_permissions": "{{appName}} запрашивает разрешения сеанса", "session_permissions_description": "Следующие разрешения будут автоматически предоставлены для этого сеанса:", "session_understand": "Я доверяю этому приложению и понимаю, что эти разрешения будут выполняться автоматически", diff --git a/src/i18n/locales/zh/auth.json b/src/i18n/locales/zh/auth.json index ab914d2b..4b6caabf 100644 --- a/src/i18n/locales/zh/auth.json +++ b/src/i18n/locales/zh/auth.json @@ -29,6 +29,7 @@ "export_seedphrase": "导出助记词", "insert_name_address": "请输入名称或地址", "publish_admin_secret_key": "发布管理员密钥", + "only_owner": "仅限群主", "publish_group_secret_key": "发布群组密钥", "reencrypt_key": "重新加密密钥", "return_to_list": "返回列表", diff --git a/src/i18n/locales/zh/core.json b/src/i18n/locales/zh/core.json index 650f769c..fd952b1e 100644 --- a/src/i18n/locales/zh/core.json +++ b/src/i18n/locales/zh/core.json @@ -276,7 +276,18 @@ "no_messages": "没有消息", "no_message": "没有消息", "no_minting_details": "无法在网关上查看薄荷细节", + "notifications": "通知", + "new_notification": "新通知", + "new_payment_received": "收到新付款", + "new_payment_body": "您收到了一笔 {{amount}} QORT 的新付款", + "new_notification_from": "来自 {{appName}} 的新通知", + "new_qmail": "您收到了一封新 qmail", "no_notifications": "没有新的通知", + "no_notification_apps": "尚无应用获得通知权限。", + "notification_settings": "通知设置", + "loading": "加载中...", + "disable_os_push": "禁用系统推送", + "revoke_permission": "撤销权限", "no_payments": "无付款", "no_pinned_changes": "您目前对固定应用程序没有任何更改", "no_results": "没有结果", diff --git a/src/i18n/locales/zh/group.json b/src/i18n/locales/zh/group.json index b2d2374a..af380a11 100644 --- a/src/i18n/locales/zh/group.json +++ b/src/i18n/locales/zh/group.json @@ -86,6 +86,8 @@ "invited_you": "邀请了你", "join_request_from": "加入请求来自", "accept_join_request_confirm": "接受此请求以将其加入群组。", + "add_new_key_option": "添加新密钥(会增加群组密钥大小)", + "add_new_key_sparingly": "请谨慎添加新密钥,以免增加群组密钥体积。", "group_key_created": "群组初始密钥已创建", "group_member_list_changed": "成员列表已变更,请重新加密密钥", "group_no_secret_key": "尚无群组密钥,你可以作为管理员发布第一个!", @@ -170,5 +172,33 @@ "user_joined": "用户成功加入!" } }, + "subscription": { + "subscriptions": "订阅", + "auto_refresh": "每5分钟自动刷新", + "no_active": "没有活跃的订阅。", + "needs_payment_one": "{{count}}个订阅需要付款", + "needs_payment_other": "{{count}}个订阅需要付款", + "by_owner": "来自 {{name}}", + "expires": "{{when}}到期", + "status_due": "待付款", + "status_active": "活跃", + "managed_subscriptions": "管理的订阅", + "actions_needed_one": "需要{{count}}项操作", + "actions_needed_other": "需要{{count}}项操作", + "member_one": "{{count}}位成员", + "member_other": "{{count}}位成员", + "pending_join_request_one": "{{count}}个待处理的加入请求", + "pending_join_request_other": "{{count}}个待处理的加入请求", + "re_encryption_needed": "需要重新加密", + "time_soon": "即将", + "time_in_mins_one": "{{count}}分钟后", + "time_in_mins_other": "{{count}}分钟后", + "time_in_hours_one": "{{count}}小时后", + "time_in_hours_other": "{{count}}小时后", + "time_in_days_one": "{{count}}天后", + "time_in_days_other": "{{count}}天后", + "time_in_months_one": "{{count}}个月后", + "time_in_months_other": "{{count}}个月后" + }, "thread_posts": "新主题消息" } diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index 2ce38778..62701c02 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -178,6 +178,10 @@ "update_group_detail": "新所有者:{{ owner }}", "update_group": "是否允许该应用更新该群组?", "lock_tab": "是否允许锁定此应用的标签页?", + "notification": "允许此应用向您发送 Hub 通知?", + "notification_title": "{{appName}} 想要发送通知", + "notification_allow": "允许", + "notification_dont_allow": "不允许", "session_permissions": "{{appName}} 正在请求会话权限", "session_permissions_description": "以下权限将自动授予本次会话:", "session_understand": "我信任此应用,并理解这些权限将自动执行", diff --git a/src/qortal/get.ts b/src/qortal/get.ts index f5eebd68..82e5e2c1 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -94,6 +94,7 @@ import { } from '../qdn/encryption/group-encryption.ts'; import { publishData } from '../qdn/publish/publish.ts'; import { + getNotificationPermissionKey, getPermission, isRunningGateway, setPermission, @@ -107,6 +108,7 @@ import DeleteTradeOffer from '../transactions/TradeBotDeleteRequest.ts'; import signTradeBotTransaction from '../transactions/signTradeBotTransaction.ts'; import { createTransaction } from '../transactions/transactions.ts'; import { executeEvent } from '../utils/events.ts'; +import { getElectronPersistentStorage } from '../utils/electronPersistentStorage'; import { fileToBase64 } from '../utils/fileReading/index.ts'; import { mimeToExtensionMap } from '../utils/memeTypes.ts'; import { RequestQueueWithPromise } from '../utils/queue/queue.ts'; @@ -443,6 +445,9 @@ function getFileFromContentScript(fileId) { const responseResolvers = new Map(); +/** Resolvers for the custom notification-permission slide-down UI (key: requestId) */ +const notificationPermissionResolvers = new Map(); + const handleMessage = (event) => { const { action, requestId, result } = event.data; @@ -455,6 +460,14 @@ const handleMessage = (event) => { responseResolvers.get(requestId)(result || false); responseResolvers.delete(requestId); // Clean up after resolving } + + if ( + action === 'NOTIFICATION_PERMISSION_RESPONSE' && + notificationPermissionResolvers.has(requestId) + ) { + notificationPermissionResolvers.get(requestId)(result || false); + notificationPermissionResolvers.delete(requestId); + } }; window.addEventListener('message', handleMessage); @@ -578,6 +591,114 @@ export const getUserAccount = async ({ } }; +export const getNotificationPermission = async ({ + isFromExtension, + appInfo, + skipAuth, +}) => { + try { + const value = + (await getPermission(getNotificationPermissionKey(appInfo?.name))) || false; + let skip = false; + if (value) skip = true; + if (skipAuth) skip = true; + let hadSessionPermissions = false; + if ( + appInfo?.tabId && + appInfo?.name && + hasSessionPermission( + appInfo.tabId, + appInfo.name, + 'NOTIFICATION_PERMISSION' + ) + ) { + skip = true; + hadSessionPermissions = true; + } + + let resPermission; + if (!skip) { + resPermission = await new Promise((resolve) => { + const requestId = `notificationPermission_${Date.now()}`; + notificationPermissionResolvers.set(requestId, resolve); + const payload = { + text1: i18n.t('question:permission.notification', { + defaultValue: 'Allow this app to send you Hub notifications?', + postProcess: 'capitalizeFirstChar', + }), + }; + window.postMessage( + { + action: 'NOTIFICATION_PERMISSION_REQUEST', + requestId, + appInfo, + payload, + }, + window.location.origin + ); + setTimeout(() => { + if (notificationPermissionResolvers.has(requestId)) { + notificationPermissionResolvers.get(requestId)(false); + notificationPermissionResolvers.delete(requestId); + } + }, TIME_MINUTES_1_IN_MILLISECONDS); + }); + } + + const { accepted = false } = resPermission || {}; + if (resPermission && accepted) { + setPermission(getNotificationPermissionKey(appInfo?.name), true); + } + if (accepted || skip) { + if (!hadSessionPermissions && appInfo?.tabId && appInfo?.name) { + setSessionPermissions(appInfo.tabId, appInfo.name, [ + 'NOTIFICATION_PERMISSION', + ]); + } + return true; + } else { + throw new Error( + i18n.t('question:message.generic.user_declined_request', { + postProcess: 'capitalizeFirstChar', + }) + ); + } + } catch (error) { + throw new Error( + error?.message || + i18n.t('auth:message.error.fetch_user_account', { + postProcess: 'capitalizeFirstChar', + }) + ); + } +}; + +/** + * Read-only: whether the app has Hub notification permission (stored grant and/or + * current session), without showing a permission prompt. Matches what NOTIFICATION_ADD + * requires (session) plus persistent grant from a prior NOTIFICATION_PERMISSION accept. + */ +export const notificationHasPermission = async ({ + appInfo, + skipAuth, +}: { + appInfo?: { name?: string; tabId?: number }; + skipAuth?: boolean; +}) => { + if (skipAuth) return true; + if (!appInfo?.name) return false; + const stored = + (await getPermission(getNotificationPermissionKey(appInfo.name))) === true; + const session = + appInfo.tabId != null && + hasSessionPermission( + appInfo.tabId, + appInfo.name, + 'NOTIFICATION_PERMISSION' + ); + return stored || session; +}; + export const sessionPermissions = async (data, isFromExtension, appInfo) => { try { const { permissions = [] } = data; @@ -1516,6 +1637,18 @@ export const publishQDNResource = async ( }) ); } + + if ( + typeof data.identifier === 'string' && + data.identifier.trim().startsWith('symmetric-qchat-group-') + ) { + throw new Error( + i18n.t('group:message.generic.invalid_data', { + postProcess: 'capitalizeFirstChar', + }) + ); + } + // Use "default" if user hasn't specified an identifier const service = data.service; const appFee = data?.appFee ? +data.appFee : undefined; @@ -1646,13 +1779,13 @@ export const publishQDNResource = async ( if (hasAppFee) { const feePayment = await getFee('PAYMENT'); - (handleDynamicValues['appFee'] = +appFee + +feePayment.fee), + ((handleDynamicValues['appFee'] = +appFee + +feePayment.fee), (handleDynamicValues['checkbox1'] = { value: true, label: i18n.t('question:accept_app_fee', { postProcess: 'capitalizeFirstChar', }), - }); + })); } if (!!data?.encrypt) { handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}`; @@ -1822,6 +1955,21 @@ export const publishMultipleQDNResources = async ( }) ); } + + const FORBIDDEN_IDENTIFIER_PREFIX = 'symmetric-qchat-group-'; + const hasForbiddenIdentifier = resources.some( + (resource) => + typeof resource?.identifier === 'string' && + resource.identifier.trim().startsWith(FORBIDDEN_IDENTIFIER_PREFIX) + ); + if (hasForbiddenIdentifier) { + throw new Error( + i18n.t('group:message.generic.invalid_data', { + postProcess: 'capitalizeFirstChar', + }) + ); + } + const isPublicNode = await isRunningGateway(); if (isPublicNode) { const hasOversizedFilePublicNode = resources.some((resource) => { @@ -1950,22 +2098,37 @@ export const publishMultipleQDNResources = async ( hasAppFee = true; } + let paymentFeeAmount = 0; const handleDynamicValues = {}; if (hasAppFee) { const feePayment = await getFee('PAYMENT'); + paymentFeeAmount = +feePayment.fee; - (handleDynamicValues['appFee'] = +appFee + +feePayment.fee), + ((handleDynamicValues['appFee'] = +appFee + +feePayment.fee), (handleDynamicValues['checkbox1'] = { value: true, label: i18n.t('question:accept_app_fee', { postProcess: 'capitalizeFirstChar', }), - }); + })); } if (data?.encrypt) { handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}`; } + const totalRequiredQort = + resources.length * +fee.fee + + paymentFeeAmount + + (hasAppFee && appFee ? +appFee : 0); + const balance = await getBalanceInfo(); + if (+balance < totalRequiredQort) { + throw new Error( + i18n.t('question:message.error.insufficient_balance_qort', { + postProcess: 'capitalizeFirstChar', + }) + ); + } + // Check for session permission const hasPermission = appInfo?.tabId && @@ -4993,6 +5156,418 @@ export const getArrrSyncStatus = async () => { } }; +const CUSTOM_WS_SUBSCRIPTIONS_KEY = 'qortal_custom_ws_subscriptions'; + +/** Current user address for address-scoped storage (set by app when user logs in). */ +function getCurrentAddress(): string | null { + if (typeof window === 'undefined') return null; + const w = window as Window & { __qortalCurrentAddress?: string | null }; + return w.__qortalCurrentAddress ?? null; +} + +function getAppStorage() { + if (typeof window === 'undefined') return undefined; + return ( + window as { + appStorage?: { + get: (k: string) => Promise; + set: (k: string, v: unknown) => Promise; + }; + } + ).appStorage; +} + +function parseSubscriptionsRecord(value: unknown): Record { + if (value != null && typeof value === 'object' && !Array.isArray(value)) + return value as Record; + if (Array.isArray(value)) return { __legacy: value }; + return {}; +} + +async function getStoredCustomSubscriptions() { + const address = getCurrentAddress(); + const appStorage = getAppStorage(); + if (appStorage) { + try { + const value = await appStorage.get(CUSTOM_WS_SUBSCRIPTIONS_KEY); + const record = parseSubscriptionsRecord(value); + if (!address) return []; + return Array.isArray(record[address]) ? record[address] : []; + } catch (_) { + return []; + } + } + if (typeof localStorage === 'undefined') return []; + try { + const raw = localStorage.getItem(CUSTOM_WS_SUBSCRIPTIONS_KEY); + if (!raw) return []; + const value = JSON.parse(raw); + const record = parseSubscriptionsRecord(value); + if (!address) return []; + return Array.isArray(record[address]) ? record[address] : []; + } catch (_) { + return []; + } +} + +async function setStoredCustomSubscriptions(list: any[]) { + const address = getCurrentAddress(); + const appStorage = getAppStorage(); + if (appStorage) { + try { + const value = await appStorage.get(CUSTOM_WS_SUBSCRIPTIONS_KEY); + const record = parseSubscriptionsRecord(value); + if (address) record[address] = list; + await appStorage.set(CUSTOM_WS_SUBSCRIPTIONS_KEY, record); + } catch (err) { + console.error( + '[get.ts] setStoredCustomSubscriptions appStorage failed:', + err + ); + } + } else if (typeof localStorage !== 'undefined' && address) { + try { + const raw = localStorage.getItem(CUSTOM_WS_SUBSCRIPTIONS_KEY); + const record = parseSubscriptionsRecord(raw ? JSON.parse(raw) : null); + record[address] = list; + localStorage.setItem(CUSTOM_WS_SUBSCRIPTIONS_KEY, JSON.stringify(record)); + } catch (err) { + console.error( + '[get.ts] setStoredCustomSubscriptions localStorage failed:', + err + ); + } + } + executeEvent('custom-ws-subscriptions-updated', list); +} + +/** Allowed filter keys and their types for notification subscription resourceFilter. */ +const NOTIFICATION_FILTER_SPEC = { + service: 'string', + query: 'string', + identifier: 'string', + names: 'arrayOfString', + title: 'string', + description: 'string', + keywords: 'arrayOfString', + prefix: 'boolean', + defaultResource: 'boolean', + followedOnly: 'boolean', + excludeBlocked: 'boolean', + after: 'long', + before: 'long', + mode: 'string', // ALL | ANY, internal +}; + +function validateNotificationFilters(filters) { + if (filters == null || typeof filters !== 'object') return; + for (const [key, value] of Object.entries(filters)) { + if (!(key in NOTIFICATION_FILTER_SPEC)) { + throw new Error(`Unknown filter key: "${key}"`); + } + if (value === undefined || value === null) continue; + const type = NOTIFICATION_FILTER_SPEC[key]; + switch (type) { + case 'string': + if (typeof value !== 'string') { + throw new Error(`Filter "${key}" must be a string`); + } + break; + case 'arrayOfString': + if (!Array.isArray(value)) { + throw new Error(`Filter "${key}" must be an array of strings`); + } + if (value.some((v) => typeof v !== 'string')) { + throw new Error(`Filter "${key}" must be an array of strings`); + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + throw new Error(`Filter "${key}" must be a boolean`); + } + break; + case 'integer': + case 'long': + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`Filter "${key}" must be an integer`); + } + break; + default: + break; + } + } +} + +export const addNotificationSubscriptions = async (payload, appInfo) => { + if ( + !appInfo?.tabId || + !appInfo?.name || + !hasSessionPermission( + appInfo.tabId, + appInfo.name, + 'NOTIFICATION_PERMISSION' + ) + ) { + throw new Error( + 'Notification permission required. Call NOTIFICATION_PERMISSION first.' + ); + } + const notifications = payload?.notifications; + if (!Array.isArray(notifications) || notifications.length === 0) { + throw new Error('notifications must be a non-empty array'); + } + const appName = (appInfo?.name ?? '').toLowerCase(); + const appService = appInfo?.service ?? 'APP'; + + const toSubscription = (item) => { + if ( + !item || + typeof item.notificationId !== 'string' || + !item.notificationId.trim() + ) { + throw new Error('Each item must have a non-empty notificationId'); + } + if (typeof item.link !== 'string' || !item.link.trim()) { + throw new Error('Each item must have a non-empty link'); + } + const filters = item.filters; + if (!filters || typeof filters !== 'object') { + throw new Error('Each item must have filters (object)'); + } + validateNotificationFilters(filters); + const message = item.message; + if ( + !message || + typeof message !== 'object' || + typeof message.en !== 'string' || + !message.en.trim() + ) { + throw new Error("Each item must have message with at least 'en'"); + } + return { + event: 'RESOURCE_PUBLISHED', + resourceFilter: { ...filters, mode: filters.mode ?? 'ALL' }, + image: typeof item.image === 'string' ? item.image : undefined, + link: item.link.trim(), + notificationId: item.notificationId.trim(), + appName, + appService, + message: { ...message }, + }; + }; + + const transformed = notifications.map(toSubscription); + const key = (sub) => `${sub.notificationId}-${sub.appName}-${sub.appService}`; + const current = await getStoredCustomSubscriptions(); + const byKey = new Map(current.map((s) => [key(s), s])); + transformed.forEach((s) => byKey.set(key(s), s)); + const merged = Array.from(byKey.values()); + await setStoredCustomSubscriptions(merged); + return true; +}; + +export const removeNotificationSubscriptions = async (payload, appInfo) => { + const appName = (appInfo?.name ?? '').toLowerCase(); + const appService = appInfo?.service ?? 'APP'; + const notificationIds = payload?.notificationIds; + const idsSet = + Array.isArray(notificationIds) && notificationIds.length > 0 + ? new Set(notificationIds.map((id) => String(id).trim())) + : null; + + const current = await getStoredCustomSubscriptions(); + const toUnsubscribe = []; + const filtered = current.filter((sub) => { + const sameApp = + (sub.appName ?? '').toLowerCase() === appName && + (sub.appService ?? 'APP') === appService; + if (!sameApp) return true; + if (idsSet == null) { + toUnsubscribe.push({ + notificationId: sub.notificationId ?? '', + appName: sub.appName ?? '', + appService: sub.appService ?? 'APP', + }); + return false; + } + if (idsSet.has(sub.notificationId ?? '')) { + toUnsubscribe.push({ + notificationId: sub.notificationId ?? '', + appName: sub.appName ?? '', + appService: sub.appService ?? 'APP', + }); + return false; + } + return true; + }); + await setStoredCustomSubscriptions(filtered); + if (toUnsubscribe.length > 0) { + executeEvent('custom-ws-unsubscribe', toUnsubscribe); + } + return true; +}; + +const NOTIFICATION_SEEN_IN_APP_KEY = 'qortal_notification_seen_in_app'; +const NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; // 3 days + +/** Stored shape: address -> notificationKey -> timestamp. Electron: appStorage (sync read from cache); else localStorage. */ +function getStoredSeenInAppRecord(): Record> { + const appStorage = getAppStorage(); + const electronStorage = getElectronPersistentStorage(); + let raw: string | null | unknown = null; + if (appStorage != null && electronStorage != null) { + raw = (electronStorage as any).getItem(NOTIFICATION_SEEN_IN_APP_KEY, null); + } else if (typeof localStorage !== 'undefined') { + raw = localStorage.getItem(NOTIFICATION_SEEN_IN_APP_KEY); + } + if (raw == null) return {}; + try { + const parsed: unknown = + typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + const obj = parsed as Record; + const cutoff = Date.now() - NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS; + const result: Record> = {}; + for (const [addr, inner] of Object.entries(obj)) { + if (inner && typeof inner === 'object' && !Array.isArray(inner)) { + const keyRecord: Record = {}; + for (const [k, t] of Object.entries(inner)) { + if (typeof t === 'number' && t > cutoff) keyRecord[k] = t; + } + if (Object.keys(keyRecord).length > 0) result[addr] = keyRecord; + } + } + return result; + } catch (_) { + return {}; + } +} + +function getStoredSeenInAppKeys(address: string | null): string[] { + if (!address) return []; + const record = getStoredSeenInAppRecord(); + const byAddr = record[address] ?? {}; + const cutoff = Date.now() - NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS; + return Object.keys(byAddr).filter( + (k) => typeof byAddr[k] === 'number' && byAddr[k] > cutoff + ); +} + +function setStoredSeenInAppKeys( + address: string | null, + keys: string[] +): string[] { + if (!address || keys.length === 0) return []; + const record = getStoredSeenInAppRecord(); + const now = Date.now(); + const cutoff = now - NOTIFICATION_SEEN_IN_APP_MAX_AGE_MS; + record[address] = record[address] ?? {}; + for (const k of keys) record[address][k] = now; + const pruned: Record> = {}; + for (const [addr, inner] of Object.entries(record)) { + const filtered = Object.fromEntries( + Object.entries(inner).filter( + ([, t]) => typeof t === 'number' && t > cutoff + ) + ); + if (Object.keys(filtered).length > 0) pruned[addr] = filtered; + } + const appStorage = getAppStorage(); + const electronStorage = getElectronPersistentStorage(); + if (appStorage != null && electronStorage != null) { + (electronStorage as any).setItem(NOTIFICATION_SEEN_IN_APP_KEY, pruned); + appStorage + .set(NOTIFICATION_SEEN_IN_APP_KEY, pruned) + .catch((err) => + console.error('[get.ts] setStoredSeenInAppKeys appStorage failed:', err) + ); + } else if (typeof localStorage !== 'undefined') { + localStorage.setItem(NOTIFICATION_SEEN_IN_APP_KEY, JSON.stringify(pruned)); + } + const keysList = Object.keys(record[address] ?? {}).filter( + (k) => typeof record[address][k] === 'number' && record[address][k] > cutoff + ); + executeEvent('notification-seen-in-app-updated', { address, keys: keysList }); + return keysList; +} + +/** + * Mark notifications as "seen in app" for the current address. + * Payload: { notificationIds: (string | { notificationId: string, identifier?: string })[] }. + * App name/service from appInfo. + * - String: add prefix key only (all with that notificationId). + * - Object with optional identifier: add prefix key; if identifier present, also add full key for that item. + */ +export const markNotificationSeenInApp = (payload, appInfo) => { + const notificationIds = payload?.notificationIds; + if (!Array.isArray(notificationIds) || notificationIds.length === 0) { + return []; + } + const address = getCurrentAddress(); + if (!address) return []; + const appName = (appInfo?.name ?? '').toLowerCase(); + const appService = appInfo?.service ?? 'APP'; + const current = getStoredSeenInAppKeys(address); + const set = new Set(current); + notificationIds.forEach((item) => { + const notificationId = + typeof item === 'string' + ? String(item).trim() + : item && typeof item === 'object' && item.notificationId != null + ? String(item.notificationId).trim() + : ''; + if (!notificationId) return; + const prefixKey = `RESOURCE_PUBLISHED-${appName}-${appService}-${notificationId}`; + const identifier = + item && typeof item === 'object' && item.identifier != null + ? String(item.identifier).trim() + : ''; + if (identifier) { + set.add(`${prefixKey}-${identifier}`); + } else { + set.add(prefixKey); + } + }); + const merged = Array.from(set); + return setStoredSeenInAppKeys(address, merged); +}; + +/** Returns notification rules for the app in the same format as NOTIFICATION_ADD (image, link, notificationId, message, filters). */ +export const getNotificationSubscriptions = async (_payload, appInfo) => { + const appName = (appInfo?.name ?? '').toLowerCase(); + const appService = appInfo?.service ?? 'APP'; + const current = await getStoredCustomSubscriptions(); + const forApp = current.filter( + (sub) => + (sub.appName ?? '').toLowerCase() === appName && + (sub.appService ?? 'APP') === appService + ); + return forApp.map((sub) => { + const rf = sub.resourceFilter ?? {}; + const filters = { + service: typeof rf.service === 'string' ? rf.service : '', + identifier: typeof rf.identifier === 'string' ? rf.identifier : '', + }; + if (typeof rf.name === 'string') filters.name = rf.name; + if (rf.excludeBlocked !== undefined) + filters.excludeBlocked = !!rf.excludeBlocked; + if (typeof rf.mode === 'string') filters.mode = rf.mode; + return { + image: typeof sub.image === 'string' ? sub.image : undefined, + link: typeof sub.link === 'string' ? sub.link : '', + notificationId: + typeof sub.notificationId === 'string' ? sub.notificationId : '', + message: + sub.message && typeof sub.message === 'object' + ? { ...sub.message } + : { en: '' }, + filters, + }; + }); +}; + export const sendCoin = async (data, isFromExtension) => { const requiredFields = ['coin', 'amount']; const missingFields: string[] = []; @@ -6074,7 +6649,15 @@ export const openNewTab = async (data, isFromExtension) => { postProcess: 'capitalizeFirstChar', }) ); - executeEvent('addTab', { data: { service, name, identifier, path } }); + executeEvent('addTab', { + data: { + service, + name, + identifier, + path, + ...(data.navigateIfAlreadyOpen && { navigateIfAlreadyOpen: true }), + }, + }); executeEvent('open-apps-mode', {}); return true; } else { diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index b498f6a8..d5b108e8 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -76,7 +76,14 @@ import { playEncryptedMedia, cleanupEncryptedMedia, cleanupEncryptedMediaByTabId, + addNotificationSubscriptions, + getNotificationPermission, + getNotificationSubscriptions, + notificationHasPermission, + markNotificationSeenInApp, + removeNotificationSubscriptions, } from './get.ts'; +import { triggerMemberGroupsFetch } from '../subscriptions/useInitializeMySubscriptions.ts'; import { getData, storeData } from '../utils/chromeStorage.ts'; import { executeEvent } from '../utils/events.ts'; @@ -109,16 +116,85 @@ export const isRunningGateway = async () => { return isGateway; }; -export async function setPermission(key, value) { - try { - // Get the existing qortalRequestPermissions object - const qortalRequestPermissions = - (await getLocalStorage('qortalRequestPermissions')) || {}; +const getAppStorage = () => + typeof window !== 'undefined' && + (window as { appStorage?: { get: (k: string) => Promise; set: (k: string, v: unknown) => Promise } }).appStorage; + +const NOTIFICATION_PERMISSION_PREFIX = 'qAPPNotification-'; + +function normalizeNotificationPermissionAppName(appName: unknown): string { + return String(appName ?? '').trim().toLowerCase(); +} + +export function getNotificationPermissionKey(appName: unknown): string { + return `${NOTIFICATION_PERMISSION_PREFIX}${normalizeNotificationPermissionAppName(appName)}`; +} + +function isNotificationPermissionKey(key: unknown): key is string { + return typeof key === 'string' && key.startsWith(NOTIFICATION_PERMISSION_PREFIX); +} + +function normalizePermissionKey(key: unknown): string { + if (!isNotificationPermissionKey(key)) return String(key ?? ''); + return getNotificationPermissionKey( + key.slice(NOTIFICATION_PERMISSION_PREFIX.length) + ); +} + +function migrateNotificationPermissionKeys( + permissions: Record, + normalizedKey: string +): Record { + if (!isNotificationPermissionKey(normalizedKey)) return permissions; + const normalizedAppName = normalizeNotificationPermissionAppName( + normalizedKey.slice(NOTIFICATION_PERMISSION_PREFIX.length) + ); + const next = { ...permissions }; + for (const key of Object.keys(next)) { + if (!isNotificationPermissionKey(key)) continue; + const keyAppName = normalizeNotificationPermissionAppName( + key.slice(NOTIFICATION_PERMISSION_PREFIX.length) + ); + if (keyAppName === normalizedAppName && key !== normalizedKey) { + delete next[key]; + } + } + return next; +} - // Update the permission - qortalRequestPermissions[key] = value; +function toPermissionRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} - // Save the updated object back to storage +export async function setPermission(key, value) { + try { + const normalizedKey = normalizePermissionKey(key); + const appStorage = getAppStorage(); + if (appStorage) { + const rawPermissions = toPermissionRecord( + await appStorage.get('qortalRequestPermissions') + ); + const qortalRequestPermissions = migrateNotificationPermissionKeys( + rawPermissions, + normalizedKey + ); + qortalRequestPermissions[normalizedKey] = value; + await appStorage.set( + 'qortalRequestPermissions', + qortalRequestPermissions + ); + return; + } + const rawPermissions = toPermissionRecord( + await getLocalStorage('qortalRequestPermissions') + ); + const qortalRequestPermissions = migrateNotificationPermissionKeys( + rawPermissions, + normalizedKey + ); + qortalRequestPermissions[normalizedKey] = value; await setLocalStorage('qortalRequestPermissions', qortalRequestPermissions); } catch (error) { console.error('Error setting permission:', error); @@ -127,18 +203,93 @@ export async function setPermission(key, value) { export async function getPermission(key) { try { - // Get the qortalRequestPermissions object from storage - const qortalRequestPermissions = - (await getLocalStorage('qortalRequestPermissions')) || {}; - - // Return the value for the given key, or null if it doesn't exist - return qortalRequestPermissions[key] || null; + const normalizedKey = normalizePermissionKey(key); + const appStorage = getAppStorage(); + if (appStorage) { + const qortalRequestPermissions = toPermissionRecord( + await appStorage.get('qortalRequestPermissions') + ); + return qortalRequestPermissions[normalizedKey] ?? null; + } + const qortalRequestPermissions = toPermissionRecord( + await getLocalStorage('qortalRequestPermissions') + ); + return qortalRequestPermissions[normalizedKey] ?? null; } catch (error) { console.error('Error getting permission:', error); return null; } } +/** Returns the full permissions object (for listing apps with notification permission). */ +export async function getQortalRequestPermissions() { + try { + const appStorage = getAppStorage(); + if (appStorage) { + return (await appStorage.get('qortalRequestPermissions')) || {}; + } + return (await getLocalStorage('qortalRequestPermissions')) || {}; + } catch (error) { + console.error('Error getting permissions:', error); + return {}; + } +} + +/** App names that have qAPPNotification- === true. */ +export async function getAppsWithNotificationPermission() { + const perms = await getQortalRequestPermissions(); + const apps = []; + for (const key of Object.keys(perms)) { + if (key.startsWith('qAPPNotification-') && perms[key] === true) { + apps.push( + normalizeNotificationPermissionAppName( + key.replace(/^qAPPNotification-/, '') + ) + ); + } + } + return apps; +} + +const QORTAL_NOTIFICATION_OS_PUSH_DISABLED_KEY = + 'qortalNotificationOsPushDisabled'; + +/** Map of appName -> true if OS push is disabled for that app. */ +export async function getNotificationOsPushDisabledMap() { + try { + const appStorage = getAppStorage(); + if (appStorage) { + const map = + (await appStorage.get(QORTAL_NOTIFICATION_OS_PUSH_DISABLED_KEY)) || {}; + return typeof map === 'object' && !Array.isArray(map) ? map : {}; + } + const raw = await getData(QORTAL_NOTIFICATION_OS_PUSH_DISABLED_KEY); + return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + } catch { + return {}; + } +} + +export async function getNotificationOsPushDisabled(appName) { + const map = await getNotificationOsPushDisabledMap(); + return map[appName] === true; +} + +export async function setNotificationOsPushDisabled(appName, disabled) { + try { + const map = await getNotificationOsPushDisabledMap(); + map[appName] = !!disabled; + const appStorage = getAppStorage(); + if (appStorage) { + await appStorage.set(QORTAL_NOTIFICATION_OS_PUSH_DISABLED_KEY, map); + return; + } + await storeData(QORTAL_NOTIFICATION_OS_PUSH_DISABLED_KEY, map); + } catch (error) { + console.error('Error setting notification OS push disabled:', error); + } +} + // In-memory storage for session permissions const sessionPermissionsStore = new Map< string, @@ -175,6 +326,7 @@ export const VALID_SESSION_PERMISSIONS = [ 'SIGN_FOREIGN_FEES', 'REENCRYPT_GROUP_KEYS', 'START_CROSSCHAIN_SERVER', + 'NOTIFICATION_PERMISSION', ]; // Permissions automatically granted for the session when GET_USER_ACCOUNT is accepted @@ -331,6 +483,66 @@ function setupMessageListenerQortalRequest() { break; } + case 'NOTIFICATION_PERMISSION': { + try { + const res = await getNotificationPermission({ + isFromExtension, + appInfo, + skipAuth, + }); + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'Unable to get notification permission', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + + case 'NOTIFICATION_HAS_PERMISSION': { + try { + const res = await notificationHasPermission({ + appInfo, + skipAuth, + }); + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + error: + error?.message ?? 'Unable to read notification permission', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + case 'WHICH_UI': { try { const res = await getWhichUI(); @@ -1630,6 +1842,110 @@ function setupMessageListenerQortalRequest() { break; } + case 'NOTIFICATION_ADD': { + try { + const res = await addNotificationSubscriptions(request.payload, appInfo); + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'NOTIFICATION_ADD failed', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + + case 'NOTIFICATION_GET': { + try { + const res = await getNotificationSubscriptions(request.payload, appInfo); + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'NOTIFICATION_GET failed', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + + case 'NOTIFICATION_MARK_SEEN': { + try { + const res = markNotificationSeenInApp(request.payload, appInfo); + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'NOTIFICATION_MARK_SEEN failed', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + + case 'NOTIFICATION_REMOVE': { + try { + const res = await removeNotificationSubscriptions(request.payload, appInfo); + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'NOTIFICATION_REMOVE failed', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + case 'REGISTER_NAME': { try { const res = await registerNameRequest( @@ -2001,6 +2317,35 @@ function setupMessageListenerQortalRequest() { break; } + case 'UPDATE_SUBSCRIPTIONS': { + try { + if (appInfo?.name?.toLowerCase() !== 'subscriptions') { + throw new Error('UPDATE_SUBSCRIPTIONS is only available to the Subscriptions app'); + } + triggerMemberGroupsFetch(); + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: { ok: true }, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message ?? 'Unable to update subscriptions', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } + case 'GET_ARRR_SYNC_STATUS': { try { const res = await getArrrSyncStatus(request.payload); diff --git a/src/subscriptions/useInitializeMySubscriptions.ts b/src/subscriptions/useInitializeMySubscriptions.ts new file mode 100644 index 00000000..2d54d218 --- /dev/null +++ b/src/subscriptions/useInitializeMySubscriptions.ts @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { + managedSubscriptionsAtom, + managedSubscriptionsLoadingAtom, + myMemberGroupsAtom, + myMemberGroupsLastFetchedAtom, + mySubscriptionsAtom, + subscriptionsLoadingAtom, + userInfoAtom, +} from '../atoms/global'; +import { getBaseApiReact } from '../App'; +import { useSubscriptionsFromGroups } from './useSubscriptionsFromGroups'; +import { useManagedSubscriptionsFromGroups } from './useSubscriptionsFromManagedGroups'; + +const MEMBER_GROUPS_INTERVAL_MS = 5 * 60 * 1_000; + +// Module-level callback so external callers (e.g. HomeDesktop refresh button) +// can trigger an immediate re-fetch without needing a prop/context. +let _doFetchMemberGroups: (() => void) | null = null; + +/** Trigger an immediate re-fetch of member groups from anywhere. */ +export function triggerMemberGroupsFetch() { + _doFetchMemberGroups?.(); +} + +/** Called on logout to clear the fetch callback. */ +export function clearMemberGroupsPolling() { + _doFetchMemberGroups = null; +} + +/** + * Initializes subscription data globally. + * Must be mounted inside an authenticated context (e.g. the title bar). + * Fetches member groups on a 5-minute interval, then derives mySubscriptions + * and managedSubscriptions, storing them in global atoms. + */ +export function useInitializeMySubscriptions() { + const userInfo = useAtomValue(userInfoAtom); + const [myMemberGroups, setMyMemberGroups] = useAtom(myMemberGroupsAtom); + const [lastFetched, setLastFetched] = useAtom(myMemberGroupsLastFetchedAtom); + const setMySubscriptions = useSetAtom(mySubscriptionsAtom); + const setManagedSubscriptions = useSetAtom(managedSubscriptionsAtom); + const setSubscriptionsLoading = useSetAtom(subscriptionsLoadingAtom); + const setManagedSubscriptionsLoading = useSetAtom( + managedSubscriptionsLoadingAtom + ); + + // Stable ref so interval/address-watch callbacks always see the latest values. + const fetchRef = useRef<{ + address: string | undefined; + lastFetched: number; + setGroups: typeof setMyMemberGroups; + setLastFetched: typeof setLastFetched; + }>({ address: undefined, lastFetched: 0, setGroups: setMyMemberGroups, setLastFetched }); + + useEffect(() => { + fetchRef.current = { + address: userInfo?.address, + lastFetched, + setGroups: setMyMemberGroups, + setLastFetched, + }; + }); + + useEffect(() => { + async function fetchMemberGroups() { + const { address, setGroups, setLastFetched: setTs } = fetchRef.current; + if (!address) return; + try { + const res = await fetch( + `${getBaseApiReact()}/groups/member/${address}` + ); + if (!res.ok) return; + const data = await res.json(); + setGroups(data); + setTs(Date.now()); + } catch { + // silently ignore network errors + } + } + + // Expose for external callers (e.g. HomeDesktop refresh button). + _doFetchMemberGroups = fetchMemberGroups; + + const intervalId = setInterval(fetchMemberGroups, MEMBER_GROUPS_INTERVAL_MS); + + return () => { + clearInterval(intervalId); + _doFetchMemberGroups = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Trigger an immediate fetch whenever the address becomes available. + // This covers the case where userInfo is not yet populated when the hook + // first mounts (e.g. after a logout → re-login cycle). + const address = userInfo?.address; + useEffect(() => { + if (!address) return; + // Skip if data was fetched recently to avoid a redundant request on the + // very first mount when the address and a fresh lastFetched arrive together. + if (Date.now() - fetchRef.current.lastFetched < MEMBER_GROUPS_INTERVAL_MS) return; + _doFetchMemberGroups?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address]); + + const myMemberGroupsWhereAdmin = useMemo(() => { + return myMemberGroups.filter((group) => group.isAdmin); + }, [myMemberGroups]); + + const { mySubscriptions, loading } = useSubscriptionsFromGroups( + userInfo?.address, + userInfo?.name, + myMemberGroups + ); + + const { managedSubscriptions, loading: managedLoading } = + useManagedSubscriptionsFromGroups( + userInfo?.address, + userInfo?.name, + myMemberGroupsWhereAdmin + ); + + // Sync local hook results into global atoms so any component can read them. + useEffect(() => { + setMySubscriptions(mySubscriptions); + }, [mySubscriptions, setMySubscriptions]); + + useEffect(() => { + setManagedSubscriptions(managedSubscriptions); + }, [managedSubscriptions, setManagedSubscriptions]); + + useEffect(() => { + setSubscriptionsLoading(loading); + }, [loading, setSubscriptionsLoading]); + + useEffect(() => { + setManagedSubscriptionsLoading(managedLoading); + }, [managedLoading, setManagedSubscriptionsLoading]); +} diff --git a/src/subscriptions/useSubscriptionsFromGroups.ts b/src/subscriptions/useSubscriptionsFromGroups.ts new file mode 100644 index 00000000..97efd143 --- /dev/null +++ b/src/subscriptions/useSubscriptionsFromGroups.ts @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Sha256 } from 'asmcrypto.js'; +import { getBaseApiReact } from '../App'; +import { Buffer } from 'buffer'; + +export const publicSaltSubscriptionApp = + 'gnRp+Pao85XZlExcqynLS0+GaKCL3ia9E1sEm9XPaOA='; + +// ─── inline types ──────────────────────────────────────────────────────────── + +type BillingInterval = 'monthly'; + +type GroupAccessType = 'private'; + +type GroupApiItem = { + groupId: number; + owner: string; + groupName: string; + description: string; + created: number; + isOpen: boolean; + approvalThreshold: string; + minimumBlockDelay: number; + maximumBlockDelay: number; + memberCount: number; + ownerPrimaryName: string; + type?: number; +}; + +type SubscriptionFullDetails = { + schema: string; + subscriptionId: string; + ownerName: string; + ownerAddress?: string; + groupId: number; + groupAccess: GroupAccessType; + title: string; + description: string; + perks: string[]; + tags?: string[]; + createdAt: string; + amountQort?: string; + intervalDays?: number; + graceDays?: number; + states?: unknown[]; + status?: 'active' | 'disabled'; + disabledAt?: number; + disabledReason?: string; +}; + +type MySubscription = { + id: string; + title: string; + ownerName: string; + groupInfo: unknown; + priceQort: number; + billingInterval: BillingInterval; + status: 'active' | 'payment-needed' | 'disabled'; + nextPaymentDue: number | null; + link: string; +}; + +// ─── inline helpers ────────────────────────────────────────────────────────── + +const AMOUNT_TOLERANCE = 0.00001; + +function getPaidIntervalsFromAmount( + paidAmount: number, + unitPrice: number +): number { + if ( + !Number.isFinite(paidAmount) || + !Number.isFinite(unitPrice) || + unitPrice <= 0 + ) { + return 0; + } + const raw = paidAmount / unitPrice; + if (!Number.isFinite(raw) || raw <= 0) return 0; + return Math.floor(raw + AMOUNT_TOLERANCE); +} + +function isMultipleOfUnitPrice(paidAmount: number, unitPrice: number): boolean { + const intervals = getPaidIntervalsFromAmount(paidAmount, unitPrice); + if (intervals < 1) return false; + return Math.abs(paidAmount - unitPrice * intervals) <= AMOUNT_TOLERANCE; +} + +export function getSubscriptionIdForGroup(groupId: number): string { + return `subscription-${groupId}`; +} + +const safeBase64 = (base64: string): string => + base64 + .replace(/\+/g, '.') // Replace '+' with '.' (URL-safe) + .replace(/\//g, '~') // Replace '/' with '~' (URL-safe) + .replace(/_/g, '!') // Replace '_' with '!' if needed (optional) + .replace(/=+$/, ''); // Remove padding + +export async function hashWord( + word: string, + collisionStrength: number, + publicSalt: string +): Promise { + const saltedWord = publicSalt + word; + + try { + if (!crypto?.subtle?.digest) throw new Error('Web Crypto not available'); + + const encoded = new TextEncoder().encode(saltedWord); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); + const base64 = Buffer.from(hashBuffer).toString('base64'); + + return safeBase64(base64).slice(0, collisionStrength); + } catch { + const hash = new Sha256() + .process(new TextEncoder().encode(saltedWord)) + .finish().result!; + const base64 = Buffer.from(hash).toString('base64'); + + return safeBase64(base64).slice(0, collisionStrength); + } +} + +export async function buildSubscriptionIdentifiers(subscriptionId: string) { + const typeDetails = await hashWord( + 'subscription_details', + 14, + publicSaltSubscriptionApp + ); + + const typeIndex = await hashWord( + 'subscription_index', + 14, + publicSaltSubscriptionApp + ); + + const idHash = await hashWord(subscriptionId, 14, publicSaltSubscriptionApp); + + if (!typeDetails || !typeIndex || !idHash) { + throw new Error('Failed to create subscription identifiers'); + } + + return { + detailsIdentifier: typeDetails + idHash, + indexIdentifier: typeIndex + idHash + '-v1', + idHash, + }; +} + +function intervalDaysToBillingInterval(_intervalDays: number): BillingInterval { + return 'monthly'; +} + +function parseOnChainIndexData( + data: string +): { priceQort: number; intervalDays: number } | null { + if (!data || typeof data !== 'string') return null; + const decoded = + data.length > 0 && !data.includes('|') + ? (() => { + try { + return atob(data); + } catch { + return data; + } + })() + : data; + const parts = decoded.trim().split('|'); + if (parts.length < 5 || parts[0] !== 'qsub1') return null; + const amt = parseFloat(parts[2]); + if (Number.isNaN(amt)) return null; + return { priceQort: amt, intervalDays: 30 }; +} + +async function fetchSubscriptionIndexPrice( + ownerName: string, + indexIdentifier: string +): Promise<{ priceQort: number; intervalDays: number } | null> { + const res = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT/${encodeURIComponent(ownerName)}/${encodeURIComponent(indexIdentifier)}` + ); + if (!res.ok) return null; + let dataStr = await res.text(); + try { + const parsed = JSON.parse(dataStr); + if (parsed && typeof parsed === 'object') { + const raw = parsed.resource?.data ?? parsed.data; + if (raw != null) dataStr = typeof raw === 'string' ? raw : String(raw); + } + } catch { + // not JSON + } + if (!dataStr.includes('|')) { + try { + dataStr = atob(dataStr); + } catch { + return null; + } + } + return parseOnChainIndexData(dataStr); +} + +/** Parse the subscriber's latest PRODUCT record to get { si, tx }. */ +function parseProductRecordData(raw: any): { si?: string; tx: string } | null { + if (!raw) return null; + if (typeof raw.tx === 'string') { + return { si: typeof raw.si === 'string' ? raw.si : undefined, tx: raw.tx }; + } + const b64 = raw.data ?? raw.resource?.data; + if (typeof b64 === 'string') { + try { + const decoded = JSON.parse(atob(b64)) as { si?: string; tx?: string }; + if (decoded && typeof decoded.tx === 'string') { + return { + si: typeof decoded.si === 'string' ? decoded.si : undefined, + tx: decoded.tx, + }; + } + } catch { + // ignore + } + } + return null; +} + +// ─── hook ──────────────────────────────────────────────────────────────────── + +export function useSubscriptionsFromGroups( + address: string, + name: string, + groups: GroupApiItem[] +) { + const [mySubscriptions, setMySubscriptions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const disableLoading = useRef(false); + useEffect(() => { + let cancelled = false; + + async function run() { + if (!address) return; + if (!name) return; + if (!groups || groups.length === 0) return; + if (!disableLoading.current) { + setLoading(true); + } + + setError(null); + + try { + disableLoading.current = true; + const results = await Promise.all( + groups + .filter((g) => g.owner !== address && !g.isOpen) + .map(async (g) => { + const ownerPrimaryName = g.ownerPrimaryName; + + if (!ownerPrimaryName) return null; + + const subscriptionId = getSubscriptionIdForGroup(g.groupId); + try { + await buildSubscriptionIdentifiers(subscriptionId); + } catch (error) { + console.log('error', error); + return null; + } + + const { indexIdentifier, detailsIdentifier } = + await buildSubscriptionIdentifiers(subscriptionId); + + const matches = await fetch( + `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT&identifier=${indexIdentifier}&name=${ownerPrimaryName}&limit=1&exactmatchnames=true&prefix=true` + ); + if (!matches.ok) return null; + const matchesData = await matches.json(); + if (!matchesData || matchesData.length === 0) return null; + + const detailsRes = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT/${encodeURIComponent(ownerPrimaryName)}/${encodeURIComponent(detailsIdentifier)}` + ); + if (!detailsRes.ok) return null; + const dataStr = await detailsRes.json(); + + const details = dataStr as SubscriptionFullDetails | undefined; + + const anyDetails = details as any; + if (anyDetails?.status === 'disabled') return null; + const title = + details && typeof anyDetails?.title === 'string' + ? anyDetails.title + : null; + const detailsAmountQort = + details && anyDetails?.amountQort != null + ? Number(anyDetails.amountQort) + : null; + const detailsIntervalDays = 30; + if (!title || !detailsAmountQort || !detailsIntervalDays) + return null; + // Resolve locked-in price/interval/expiry from subscriber's PRODUCT record. + let priceQort = Number.isFinite(detailsAmountQort) + ? detailsAmountQort + : null; + if (!priceQort) return null; + let resolvedIntervalDays = detailsIntervalDays; + let nextPaymentDue: number | null = null; + + const userName = name; + if (!userName) return null; + + try { + const paymentRecords = await fetch( + `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=PRODUCT&identifier=${detailsIdentifier}&name=${userName}&limit=1&exactmatchnames=true&reverse=true` + ); + if (!paymentRecords.ok) return null; + const paymentRecordsData = await paymentRecords.json(); + + if (paymentRecordsData && paymentRecordsData.length > 0) { + const record = paymentRecordsData[0]; + let recordData: any = null; + try { + if ((record as any).data) { + recordData = (record as any).data; + } else if ((record as any).identifier) { + const dataResponse = await fetch( + `${getBaseApiReact()}/arbitrary/PRODUCT/${userName}/${(record as any).identifier}` + ); + if (dataResponse.ok) + recordData = await dataResponse.json(); + } + } catch { + // ignore fetch error + } + + const parsed = parseProductRecordData(recordData); + if (parsed) recordData = parsed; + + if (parsed?.si && parsed?.tx) { + const indexData = await fetchSubscriptionIndexPrice( + ownerPrimaryName, + parsed.si + ); + if (indexData) { + priceQort = indexData.priceQort; + resolvedIntervalDays = 30; + } + + try { + const txResponse = await fetch( + `${getBaseApiReact()}/transactions/signature/${parsed.tx}` + ); + if (txResponse.ok) { + const txData = await txResponse.json(); + const paymentTs = txData?.timestamp; + const amountPaid = parseFloat(txData?.amount || '0'); + if ( + paymentTs != null && + amountPaid > 0 && + isMultipleOfUnitPrice(amountPaid, priceQort) + ) { + const paidIntervals = getPaidIntervalsFromAmount( + amountPaid, + priceQort + ); + const expiresAt = + paymentTs + + paidIntervals * + resolvedIntervalDays * + 24 * + 60 * + 60 * + 1000; + nextPaymentDue = expiresAt; + } + } + } catch { + // ignore tx fetch error — keep details-based fallback + } + } + } + } catch { + // ignore PRODUCT fetch error — keep details-based fallback + } + + const sub: MySubscription = { + id: subscriptionId, + title, + ownerName: ownerPrimaryName, + groupInfo: g, + priceQort, + billingInterval: + intervalDaysToBillingInterval(resolvedIntervalDays), + nextPaymentDue, + link: ``, + status: + nextPaymentDue == null || Date.now() > nextPaymentDue + ? 'payment-needed' + : 'active', + }; + + return sub; + }) + ); + + const mySubs = results.filter(Boolean) as MySubscription[]; + if (!cancelled) setMySubscriptions(mySubs); + } catch (e: any) { + if (!cancelled) setError(e?.message ?? 'Failed to load subscriptions'); + } finally { + if (!cancelled) setLoading(false); + } + } + + run(); + return () => { + cancelled = true; + }; + }, [address, name, groups]); + + return { mySubscriptions, loading, error }; +} diff --git a/src/subscriptions/useSubscriptionsFromManagedGroups.ts b/src/subscriptions/useSubscriptionsFromManagedGroups.ts new file mode 100644 index 00000000..4e0df712 --- /dev/null +++ b/src/subscriptions/useSubscriptionsFromManagedGroups.ts @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { + buildSubscriptionIdentifiers, + getSubscriptionIdForGroup, +} from './useSubscriptionsFromGroups'; +import { base64ToUint8Array } from '../encryption/encryption'; +import { getBaseApiReact } from '../App'; + +// ─── types ──────────────────────────────────────────────────────────────────── + +type GroupApiItem = { + groupId: number; + owner: string; + groupName: string; + memberCount: number; + [key: string]: unknown; +}; + +export type ManagedSubscriptionActions = { + groupId: number; + pendingJoinRequests: number; + needsReEncryption: boolean; + totalActions: number; +}; + +export type ManagedSubscriptionEntry = { + group: GroupApiItem; + groupId: number; + actions: ManagedSubscriptionActions; + url: string; +}; + +export const getGroupMembers = async (groupNumber: number) => { + // const validApi = await findUsableApi(); + + const response = await fetch( + `${getBaseApiReact()}/groups/members/${groupNumber}?limit=0` + ); + const groupData = await response.json(); + return groupData; +}; + +export const getGroupAdmins = async (groupNumber: number) => { + const response = await fetch( + `${getBaseApiReact()}/groups/members/${groupNumber}?limit=0&onlyAdmins=true` + ); + const groupData = await response.json(); + const members: string[] = []; + const membersAddresses: string[] = []; + const both: Array<{ name: string; address: string }> = []; + + const getMemNames = groupData?.members?.map(async (member: any) => { + if (member?.member) { + const name = member.primaryName; + if (name) { + members.push(name); + both.push({ name, address: member.member }); + } + membersAddresses.push(member.member); + } + + return true; + }); + await Promise.all(getMemNames); + + return { names: members, addresses: membersAddresses, both }; +}; + +// ─── hook ──────────────────────────────────────────────────────────────────── + +export function useManagedSubscriptionsFromGroups( + address: string, + name: string, + groups: GroupApiItem[] +) { + const [managedSubscriptions, setManagedSubscriptions] = useState< + ManagedSubscriptionEntry[] + >([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const disableLoading = useRef(false); + useEffect(() => { + let cancelled = false; + + async function run() { + if (!address || !name) return; + if (!groups || groups.length === 0) return; + if (!disableLoading.current) { + setLoading(true); + } + setError(null); + + try { + const results = await Promise.all( + groups + .filter((g) => g.owner === address) + .map(async (g): Promise => { + const groupId = g.groupId; + + try { + const subscriptionId = getSubscriptionIdForGroup(groupId); + const { + detailsIdentifier, + indexIdentifier: baseIndexIdentifier, + } = await buildSubscriptionIdentifiers(subscriptionId); + + // Confirm a versioned index exists (subscription is published) + const baseIdentifierPrefix = baseIndexIdentifier.replace( + /-v\d+$/, + '' + ); + + const matchesResponse = await fetch( + `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT&identifier=${baseIdentifierPrefix}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${name}` + ); + const matches = await matchesResponse.json(); + + if (!matches || matches.length === 0) return null; + const latestIdentifier = matches[0]?.identifier; + if (!latestIdentifier || !/-v\d+$/.test(latestIdentifier)) { + return null; + } + + // ── join requests ───────────────────────────────────────── + + let validJoinRequestCount = 0; + + const joinRes = await fetch( + `${getBaseApiReact()}/groups/joinrequests/${groupId}` + ); + if (joinRes.ok) { + const joinData = await joinRes.json(); + const joinRequests: any[] = Array.isArray(joinData) + ? joinData + : []; + + const validations = await Promise.all( + joinRequests.map(async (request) => { + try { + const nameRes = await fetch( + `${getBaseApiReact()}/names/primary/${request.joiner}` + ); + if (!nameRes.ok) return false; + const nameData = await nameRes.json(); + const primaryName = nameData?.name; + if (!primaryName) return false; + + const resourceResponse = await fetch( + `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=PRODUCT&identifier=${detailsIdentifier}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${primaryName}` + ); + const resources = await resourceResponse.json(); + + return resourceResponse.ok && resources.length > 0; + } catch { + return false; + } + }) + ); + + validJoinRequestCount = validations.filter(Boolean).length; + } + + // ── re-encryption check ─────────────────────────────────── + + let needsReEncryption = false; + + try { + const memberData = await getGroupMembers(groupId); + const { names: adminNames } = await getGroupAdmins(groupId); + + if (adminNames.length > 0) { + const queryString = adminNames + .map((n) => `name=${n}`) + .join('&'); + const url = `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT_PRIVATE&identifier=symmetric-qchat-group-${groupId}&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true`; + const pubRes = await fetch(url); + + if (!pubRes.ok) { + needsReEncryption = true; + } else { + const adminData = await pubRes.json(); + const filterId = adminData.filter( + (d: any) => + d.identifier === `symmetric-qchat-group-${groupId}` + ); + + if (!filterId || filterId.length === 0) { + needsReEncryption = true; + } else { + const sorted = filterId.sort((a: any, b: any) => { + const dateA = a.updated + ? new Date(a.updated) + : new Date(a.created); + const dateB = b.updated + ? new Date(b.updated) + : new Date(b.created); + return dateB.getTime() - dateA.getTime(); + }); + const publish = sorted[0]; + + const encRes = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${publish.name}/${publish.identifier}?encoding=base64` + ); + const encData = await encRes.text(); + const allCombined = base64ToUint8Array(encData); + const countStart = allCombined.length - 4; + const countArray = allCombined.slice( + countStart, + countStart + 4 + ); + const count = new Uint32Array(countArray.buffer)[0]; + + if (count !== memberData?.memberCount) { + needsReEncryption = true; + } + } + } + } + } catch { + // silently fail — don't flag re-encryption on a network error + } + + const actions: ManagedSubscriptionActions = { + groupId, + pendingJoinRequests: validJoinRequestCount, + needsReEncryption, + totalActions: + validJoinRequestCount + (needsReEncryption ? 1 : 0), + }; + + return { + group: g, + groupId, + actions, + url: `manage/${groupId}`, + }; + } catch { + return null; + } + }) + ); + + const entries = results.filter( + (r): r is ManagedSubscriptionEntry => r !== null + ); + + if (!cancelled) setManagedSubscriptions(entries); + } catch (e: any) { + if (!cancelled) + setError(e?.message ?? 'Failed to load managed subscriptions'); + } finally { + if (!cancelled) setLoading(false); + } + } + + run(); + return () => { + cancelled = true; + }; + }, [address, name, groups]); + + return { managedSubscriptions, loading, error }; +} diff --git a/src/types/window.d.ts b/src/types/window.d.ts index c54060b7..58d48697 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -1,5 +1,10 @@ declare global { interface Window { + appStorage?: { + get: (key: string) => Promise; + set: (key: string, value: unknown) => Promise; + delete: (key: string) => Promise; + }; coreSetup?: unknown; electronAPI?: { openExternal?: (url: string) => void; @@ -11,6 +16,7 @@ declare global { windowMinimize?: () => void; windowMaximize?: () => Promise; windowClose?: () => void; + focusWindow?: () => Promise; getWindowState?: () => Promise<{ isMaximized: boolean }>; getPlatform?: () => Promise; showAppMenu?: (x?: number, y?: number) => void; diff --git a/src/utils/electronPersistentStorage.ts b/src/utils/electronPersistentStorage.ts new file mode 100644 index 00000000..a0117c65 --- /dev/null +++ b/src/utils/electronPersistentStorage.ts @@ -0,0 +1,63 @@ +/** + * Jotai-compatible storage that backs onto window.appStorage (Electron persistent-store.json). + * Uses an in-memory cache for sync getItem; setItem/removeItem persist async. + * Use for atoms that should persist in Electron the same way as permissions. + */ + +/** Sync storage compatible with Jotai's atomWithStorage (getItem returns same type as initialValue). */ +export type ElectronPersistentStorage = { + getItem: (key: string, initialValue: T) => T; + setItem: (key: string, value: unknown) => void; + removeItem: (key: string) => void; +}; + +const cache: Record = {}; +let storageInstance: ElectronPersistentStorage | null = null; + +function getAppStorage(): Window['appStorage'] { + if (typeof window === 'undefined') return undefined; + return (window as Window & { appStorage?: Window['appStorage'] }).appStorage; +} + +export function getElectronPersistentStorage(): ElectronPersistentStorage | undefined { + if (!getAppStorage()) return undefined; + if (storageInstance) return storageInstance; + const appStorage = getAppStorage()!; + storageInstance = { + getItem(key: string, initialValue: T): T { + return (key in cache ? cache[key] : initialValue) as T; + }, + setItem(key: string, value: unknown): void { + cache[key] = value; + appStorage.set(key, value).catch((err) => + console.error('[electronPersistentStorage] setItem failed:', err) + ); + }, + removeItem(key: string): void { + delete cache[key]; + appStorage.delete(key).catch((err) => + console.error('[electronPersistentStorage] removeItem failed:', err) + ); + }, + }; + return storageInstance; +} + +/** Keys used for notification/ws atoms so hydration can load them. */ +export const ELECTRON_PERSISTENT_ATOM_KEYS = { + customWsSubscriptionsByAddress: 'qortal_custom_ws_subscriptions', + notificationSeenInApp: 'qortal_notification_seen_in_app', + seenAllNotificationsByAddress: 'qortal_seen_all_notifications', +} as const; + +/** Populate in-memory cache from appStorage (call once when app mounts in Electron). */ +export async function hydrateElectronPersistentCache(): Promise { + const appStorage = getAppStorage(); + if (!appStorage) return; + const keys = Object.values(ELECTRON_PERSISTENT_ATOM_KEYS); + const results = await Promise.all(keys.map((k) => appStorage.get(k))); + keys.forEach((key, i) => { + const value = results[i]; + if (value !== undefined && value !== null) cache[key] = value; + }); +}