diff --git a/packages/status-js/src/client/account.test.ts b/packages/status-js/src/client/account.test.ts index 20937954..a5a0869f 100644 --- a/packages/status-js/src/client/account.test.ts +++ b/packages/status-js/src/client/account.test.ts @@ -5,8 +5,11 @@ import { expect, test } from 'vitest' import { Account } from './account' +import type { Client } from './client' + test('should verify the signature', async () => { - const account = new Account() + // @fixme + const account = new Account({} as unknown as Client) const message = utf8ToBytes('123') const messageHash = keccak256(message) @@ -20,7 +23,8 @@ test('should verify the signature', async () => { }) test('should not verify signature with different message', async () => { - const account = new Account() + // @fixme + const account = new Account({} as unknown as Client) const message = utf8ToBytes('123') const messageHash = keccak256(message) diff --git a/packages/status-js/src/client/account.ts b/packages/status-js/src/client/account.ts index a4b17b98..a5bede55 100644 --- a/packages/status-js/src/client/account.ts +++ b/packages/status-js/src/client/account.ts @@ -1,18 +1,34 @@ import { keccak256 } from 'ethereum-cryptography/keccak' import { getPublicKey, sign, utils } from 'ethereum-cryptography/secp256k1' -import { bytesToHex, concatBytes } from 'ethereum-cryptography/utils' +import { + bytesToHex, + concatBytes, + hexToBytes, +} from 'ethereum-cryptography/utils' import { compressPublicKey } from '../utils/compress-public-key' import { generateUsername } from '../utils/generate-username' -export class Account { - public privateKey: string - public publicKey: string - public chatKey: string - public username: string +import type { Client } from './client' +import type { Community } from './community/community' - constructor() { - const privateKey = utils.randomPrivateKey() +type MembershipStatus = 'none' | 'requested' | 'approved' | 'kicked' // TODO: add 'banned' + +export class Account { + #client: Client + + privateKey: string + publicKey: string + chatKey: string + username: string + membership: MembershipStatus = 'none' + + constructor(client: Client, initialPrivateKey?: string) { + this.#client = client + + const privateKey = initialPrivateKey + ? hexToBytes(initialPrivateKey) + : utils.randomPrivateKey() const publicKey = getPublicKey(privateKey) this.privateKey = bytesToHex(privateKey) @@ -22,7 +38,7 @@ export class Account { } // sig must be a 65-byte compact ECDSA signature containing the recovery id as the last element. - sign = async (payload: Uint8Array) => { + async sign(payload: Uint8Array) { const hash = keccak256(payload) const [signature, recoverId] = await sign(hash, this.privateKey, { recovered: true, @@ -31,4 +47,34 @@ export class Account { return concatBytes(signature, new Uint8Array([recoverId])) } + + updateMembership(community: Community): void { + const isMember = community.isMember('0x' + this.publicKey) + + switch (this.membership) { + case 'none': { + community.requestToJoin() + this.membership = 'requested' + // fixme: this is a hack to make sure the UI updates when the membership status changes + this.#client.account = this + return + } + + case 'approved': { + if (isMember === false) { + this.membership = 'kicked' + this.#client.account = this // fixme + } + return + } + + case 'requested': { + if (isMember) { + this.membership = 'approved' + this.#client.account = this // fixme + } + return + } + } + } } diff --git a/packages/status-js/src/client/client.ts b/packages/status-js/src/client/client.ts index d7b60193..4554a515 100644 --- a/packages/status-js/src/client/client.ts +++ b/packages/status-js/src/client/client.ts @@ -14,12 +14,24 @@ import { Account } from './account' import { ActivityCenter } from './activityCenter' import { Community } from './community/community' import { handleWakuMessage } from './community/handle-waku-message' +import { LocalStorage } from './storage' +import type { Storage } from './storage' import type { Waku } from 'js-waku' +const THROWAWAY_ACCOUNT_STORAGE_KEY = 'throwaway_account' + export interface ClientOptions { + /** + * Public key of a community to join. + */ publicKey: string environment?: 'production' | 'test' + /** + * Custom storage for data persistance + * @default window.localStorage + */ + storage?: Storage } class Client { @@ -40,9 +52,13 @@ class Client { #wakuDisconnectionTimer: ReturnType public activityCenter: ActivityCenter - public account?: Account public community: Community + #account?: Account + #accountCallbacks = new Set<(account?: Account) => void>() + + storage: Storage + constructor( waku: Waku, wakuDisconnectionTimer: ReturnType, @@ -53,11 +69,22 @@ class Client { this.wakuMessages = new Set() this.#wakuDisconnectionTimer = wakuDisconnectionTimer + // Storage + this.storage = options.storage ?? new LocalStorage() + // Activity Center this.activityCenter = new ActivityCenter(this) // Community this.community = new Community(this, options.publicKey) + + // Restore account if exists + const privateKey = this.storage.getItem( + THROWAWAY_ACCOUNT_STORAGE_KEY + ) + if (privateKey) { + this.#account = new Account(this, privateKey) + } } static async start(options: ClientOptions) { @@ -110,18 +137,39 @@ class Client { await this.waku.stop() } - public createAccount = async (): Promise => { - this.account = new Account() + get account() { + return this.#account + } - await this.community.requestToJoin() + set account(account: Account | undefined) { + this.#account = account + this.storage.setItem( + THROWAWAY_ACCOUNT_STORAGE_KEY, + this.#account?.privateKey + ) + + for (const callback of this.#accountCallbacks) { + callback(this.#account) + } + } + + public createAccount(): Account { + this.account = new Account(this) + this.account.updateMembership(this.community) return this.account } - // TODO?: should this exist - // public deleteAccount = () => { - // this.account = undefined - // } + public deleteAccount() { + this.account = undefined + } + + public onAccountChange(listener: (account?: Account) => void) { + this.#accountCallbacks.add(listener) + return () => { + this.#accountCallbacks.delete(listener) + } + } public sendWakuMessage = async ( type: keyof typeof ApplicationMetadataMessage.Type, @@ -133,11 +181,11 @@ class Client { throw new Error('Waku not started') } - if (!this.account) { + if (!this.#account) { throw new Error('Account not created') } - const signature = await this.account.sign(payload) + const signature = await this.#account.sign(payload) const message = ApplicationMetadataMessage.encode({ type: type as ApplicationMetadataMessage.Type, @@ -146,7 +194,7 @@ class Client { }) const wakuMesage = await WakuMessage.fromBytes(message, contentTopic, { - sigPrivKey: hexToBytes(this.account.privateKey), + sigPrivKey: hexToBytes(this.#account.privateKey), symKey, }) @@ -154,7 +202,7 @@ class Client { } public handleWakuMessage = (wakuMessage: WakuMessage): void => { - handleWakuMessage(wakuMessage, this, this.community) + handleWakuMessage(wakuMessage, this, this.community, this.#account) } } diff --git a/packages/status-js/src/client/community/community.ts b/packages/status-js/src/client/community/community.ts index 74b29395..967f6c96 100644 --- a/packages/status-js/src/client/community/community.ts +++ b/packages/status-js/src/client/community/community.ts @@ -63,10 +63,8 @@ export class Community { throw new Error('Failed to intiliaze Community') } - this.description = description - + // Observe community description this.observe() - this.addMembers(this.description.members) // Chats await this.observeChatMessages(this.description.chats) @@ -235,6 +233,7 @@ export class Community { // Community // state this.description = description + this.addMembers(this.description.members) // callback this.#callbacks.forEach(callback => callback({ ...this.description })) diff --git a/packages/status-js/src/client/community/handle-waku-message.ts b/packages/status-js/src/client/community/handle-waku-message.ts index 148206bb..86e84f26 100644 --- a/packages/status-js/src/client/community/handle-waku-message.ts +++ b/packages/status-js/src/client/community/handle-waku-message.ts @@ -17,6 +17,7 @@ import { recoverPublicKey } from '../../utils/recover-public-key' import { getChatUuid } from './get-chat-uuid' import { mapChatMessage } from './map-chat-message' +import type { Account } from '../account' import type { Client } from '../client' import type { Community } from './community' import type { WakuMessage } from 'js-waku' @@ -25,7 +26,8 @@ export function handleWakuMessage( wakuMessage: WakuMessage, // state client: Client, - community: Community + community: Community, + account?: Account ): void { // decode (layers) // validate @@ -97,6 +99,8 @@ export function handleWakuMessage( community.handleDescription(decodedPayload) community.setClock(BigInt(decodedPayload.clock)) + account?.updateMembership(community) + break } diff --git a/packages/status-js/src/client/storage.ts b/packages/status-js/src/client/storage.ts new file mode 100644 index 00000000..84afca57 --- /dev/null +++ b/packages/status-js/src/client/storage.ts @@ -0,0 +1,61 @@ +type Unsubscribe = () => void + +export interface Storage { + getItem: (key: string) => T | null + setItem: (key: string, newValue: unknown) => void + removeItem: (key: string) => void + subscribe?: (key: string, callback: (value: unknown) => void) => Unsubscribe +} + +export class LocalStorage implements Storage { + #prefix = 'status-im:' + + #getStorageKey(key: string) { + return `${this.#prefix}${key}` + } + + getItem(key: string): T | null { + const value = window.localStorage.getItem(this.#getStorageKey(key)) + + if (!value) { + return null + } + + try { + return JSON.parse(value) as T + } catch { + // invalid JSON + return null + } + } + + setItem(key: string, value: unknown): boolean { + try { + window.localStorage.setItem( + this.#getStorageKey(key), + JSON.stringify(value) + ) + return true + } catch { + return false + } + } + + removeItem(key: string) { + return window.localStorage.removeItem(this.#getStorageKey(key)) + } + + subscribe(key: string, callback: (value: unknown) => void) { + const handler = (event: StorageEvent) => { + if (event.key === key && event.newValue) { + callback(JSON.parse(event.newValue)) + } + } + + window.addEventListener('storage', handler) + + return () => { + window.removeEventListener('storage', handler) + } + } +} diff --git a/packages/status-js/src/utils/recover-public-key.test.ts b/packages/status-js/src/utils/recover-public-key.test.ts index 26fe593e..cfa463c8 100644 --- a/packages/status-js/src/utils/recover-public-key.test.ts +++ b/packages/status-js/src/utils/recover-public-key.test.ts @@ -4,12 +4,14 @@ import { expect, test } from 'vitest' import { Account } from '../client/account' import { recoverPublicKey } from './recover-public-key' +import type { Client } from '../client/client' import type { ApplicationMetadataMessage } from '../protos/application-metadata-message' test('should recover public key', async () => { const payload = utf8ToBytes('hello') - const account = new Account() + // @fixme + const account = new Account({} as unknown as Client) const signature = await account.sign(payload) expect(bytesToHex(recoverPublicKey(signature, payload))).toEqual( @@ -60,7 +62,8 @@ test('should recover public key from fixture', async () => { test('should not recover public key with different payload', async () => { const payload = utf8ToBytes('1') - const account = new Account() + // @fixme + const account = new Account({} as unknown as Client) const signature = await account.sign(payload) const payload2 = utf8ToBytes('2') diff --git a/packages/status-react/src/components/main-sidebar/components/community-info/index.tsx b/packages/status-react/src/components/main-sidebar/components/community-info/index.tsx index 8b75b576..0343c19f 100644 --- a/packages/status-react/src/components/main-sidebar/components/community-info/index.tsx +++ b/packages/status-react/src/components/main-sidebar/components/community-info/index.tsx @@ -1,15 +1,14 @@ import React from 'react' -import { useMembers, useProtocol } from '../../../../protocol' +import { useProtocol } from '../../../../protocol' import { styled } from '../../../../styles/config' import { Avatar, DialogTrigger, Text } from '../../../../system' import { CommunityDialog } from './community-dialog' export const CommunityInfo = () => { - const { community } = useProtocol() - const members = useMembers() + const { client } = useProtocol() - const { displayName, color } = community.identity! + const { displayName, color } = client.community.description.identity! return ( @@ -18,7 +17,7 @@ export const CommunityInfo = () => {
{displayName} - {members.length} members + {client.community.members.length} members
diff --git a/packages/status-react/src/components/main-sidebar/components/get-started/index.tsx b/packages/status-react/src/components/main-sidebar/components/get-started/index.tsx index e32ddce0..f162ed7e 100644 --- a/packages/status-react/src/components/main-sidebar/components/get-started/index.tsx +++ b/packages/status-react/src/components/main-sidebar/components/get-started/index.tsx @@ -1,25 +1,14 @@ import React from 'react' -// import { CreateProfileDialog } from '../../../../components/create-profile-dialog' -// import { useLocalStorage } from '../../../../hooks/use-local-storage' import { useAccount } from '../../../../protocol' import { Button, Flex } from '../../../../system' -// import { DialogTrigger } from '../../../../system/dialog' import { Grid } from '../../../../system/grid' import { Heading } from '../../../../system/heading' -// import { ConnectWalletDialog } from './connect-wallet-dialog' -// import { SyncStatusProfileDialog } from './sync-status-profile-dialog' -// import { ThrowawayProfileFoundDialog } from './throwaway-profile-found-dialog' - export const GetStarted = () => { - // const [throwawayProfile] = useLocalStorage('cipherIdentityt', null) + const { account, createAccount } = useAccount() - // const handleSkip = () => { - // // TODO: Add skip logic - // } - - const { createAccount } = useAccount() + const membershipRequested = account?.membership === 'requested' return ( @@ -145,25 +134,11 @@ export const GetStarted = () => { Want to jump into the discussion? - {/* - - - */} - - {/* - - - */} - - - {/* - - {account ? ( - - ) : ( - - )} - */} + ) diff --git a/packages/status-react/src/components/main-sidebar/components/get-started/throwaway-profile-found-dialog.tsx b/packages/status-react/src/components/main-sidebar/components/get-started/throwaway-profile-found-dialog.tsx index 67673b9d..8a687893 100644 --- a/packages/status-react/src/components/main-sidebar/components/get-started/throwaway-profile-found-dialog.tsx +++ b/packages/status-react/src/components/main-sidebar/components/get-started/throwaway-profile-found-dialog.tsx @@ -1,55 +1,49 @@ import React from 'react' -import { useAccount } from '../../../../protocol' import { Avatar, Dialog, - EmojiHash, + // EmojiHash, + EthAddress, Flex, Heading, Text, } from '../../../../system' +import type { Account } from '../../../../protocol' + interface Props { - onSkip: () => void + account: Account } export const ThrowawayProfileFoundDialog = (props: Props) => { - const { onSkip } = props + const { account } = props - const { account } = useAccount() - - const handleLoadThrowawayProfile = () => { + const handleLoad = () => { // TODO: load throwaway profile } - if (!account) { - return null - } - return ( - + {account.username} - - Chatkey: 0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377 - - + + {account.chatKey} + {/* Chatkey: {account.chatKey} */} + {/* */} - Throwaway profile is found in your local {"browser's"} storage. + Throwaway profile is found in your local storage.
- Would you like to load it and use it? + Would you like to use it?
- - Skip - - + Skip + Load Throwaway Profile diff --git a/packages/status-react/src/components/main-sidebar/index.tsx b/packages/status-react/src/components/main-sidebar/index.tsx index 0bed4c0c..4b5cf560 100644 --- a/packages/status-react/src/components/main-sidebar/index.tsx +++ b/packages/status-react/src/components/main-sidebar/index.tsx @@ -21,7 +21,7 @@ export const MainSidebar = () => { - {!account && ( + {account?.membership !== 'approved' && ( <> diff --git a/packages/status-react/src/components/member-sidebar/disconnect-dialog.tsx b/packages/status-react/src/components/member-sidebar/disconnect-dialog.tsx index 1dd04d9b..39e8edf5 100644 --- a/packages/status-react/src/components/member-sidebar/disconnect-dialog.tsx +++ b/packages/status-react/src/components/member-sidebar/disconnect-dialog.tsx @@ -1,7 +1,15 @@ import React from 'react' import { useAccount } from '../../protocol' -import { Avatar, Dialog, EmojiHash, Flex, Heading, Text } from '../../system' +import { + Avatar, + Dialog, + // EmojiHash, + EthAddress, + Flex, + Heading, + Text, +} from '../../system' import type { Account } from '../../protocol' @@ -18,10 +26,12 @@ export const DisconnectDialog = (props: Props) => { Do you want to disconnect your profile from this browser? - + {account.username} - Chatkey: {account.chatKey} - + + Chatkey: {account.chatKey} + + {/* */} diff --git a/packages/status-react/src/components/member-sidebar/member-item.tsx b/packages/status-react/src/components/member-sidebar/member-item.tsx index 5c1dfdd7..b3830848 100644 --- a/packages/status-react/src/components/member-sidebar/member-item.tsx +++ b/packages/status-react/src/components/member-sidebar/member-item.tsx @@ -26,7 +26,7 @@ export const MemberItem = (props: Props) => { />
- + {username} {verified && ( diff --git a/packages/status-react/src/components/member-sidebar/user-item.tsx b/packages/status-react/src/components/member-sidebar/user-item.tsx index 46c892fd..2f91c979 100644 --- a/packages/status-react/src/components/member-sidebar/user-item.tsx +++ b/packages/status-react/src/components/member-sidebar/user-item.tsx @@ -14,12 +14,12 @@ export const UserItem = (props: Props) => { const { account } = props return ( - +
- + {account.username} @@ -27,29 +27,28 @@ export const UserItem = (props: Props) => { {account.chatKey}
+ + + + + + + + +
- - - - - - - - - -
) } @@ -62,4 +61,5 @@ const DisconnectButton = styled('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + flexShrink: 0, }) diff --git a/packages/status-react/src/hooks/use-local-storage.tsx b/packages/status-react/src/hooks/use-local-storage.tsx deleted file mode 100644 index f7dc55ff..00000000 --- a/packages/status-react/src/hooks/use-local-storage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -// See: https://usehooks-ts.com/react-hook/use-event-listener -// import { useEventListener } from '../useEventListener' -import type { Dispatch, SetStateAction } from 'react' - -declare global { - interface WindowEventMap { - 'local-storage': CustomEvent - } -} - -type SetValue = Dispatch> - -export function useLocalStorage( - key: string, - initialValue: T -): [T, SetValue] { - // Get from local storage then - // parse stored json or return initialValue - const readValue = useCallback((): T => { - // Prevent build error "window is undefined" but keep keep working - if (typeof window === 'undefined') { - return initialValue - } - - try { - const item = window.localStorage.getItem(key) - return item ? (parseJSON(item) as T) : initialValue - } catch (error) { - console.warn(`Error reading localStorage key “${key}”:`, error) - return initialValue - } - }, [initialValue, key]) - - // State to store our value - // Pass initial state function to useState so logic is only executed once - const [storedValue, setStoredValue] = useState(readValue) - - // Return a wrapped version of useState's setter function that ... - // ... persists the new value to localStorage. - const setValue: SetValue = useCallback( - value => { - // Prevent build error "window is undefined" but keeps working - if (typeof window == 'undefined') { - console.warn( - `Tried setting localStorage key “${key}” even though environment is not a client` - ) - } - - try { - // Allow value to be a function so we have the same API as useState - const newValue = value instanceof Function ? value(storedValue) : value - - // Save to local storage - window.localStorage.setItem(key, JSON.stringify(newValue)) - - // Save state - setStoredValue(newValue) - - // We dispatch a custom event so every useLocalStorage hook are notified - window.dispatchEvent(new Event('local-storage')) - } catch (error) { - console.warn(`Error setting localStorage key “${key}”:`, error) - } - }, - [key, storedValue] - ) - - useEffect(() => { - setStoredValue(readValue()) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // const handleStorageChange = useCallback(() => { - // setStoredValue(readValue()) - // }, [readValue]) - - // this only works for other documents, not the current one - // useEventListener('storage', handleStorageChange) - - // this is a custom event, triggered in writeValueToLocalStorage - // See: useLocalStorage() - // useEventListener('local-storage', handleStorageChange) - - return [storedValue, setValue] -} - -function parseJSON(value: string | null): T | undefined { - try { - return value === 'undefined' ? undefined : JSON.parse(value ?? '') - } catch { - return undefined - } -} diff --git a/packages/status-react/src/protocol/provider.tsx b/packages/status-react/src/protocol/provider.tsx index ff1153f3..781bfc5f 100644 --- a/packages/status-react/src/protocol/provider.tsx +++ b/packages/status-react/src/protocol/provider.tsx @@ -19,8 +19,7 @@ type State = { type Action = | { type: 'INIT'; client: Client } | { type: 'UPDATE_COMMUNITY'; community: Community['description'] } - | { type: 'SET_ACCOUNT'; account: Account } - | { type: 'REMOVE_ACCOUNT' } + | { type: 'SET_ACCOUNT'; account: Account | undefined } interface Props { options: ClientOptions @@ -35,6 +34,7 @@ const reducer = (state: State, action: Action): State => { ...state, loading: false, client, + account: client.account, community: client.community.description, } } @@ -44,9 +44,6 @@ const reducer = (state: State, action: Action): State => { case 'SET_ACCOUNT': { return { ...state, account: action.account } } - case 'REMOVE_ACCOUNT': { - return { ...state, account: undefined } - } } } @@ -66,6 +63,7 @@ export const ProtocolProvider = (props: Props) => { useEffect(() => { const loadClient = async () => { const client = await createClient(options) + dispatch({ type: 'INIT', client }) } @@ -76,9 +74,18 @@ export const ProtocolProvider = (props: Props) => { useEffect(() => { if (client) { - return client.community.onChange(community => { - dispatch({ type: 'UPDATE_COMMUNITY', community }) - }) + const unsubscribe = [ + client.onAccountChange(account => { + dispatch({ type: 'SET_ACCOUNT', account }) + }), + client.community.onChange(community => { + dispatch({ type: 'UPDATE_COMMUNITY', community }) + }), + ] + + return () => { + unsubscribe.forEach(fn => fn()) + } } }, [client]) diff --git a/packages/status-react/src/protocol/use-account.tsx b/packages/status-react/src/protocol/use-account.tsx index d6ce915d..fd942949 100644 --- a/packages/status-react/src/protocol/use-account.tsx +++ b/packages/status-react/src/protocol/use-account.tsx @@ -3,22 +3,14 @@ import { useProtocol } from './provider' import type { Account } from '@status-im/js' export const useAccount = () => { - const { client, account, dispatch } = useProtocol() + const { client, account } = useProtocol() - const createAccount = async () => { - const account = await client.createAccount() - dispatch({ type: 'SET_ACCOUNT', account }) - // TODO: save account - - return account - } - - const deleteAccount = () => { - dispatch({ type: 'REMOVE_ACCOUNT' }) - // TODO: remove from storage - } - - return { account, createAccount, deleteAccount } as const + return { + account, + createAccount: () => client.createAccount(), + deleteAccount: () => client.deleteAccount(), + isMember: account ? client.community.isMember(account.chatKey) : false, + } as const } export type { Account } diff --git a/packages/status-react/src/system/avatar/avatar.tsx b/packages/status-react/src/system/avatar/avatar.tsx index 1fe90683..8da40b08 100644 --- a/packages/status-react/src/system/avatar/avatar.tsx +++ b/packages/status-react/src/system/avatar/avatar.tsx @@ -41,7 +41,7 @@ const Avatar = (props: Props) => { size={size} style={{ background: identiconRing, - padding: !identiconRing ? 0 : undefined, + padding: identiconRing ? undefined : 0, }} >