diff --git a/package.json b/package.json index 4096d5c9..0a6b75fe 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@juggle/resize-observer": "^3.4.0", "@react-spring/web": "^9.5.5", "@reduxjs/toolkit": "^1.9.1", + "@types/chrome": "^0.0.326", "@vitejs/plugin-react": "^4.3.4", "@windmillcode/quill-emoji": "^2.0.3000", "body-scroll-lock": "^4.0.0-beta.0", diff --git a/src/components/common/WebAssemblyRunner/WebAssemblyRunner.tsx b/src/components/common/WebAssemblyRunner/WebAssemblyRunner.tsx index 8015a182..2b6e77f3 100644 --- a/src/components/common/WebAssemblyRunner/WebAssemblyRunner.tsx +++ b/src/components/common/WebAssemblyRunner/WebAssemblyRunner.tsx @@ -6,6 +6,9 @@ import { FC, useEffect } from 'react'; import { InitXXDK, setXXDKBasePath } from 'xxdk-wasm'; import { useUtils } from 'src/contexts/utils-context'; +import { havenStorageExtension } from './haven-storage-extension'; +import { havenStorageLocal } from './local-storage'; +import { HavenStorage } from './type'; type Logger = { StopLogging: () => void; @@ -23,6 +26,7 @@ declare global { GetLogger: () => Logger; logger?: Logger; getCrashedLogFile: () => Promise; + havenStorage: HavenStorage; } } @@ -41,15 +45,32 @@ const WebAssemblyRunner: FC = ({ children }) => { // symlinking your public directory: // cd public && ln -s ../node_modules/xxdk-wasm xxdk-wasm && cd .. // Then override with this function here: - //setXXDKBasePath(window!.location.href + 'xxdk-wasm'); + // setXXDKBasePath(window!.location.href + 'xxdk-wasm'); // NOTE: NextJS hackery, since they can't seem to provide a helper to get a proper origin... setXXDKBasePath(basePath); - InitXXDK().then(async (result: any) => { - setUtils(result); + const initXXdk = async () => { + if (localStorage.getItem('🞮🞮speakeasyapp') === null) { + const isAvailable = await havenStorageExtension.init(); + if (isAvailable) { + console.log('[HavenStorage] Using extension storage since extension is available'); + window.havenStorage = havenStorageExtension; + } else { + console.log('[HavenStorage] Using localStorage since extension is not available'); + window.havenStorage = havenStorageLocal; + } + } else { + console.log('[HavenStorage] Using localStorage due to existing keys'); + window.havenStorage = havenStorageLocal; + } + + const xxdkUtils = await InitXXDK(); + setUtils(xxdkUtils); setUtilsLoaded(true); - }); + }; + + initXXdk(); } }, [basePath, setUtils, setUtilsLoaded, utilsLoaded]); return <>{children}; diff --git a/src/components/common/WebAssemblyRunner/haven-storage-extension.ts b/src/components/common/WebAssemblyRunner/haven-storage-extension.ts new file mode 100644 index 00000000..b5391c0b --- /dev/null +++ b/src/components/common/WebAssemblyRunner/haven-storage-extension.ts @@ -0,0 +1,180 @@ +import { HavenStorage } from './type'; + +// type from Extension +type BaseMessage = { + api: Api; + requestId: string; +}; + +export type TRequest = BaseMessage<'LocalStorage:Request'> & + ( + | { + action: 'clear' | 'keys'; + } + | { + action: 'getItem' | 'removeItem'; + key: string; + } + | { + action: 'setItem'; + key: string; + value: string; + } + ); + +export type TResponse = BaseMessage<'LocalStorage:Response'> & + ( + | { + action: 'getItem'; + result: unknown; + } + | { + action: 'keys'; + result: string[]; + } + | { + action: 'removeItem' | 'clear' | 'setItem'; + } + ); + +function generateRequestId(): string { + return Math.random().toString(36).slice(2, 11); +} + +const promiseHandlers: Record< + string, + { resolve: (result: any) => void; reject: (error: any) => void } +> = {}; + +// establish a long-lived Port to extension +const EXT_ID = 'ihilcflljgogpbkopcmkoaoildehaehc'; +let port: chrome.runtime.Port; + +function isValidResponse(msg: any): msg is TResponse { + return msg?.requestId && msg.api === 'LocalStorage:Response'; +} + +let status: 'disconnected' | 'not-installed' | 'ok' = 'disconnected'; +function setupPort(): Promise { + return new Promise((resolve, reject) => { + port = chrome.runtime.connect(EXT_ID, { name: 'LocalStorageChannel' }); + + let settled = false; + // incoming responses on that port + port.onMessage.addListener((msg) => { + // we want to settle only once + if (!settled) { + settled = true; + resolve(true); + } + + if (msg?.requestId && msg.api === 'LocalStorage:Response') { + const handler = promiseHandlers[msg.requestId]; + + if (handler && isValidResponse(msg)) { + handler.resolve('result' in msg ? msg.result : undefined); + delete promiseHandlers[msg.requestId]; + } + } + }); + + port.onDisconnect.addListener(() => { + if (chrome.runtime.lastError) { + if ( + chrome.runtime.lastError.message === + 'Could not establish connection. Receiving end does not exist.' + ) { + status = 'not-installed'; + resolve(false); + } else { + status = 'disconnected'; + reject(chrome.runtime.lastError); + } + } + }); + + // send message to check if extension is responding to request + port.postMessage({ + api: 'LocalStorage:Request', + action: 'getItem', + key: 'hello', + requestId: 'init' + } satisfies TRequest); + }); +} + +function sendViaPort(action: 'clear' | 'keys'): Promise; +function sendViaPort(action: 'getItem' | 'removeItem', key: string): Promise; +function sendViaPort(action: 'setItem', key: string, value: any): Promise; +function sendViaPort( + action: 'getItem' | 'setItem' | 'removeItem' | 'clear' | 'keys', + key?: string, + value?: any +): Promise { + const requestId = generateRequestId(); + + return new Promise(async (resolve, reject) => { + promiseHandlers[requestId] = { resolve, reject }; + let request: TRequest; + + if (action === 'clear' || action === 'keys') { + // no key/value + request = { api: 'LocalStorage:Request', action, requestId }; + } else if (action === 'getItem' || action === 'removeItem') { + // key is required here + request = { api: 'LocalStorage:Request', action, key: key!, requestId }; + } else if (action === 'setItem') { + // setItem: both key and value are required + request = { api: 'LocalStorage:Request', action, key: key!, value: value!, requestId }; + } else { + throw new Error(`Unknown action: ${action}`); + } + + // ports get disconnected after inactivity + if (status === 'disconnected') { + await setupPort(); + } + + try { + port.postMessage(request); + } catch (err) { + console.error('postMessage failed, reconnecting port', err); + setupPort(); + // retry once after reconnect + try { + port.postMessage({ api: 'LocalStorage:Request', action, key, value, requestId }); + } catch (retryErr) { + delete promiseHandlers[requestId]; + reject(retryErr); + } + } + }); +} + +export const havenStorageExtension = { + getItem(key) { + return sendViaPort('getItem', key); + }, + setItem(key, value) { + return sendViaPort('setItem', key, value); + }, + delete(key) { + return sendViaPort('removeItem', key); + }, + clear() { + return sendViaPort('clear'); + }, + keys() { + return sendViaPort('keys'); + }, + key(_idx) { + // not implemented on SW side + return Promise.reject('not implemented'); + }, + isAvailable() { + return true; + }, + init() { + return setupPort(); + } +} satisfies HavenStorage; diff --git a/src/components/common/WebAssemblyRunner/local-storage.ts b/src/components/common/WebAssemblyRunner/local-storage.ts new file mode 100644 index 00000000..f395e636 --- /dev/null +++ b/src/components/common/WebAssemblyRunner/local-storage.ts @@ -0,0 +1,43 @@ +import { HavenStorage } from './type'; + +export const havenStorageLocal: HavenStorage = { + async getItem(key) { + return localStorage.getItem(key); + }, + + async setItem(key, value) { + localStorage.setItem(key, value); + }, + + async delete(key) { + localStorage.removeItem(key); + }, + + async clear() { + localStorage.clear(); + }, + + async keys() { + const keys: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) { + keys.push(key); + } + } + + return keys; + }, + + key(index) { + return new Promise((resolve, reject) => { + try { + const key = localStorage.key(index); + resolve(key); + } catch (err) { + reject(err); + } + }); + } +}; diff --git a/src/components/common/WebAssemblyRunner/type.ts b/src/components/common/WebAssemblyRunner/type.ts new file mode 100644 index 00000000..1fe9d352 --- /dev/null +++ b/src/components/common/WebAssemblyRunner/type.ts @@ -0,0 +1,12 @@ +export type HavenStorage = { + getItem: (key: string) => Promise; + setItem: (key: string, value: any) => Promise; + delete: (key: string) => Promise; + clear: () => Promise; + keys: () => Promise; + key: (index: number) => Promise; + isAvailable?: () => boolean; + + // boolean indicating if the storage is available + init?: () => Promise; +}; diff --git a/src/contexts/dm-client-context.tsx b/src/contexts/dm-client-context.tsx index 4393b82c..6cc97e7d 100644 --- a/src/contexts/dm-client-context.tsx +++ b/src/contexts/dm-client-context.tsx @@ -135,7 +135,9 @@ export const DMContextProvider: FC = ({ children }) => { useEffect(() => { if (client) { try { - dispatch(dms.actions.setUserNickname(client.GetNickname())); + client.GetNickname().then((nickname) => { + dispatch(dms.actions.setUserNickname(nickname)); + }); } catch (e) { // no nickname found } @@ -149,8 +151,8 @@ export const DMContextProvider: FC = ({ children }) => { }, [client, dmsDatabaseName, setDmsDatabaseName]); const createDatabaseCipher = useCallback( - (cmix: CMix, decryptedPassword: Uint8Array) => { - const cipher = utils.NewDatabaseCipher( + async (cmix: CMix, decryptedPassword: Uint8Array) => { + const cipher = await utils.NewDatabaseCipher( cmix.GetID(), decryptedPassword, MAXIMUM_PAYLOAD_BLOCK_SIZE @@ -177,7 +179,7 @@ export const DMContextProvider: FC = ({ children }) => { try { const workerPath = (await dmIndexedDbWorkerPath()).toString(); //console.log('DMWORKERPATH: ' + workerPath); - const notifications = utils.LoadNotificationsDummy(cmix.GetID()); + const notifications = await utils.LoadNotificationsDummy(cmix.GetID()); NewDMClientWithIndexedDb( cmix.GetID(), notifications.GetID(), @@ -206,8 +208,9 @@ export const DMContextProvider: FC = ({ children }) => { rawPassword as string, channelManager.ExportPrivateIdentity(rawPassword as string) ); - const cipher = createDatabaseCipher(cmix as CMix, decryptedPassword as Uint8Array); - createDMClient(cmix as CMix, cipher, privateIdentity); + createDatabaseCipher(cmix as CMix, decryptedPassword as Uint8Array).then((cipher) => { + createDMClient(cmix as CMix, cipher, privateIdentity); + }); } }, [ channelManager, diff --git a/src/contexts/network-client-context.tsx b/src/contexts/network-client-context.tsx index e2643722..1d85b328 100644 --- a/src/contexts/network-client-context.tsx +++ b/src/contexts/network-client-context.tsx @@ -117,7 +117,7 @@ export type ChannelManager = { cmixParams: Uint8Array, tags: Uint8Array ) => Promise; - IsChannelAdmin: (channelId: Uint8Array) => boolean; + IsChannelAdmin: (channelId: Uint8Array) => Promise; GetNotificationLevel: (channelId: Uint8Array) => ChannelNotificationLevel; GetNotificationStatus: (channelId: Uint8Array) => NotificationStatus; SetMobileNotificationsLevel: ( @@ -184,7 +184,7 @@ export type NetworkContext = { createChannelManager: (privateIdentity: Uint8Array) => Promise; loadChannelManager: (storageTag: string, cmix?: CMix) => Promise; handleInitialLoadData: () => Promise; - getNickName: () => string; + getNickName: () => Promise; setNickname: (nickname: string) => boolean; sendReply: (reply: string, replyToMessageId: string, tags?: string[]) => Promise; sendReaction: (reaction: string, reactToMessageId: string) => Promise; @@ -352,7 +352,7 @@ export const NetworkProvider: FC = (props) => { name: chanInfo.name, privacyLevel: getPrivacyLevel(chanInfo.receptionId || chanInfo.channelId), description: chanInfo.description, - isAdmin: channelManager.IsChannelAdmin(utils.Base64ToUint8Array(chanInfo.channelId)) + isAdmin: await channelManager.IsChannelAdmin(utils.Base64ToUint8Array(chanInfo.channelId)) }; if (appendToCurrent) { @@ -456,12 +456,14 @@ export const NetworkProvider: FC = (props) => { console.log('fetchedChannels', JSON.stringify(fetchedChannels)); - const channelList = fetchedChannels.map((ch: DBChannel) => ({ + const channelListPromises = fetchedChannels.map(async (ch: DBChannel) => ({ ...ch, privacyLevel: getPrivacyLevel(ch.id), - isAdmin: channelManager.IsChannelAdmin(utils.Base64ToUint8Array(ch.id)) + isAdmin: await channelManager.IsChannelAdmin(utils.Base64ToUint8Array(ch.id)) })); + const channelList = await Promise.all(channelListPromises); + channelList.forEach((channel) => dispatch(channels.actions.upsert(channel))); }, [channelManager, db, dispatch, getPrivacyLevel, utils]); @@ -506,7 +508,7 @@ export const NetworkProvider: FC = (props) => { async (tag: string) => { console.log('Loading channel manager with tag:', tag); if (cmixId !== undefined && cipher && utils) { - const notifications = utils.LoadNotificationsDummy(cmixId); + const notifications = await utils.LoadNotificationsDummy(cmixId); const loadedChannelsManager = await utils.LoadChannelsManagerWithIndexedDb( cmixId, (await channelsIndexedDbWorkerPath()).toString(), @@ -548,7 +550,7 @@ export const NetworkProvider: FC = (props) => { console.log('Creating channel manager...'); if (cmixId !== undefined && cipher && utils && utils.NewChannelsManagerWithIndexedDb) { const workerPath = (await channelsIndexedDbWorkerPath()).toString(); - const notifications = utils.LoadNotificationsDummy(cmixId); + const notifications = await utils.LoadNotificationsDummy(cmixId); const createdChannelManager = await utils.NewChannelsManagerWithIndexedDb( cmixId, workerPath, @@ -667,7 +669,7 @@ export const NetworkProvider: FC = (props) => { }, [currentChannel?.id, currentMessages?.length, hasMore, loadMoreChannelData, pagination.end]); const joinChannelFromURL = useCallback( - (url: string, password = '') => { + async (url: string, password = '') => { if (channelManager && channelManager.JoinChannelFromURL) { try { const chanInfo = channelDecoder( @@ -681,7 +683,9 @@ export const NetworkProvider: FC = (props) => { name: chanInfo?.name, description: chanInfo?.description, privacyLevel: getPrivacyLevel(chanInfo?.channelId), - isAdmin: channelManager.IsChannelAdmin(utils.Base64ToUint8Array(chanInfo.channelId)) + isAdmin: await channelManager.IsChannelAdmin( + utils.Base64ToUint8Array(chanInfo.channelId) + ) }) ); dispatch(app.actions.selectChannelOrConversation(chanInfo.channelId)); @@ -889,7 +893,7 @@ export const NetworkProvider: FC = (props) => { [setDmNickname, channelManager, currentChannel?.id, currentConversation, utils] ); - const getNickName = useCallback(() => { + const getNickName = useCallback(async () => { let nickName = ''; if (channelManager?.GetNickname && currentChannel) { try { @@ -900,27 +904,29 @@ export const NetworkProvider: FC = (props) => { } if (currentConversation) { - nickName = getDmNickname(); + nickName = await getDmNickname(); } return nickName; }, [channelManager, currentChannel, currentConversation, getDmNickname, utils]); useEffect(() => { - if (currentChannel) { - dispatch( - channels.actions.updateNickname({ - channelId: currentChannel.id, - nickname: getNickName() - }) - ); - } else if (currentConversation) { - dispatch(dms.actions.setUserNickname(getNickName())); - } + getNickName().then((nickname) => { + if (currentChannel) { + dispatch( + channels.actions.updateNickname({ + channelId: currentChannel.id, + nickname: nickname + }) + ); + } else if (currentConversation) { + dispatch(dms.actions.setUserNickname(nickname)); + } + }); }, [currentChannel, currentConversation, dispatch, getNickName]); const generateIdentities = useCallback( (amountOfIdentities: number) => { - const identitiesObjects: ReturnType = []; + const identitiesObjects: Awaited> = []; if (utils && utils.GenerateChannelIdentity && cmix) { for (let i = 0; i < amountOfIdentities; i++) { const createdPrivateIdentity = utils.GenerateChannelIdentity(cmix?.GetID()); @@ -979,17 +985,17 @@ export const NetworkProvider: FC = (props) => { ) => { console.log('Checking registration readiness...'); return new Promise((resolve) => { - const intervalId = setInterval(() => { + const intervalId = setInterval(async () => { if (cmix) { const isReadyInfo = isReadyInfoDecoder( - JSON.parse(decoder.decode(cmix?.IsReady(CMIX_NETWORK_READINESS_THRESHOLD))) + JSON.parse(decoder.decode(await cmix?.IsReady(CMIX_NETWORK_READINESS_THRESHOLD))) ); onIsReadyInfoChange(isReadyInfo); if (isReadyInfo.isReady) { clearInterval(intervalId); - setTimeout(() => { + setTimeout(async () => { console.log('Network ready, creating channel manager...'); - createChannelManager(selectedPrivateIdentity); + await createChannelManager(selectedPrivateIdentity); setIsAuthenticated(true); resolve(); }, 3000); diff --git a/src/contexts/utils-context.tsx b/src/contexts/utils-context.tsx index 450c4dd8..b9e23f6d 100644 --- a/src/contexts/utils-context.tsx +++ b/src/contexts/utils-context.tsx @@ -55,7 +55,7 @@ export type XXDKUtils = { cmixParams: Uint8Array ) => Promise; LoadNotifications: (cmixId: number) => Notifications; - LoadNotificationsDummy: (cmixId: number) => Notifications; + LoadNotificationsDummy: (cmixId: number) => Promise; GetDefaultCMixParams: () => Uint8Array; GetChannelInfo: (prettyPrint: string) => Uint8Array; Base64ToUint8Array: (base64: string) => Uint8Array; @@ -81,7 +81,7 @@ export type XXDKUtils = { cmixId: number, storagePassword: Uint8Array, payloadMaximumSize: number - ) => RawCipher; + ) => Promise; LoadChannelsManagerWithIndexedDb: ( cmixId: number, wasmJsPath: string, diff --git a/src/hooks/useCmix.ts b/src/hooks/useCmix.ts index 17d86f8b..04056ea2 100644 --- a/src/hooks/useCmix.ts +++ b/src/hooks/useCmix.ts @@ -56,8 +56,8 @@ const useCmix = () => { }, [utils]); const createDatabaseCipher = useCallback( - (id: number, password: Uint8Array) => { - const cipher = utils.NewDatabaseCipher(id, password, MAXIMUM_PAYLOAD_BLOCK_SIZE); + async (id: number, password: Uint8Array) => { + const cipher = await utils.NewDatabaseCipher(id, password, MAXIMUM_PAYLOAD_BLOCK_SIZE); setDatabaseCipher({ id: cipher.GetID(), @@ -107,7 +107,7 @@ const useCmix = () => { setStatus(NetworkStatus.CONNECTING); try { - cmix.StartNetworkFollower(FOLLOWER_TIMEOUT_PERIOD); + await cmix.StartNetworkFollower(FOLLOWER_TIMEOUT_PERIOD); } catch (error) { console.error('Error while StartNetworkFollower:', error); setStatus(NetworkStatus.FAILED); diff --git a/src/hooks/useDmClient.ts b/src/hooks/useDmClient.ts index cb4c4132..ce54cd73 100644 --- a/src/hooks/useDmClient.ts +++ b/src/hooks/useDmClient.ts @@ -143,10 +143,10 @@ const useDmClient = () => { [client, dispatch] ); - const getDmNickname = useCallback(() => { + const getDmNickname = useCallback(async () => { let nickname: string; try { - nickname = client?.GetNickname() ?? ''; + nickname = (await client?.GetNickname()) ?? ''; } catch (error) { nickname = ''; } diff --git a/src/hooks/useRemotelySynchedValue.ts b/src/hooks/useRemotelySynchedValue.ts index 68a0f359..33e105c8 100644 --- a/src/hooks/useRemotelySynchedValue.ts +++ b/src/hooks/useRemotelySynchedValue.ts @@ -15,12 +15,17 @@ const useRemotelySynchedValue = (key: string, decoder: Decoder, defaultVal useEffect(() => { if (kv) { - const id = kv.listenOn(key, (v) => { + let listenOnId: number | undefined = undefined; + kv.listenOn(key, (v) => { setValue(v !== undefined ? decoder(v) : v); + }).then((_listenOnId) => { + listenOnId = _listenOnId; }); return () => { - kv.unregisterListener(key, id); + if (listenOnId) { + kv.unregisterListener(key, listenOnId); + } }; } }, [decoder, key, kv]); diff --git a/src/types/collective.ts b/src/types/collective.ts index 088e12a7..01614e72 100644 --- a/src/types/collective.ts +++ b/src/types/collective.ts @@ -28,7 +28,11 @@ export type RemoteKV = { Get: (key: string, version: number) => Promise; Delete: (key: string, version: number) => Promise; Set: (key: string, encodedKVMapEntry: Uint8Array) => Promise; - ListenOnRemoteKey: (key: string, version: number, onChange: KeyChangedByRemoteCallback) => number; + ListenOnRemoteKey: ( + key: string, + version: number, + onChange: KeyChangedByRemoteCallback + ) => Promise; DeleteRemoteKeyListener: (key: string, id: number) => void; }; diff --git a/src/types/index.ts b/src/types/index.ts index bd6cd4cf..bcb6d825 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,9 +10,9 @@ type HealthCallback = { Callback: (healthy: boolean) => void }; export type CMix = { AddHealthCallback: (callback: HealthCallback) => number; GetID: () => number; - IsReady: (threshold: number) => Uint8Array; + IsReady: (threshold: number) => Promise; ReadyToSend: () => boolean; - StartNetworkFollower: (timeoutMilliseconds: number) => void; + StartNetworkFollower: (timeoutMilliseconds: number) => Promise; StopNetworkFollower: () => void; WaitForNetwork: (timeoutMilliseconds: number) => Promise; SetTrackNetworkPeriod: (periodMs: number) => void; @@ -44,7 +44,7 @@ export type DMClient = { ) => Promise; GetIdentity: () => Uint8Array; SetNickname: (nickname: string) => void; - GetNickname: () => string; + GetNickname: () => Promise; GetDatabaseName: () => string; BlockPartner: (pubkey: Uint8Array) => Promise; UnblockPartner: (pubkey: Uint8Array) => Promise;