Add persistence of throwaway accounts (#315)

* add account membership status

* add storage and account listeners

* update membership

* update ui

* update membership status value

* use boolean instead of undefined

* rename callback function

* remove noise

* listeners -> callbacks

* update storage

* lint issue workaround

* update account storing
This commit is contained in:
Pavel 2022-10-07 20:05:09 +02:00 committed by GitHub
parent 36322d5a41
commit 213ca26877
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
18 changed files with 284 additions and 237 deletions

View File

@ -5,8 +5,11 @@ import { expect, test } from 'vitest'
import { Account } from './account' import { Account } from './account'
import type { Client } from './client'
test('should verify the signature', async () => { test('should verify the signature', async () => {
const account = new Account() // @fixme
const account = new Account({} as unknown as Client)
const message = utf8ToBytes('123') const message = utf8ToBytes('123')
const messageHash = keccak256(message) const messageHash = keccak256(message)
@ -20,7 +23,8 @@ test('should verify the signature', async () => {
}) })
test('should not verify signature with different message', 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 message = utf8ToBytes('123')
const messageHash = keccak256(message) const messageHash = keccak256(message)

View File

@ -1,18 +1,34 @@
import { keccak256 } from 'ethereum-cryptography/keccak' import { keccak256 } from 'ethereum-cryptography/keccak'
import { getPublicKey, sign, utils } from 'ethereum-cryptography/secp256k1' 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 { compressPublicKey } from '../utils/compress-public-key'
import { generateUsername } from '../utils/generate-username' import { generateUsername } from '../utils/generate-username'
export class Account { import type { Client } from './client'
public privateKey: string import type { Community } from './community/community'
public publicKey: string
public chatKey: string
public username: string
constructor() { type MembershipStatus = 'none' | 'requested' | 'approved' | 'kicked' // TODO: add 'banned'
const privateKey = utils.randomPrivateKey()
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) const publicKey = getPublicKey(privateKey)
this.privateKey = bytesToHex(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. // 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 hash = keccak256(payload)
const [signature, recoverId] = await sign(hash, this.privateKey, { const [signature, recoverId] = await sign(hash, this.privateKey, {
recovered: true, recovered: true,
@ -31,4 +47,34 @@ export class Account {
return concatBytes(signature, new Uint8Array([recoverId])) 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
}
}
}
} }

View File

@ -14,12 +14,24 @@ import { Account } from './account'
import { ActivityCenter } from './activityCenter' import { ActivityCenter } from './activityCenter'
import { Community } from './community/community' import { Community } from './community/community'
import { handleWakuMessage } from './community/handle-waku-message' import { handleWakuMessage } from './community/handle-waku-message'
import { LocalStorage } from './storage'
import type { Storage } from './storage'
import type { Waku } from 'js-waku' import type { Waku } from 'js-waku'
const THROWAWAY_ACCOUNT_STORAGE_KEY = 'throwaway_account'
export interface ClientOptions { export interface ClientOptions {
/**
* Public key of a community to join.
*/
publicKey: string publicKey: string
environment?: 'production' | 'test' environment?: 'production' | 'test'
/**
* Custom storage for data persistance
* @default window.localStorage
*/
storage?: Storage
} }
class Client { class Client {
@ -40,9 +52,13 @@ class Client {
#wakuDisconnectionTimer: ReturnType<typeof setInterval> #wakuDisconnectionTimer: ReturnType<typeof setInterval>
public activityCenter: ActivityCenter public activityCenter: ActivityCenter
public account?: Account
public community: Community public community: Community
#account?: Account
#accountCallbacks = new Set<(account?: Account) => void>()
storage: Storage
constructor( constructor(
waku: Waku, waku: Waku,
wakuDisconnectionTimer: ReturnType<typeof setInterval>, wakuDisconnectionTimer: ReturnType<typeof setInterval>,
@ -53,11 +69,22 @@ class Client {
this.wakuMessages = new Set() this.wakuMessages = new Set()
this.#wakuDisconnectionTimer = wakuDisconnectionTimer this.#wakuDisconnectionTimer = wakuDisconnectionTimer
// Storage
this.storage = options.storage ?? new LocalStorage()
// Activity Center // Activity Center
this.activityCenter = new ActivityCenter(this) this.activityCenter = new ActivityCenter(this)
// Community // Community
this.community = new Community(this, options.publicKey) this.community = new Community(this, options.publicKey)
// Restore account if exists
const privateKey = this.storage.getItem<string>(
THROWAWAY_ACCOUNT_STORAGE_KEY
)
if (privateKey) {
this.#account = new Account(this, privateKey)
}
} }
static async start(options: ClientOptions) { static async start(options: ClientOptions) {
@ -110,18 +137,39 @@ class Client {
await this.waku.stop() await this.waku.stop()
} }
public createAccount = async (): Promise<Account> => { get account() {
this.account = new 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 return this.account
} }
// TODO?: should this exist public deleteAccount() {
// public deleteAccount = () => { this.account = undefined
// this.account = undefined }
// }
public onAccountChange(listener: (account?: Account) => void) {
this.#accountCallbacks.add(listener)
return () => {
this.#accountCallbacks.delete(listener)
}
}
public sendWakuMessage = async ( public sendWakuMessage = async (
type: keyof typeof ApplicationMetadataMessage.Type, type: keyof typeof ApplicationMetadataMessage.Type,
@ -133,11 +181,11 @@ class Client {
throw new Error('Waku not started') throw new Error('Waku not started')
} }
if (!this.account) { if (!this.#account) {
throw new Error('Account not created') throw new Error('Account not created')
} }
const signature = await this.account.sign(payload) const signature = await this.#account.sign(payload)
const message = ApplicationMetadataMessage.encode({ const message = ApplicationMetadataMessage.encode({
type: type as ApplicationMetadataMessage.Type, type: type as ApplicationMetadataMessage.Type,
@ -146,7 +194,7 @@ class Client {
}) })
const wakuMesage = await WakuMessage.fromBytes(message, contentTopic, { const wakuMesage = await WakuMessage.fromBytes(message, contentTopic, {
sigPrivKey: hexToBytes(this.account.privateKey), sigPrivKey: hexToBytes(this.#account.privateKey),
symKey, symKey,
}) })
@ -154,7 +202,7 @@ class Client {
} }
public handleWakuMessage = (wakuMessage: WakuMessage): void => { public handleWakuMessage = (wakuMessage: WakuMessage): void => {
handleWakuMessage(wakuMessage, this, this.community) handleWakuMessage(wakuMessage, this, this.community, this.#account)
} }
} }

View File

@ -63,10 +63,8 @@ export class Community {
throw new Error('Failed to intiliaze Community') throw new Error('Failed to intiliaze Community')
} }
this.description = description // Observe community description
this.observe() this.observe()
this.addMembers(this.description.members)
// Chats // Chats
await this.observeChatMessages(this.description.chats) await this.observeChatMessages(this.description.chats)
@ -235,6 +233,7 @@ export class Community {
// Community // Community
// state // state
this.description = description this.description = description
this.addMembers(this.description.members)
// callback // callback
this.#callbacks.forEach(callback => callback({ ...this.description })) this.#callbacks.forEach(callback => callback({ ...this.description }))

View File

@ -17,6 +17,7 @@ import { recoverPublicKey } from '../../utils/recover-public-key'
import { getChatUuid } from './get-chat-uuid' import { getChatUuid } from './get-chat-uuid'
import { mapChatMessage } from './map-chat-message' import { mapChatMessage } from './map-chat-message'
import type { Account } from '../account'
import type { Client } from '../client' import type { Client } from '../client'
import type { Community } from './community' import type { Community } from './community'
import type { WakuMessage } from 'js-waku' import type { WakuMessage } from 'js-waku'
@ -25,7 +26,8 @@ export function handleWakuMessage(
wakuMessage: WakuMessage, wakuMessage: WakuMessage,
// state // state
client: Client, client: Client,
community: Community community: Community,
account?: Account
): void { ): void {
// decode (layers) // decode (layers)
// validate // validate
@ -97,6 +99,8 @@ export function handleWakuMessage(
community.handleDescription(decodedPayload) community.handleDescription(decodedPayload)
community.setClock(BigInt(decodedPayload.clock)) community.setClock(BigInt(decodedPayload.clock))
account?.updateMembership(community)
break break
} }

View File

@ -0,0 +1,61 @@
type Unsubscribe = () => void
export interface Storage {
getItem: <T = string>(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<T>(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)
}
}
}

View File

@ -4,12 +4,14 @@ import { expect, test } from 'vitest'
import { Account } from '../client/account' import { Account } from '../client/account'
import { recoverPublicKey } from './recover-public-key' import { recoverPublicKey } from './recover-public-key'
import type { Client } from '../client/client'
import type { ApplicationMetadataMessage } from '../protos/application-metadata-message' import type { ApplicationMetadataMessage } from '../protos/application-metadata-message'
test('should recover public key', async () => { test('should recover public key', async () => {
const payload = utf8ToBytes('hello') const payload = utf8ToBytes('hello')
const account = new Account() // @fixme
const account = new Account({} as unknown as Client)
const signature = await account.sign(payload) const signature = await account.sign(payload)
expect(bytesToHex(recoverPublicKey(signature, payload))).toEqual( 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 () => { test('should not recover public key with different payload', async () => {
const payload = utf8ToBytes('1') const payload = utf8ToBytes('1')
const account = new Account() // @fixme
const account = new Account({} as unknown as Client)
const signature = await account.sign(payload) const signature = await account.sign(payload)
const payload2 = utf8ToBytes('2') const payload2 = utf8ToBytes('2')

View File

@ -1,15 +1,14 @@
import React from 'react' import React from 'react'
import { useMembers, useProtocol } from '../../../../protocol' import { useProtocol } from '../../../../protocol'
import { styled } from '../../../../styles/config' import { styled } from '../../../../styles/config'
import { Avatar, DialogTrigger, Text } from '../../../../system' import { Avatar, DialogTrigger, Text } from '../../../../system'
import { CommunityDialog } from './community-dialog' import { CommunityDialog } from './community-dialog'
export const CommunityInfo = () => { export const CommunityInfo = () => {
const { community } = useProtocol() const { client } = useProtocol()
const members = useMembers()
const { displayName, color } = community.identity! const { displayName, color } = client.community.description.identity!
return ( return (
<DialogTrigger> <DialogTrigger>
@ -18,7 +17,7 @@ export const CommunityInfo = () => {
<div> <div>
<Text>{displayName}</Text> <Text>{displayName}</Text>
<Text color="gray" size={12}> <Text color="gray" size={12}>
{members.length} members {client.community.members.length} members
</Text> </Text>
</div> </div>
</Button> </Button>

View File

@ -1,25 +1,14 @@
import React from 'react' import React from 'react'
// import { CreateProfileDialog } from '../../../../components/create-profile-dialog'
// import { useLocalStorage } from '../../../../hooks/use-local-storage'
import { useAccount } from '../../../../protocol' import { useAccount } from '../../../../protocol'
import { Button, Flex } from '../../../../system' import { Button, Flex } from '../../../../system'
// import { DialogTrigger } from '../../../../system/dialog'
import { Grid } from '../../../../system/grid' import { Grid } from '../../../../system/grid'
import { Heading } from '../../../../system/heading' 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 = () => { export const GetStarted = () => {
// const [throwawayProfile] = useLocalStorage('cipherIdentityt', null) const { account, createAccount } = useAccount()
// const handleSkip = () => { const membershipRequested = account?.membership === 'requested'
// // TODO: Add skip logic
// }
const { createAccount } = useAccount()
return ( return (
<Flex direction="column" align="center" gap={5} css={{ padding: '30px 0' }}> <Flex direction="column" align="center" gap={5} css={{ padding: '30px 0' }}>
@ -145,25 +134,11 @@ export const GetStarted = () => {
Want to jump into the discussion? Want to jump into the discussion?
</Heading> </Heading>
<Grid gap={3} align="center" justify="center"> <Grid gap={3} align="center" justify="center">
{/* <DialogTrigger> <Button onClick={createAccount} disabled={membershipRequested}>
<Button>Sync with Status profile</Button> {membershipRequested
<SyncStatusProfileDialog /> ? 'Membership Requested'
</DialogTrigger> */} : 'Use Throwaway Profile'}
</Button>
{/* <DialogTrigger>
<Button>Connect Wallet</Button>
<ConnectWalletDialog />
</DialogTrigger> */}
<Button onClick={createAccount}>Use Throwaway Profile</Button>
{/* <DialogTrigger>
<Button>Use Throwaway Profile</Button>
{account ? (
<ThrowawayProfileFoundDialog onSkip={handleSkip} />
) : (
<CreateProfileDialog />
)}
</DialogTrigger> */}
</Grid> </Grid>
</Flex> </Flex>
) )

View File

@ -1,55 +1,49 @@
import React from 'react' import React from 'react'
import { useAccount } from '../../../../protocol'
import { import {
Avatar, Avatar,
Dialog, Dialog,
EmojiHash, // EmojiHash,
EthAddress,
Flex, Flex,
Heading, Heading,
Text, Text,
} from '../../../../system' } from '../../../../system'
import type { Account } from '../../../../protocol'
interface Props { interface Props {
onSkip: () => void account: Account
} }
export const ThrowawayProfileFoundDialog = (props: Props) => { export const ThrowawayProfileFoundDialog = (props: Props) => {
const { onSkip } = props const { account } = props
const { account } = useAccount() const handleLoad = () => {
const handleLoadThrowawayProfile = () => {
// TODO: load throwaway profile // TODO: load throwaway profile
} }
if (!account) {
return null
}
return ( return (
<Dialog title="Throwaway Profile Found"> <Dialog title="Throwaway Profile Found">
<Dialog.Body gap="5"> <Dialog.Body gap="5">
<Flex direction="column" align="center" gap="2"> <Flex direction="column" align="center" gap="2">
<Avatar size={64} /> <Avatar size={64} name={account.username} />
<Heading weight="600">{account.username}</Heading> <Heading weight="600">{account.username}</Heading>
<Text color="gray">
Chatkey: 0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377 <EthAddress color="gray">{account.chatKey}</EthAddress>
</Text> {/* <Text color="gray">Chatkey: {account.chatKey}</Text> */}
<EmojiHash /> {/* <EmojiHash /> */}
</Flex> </Flex>
<Text> <Text>
Throwaway profile is found in your local {"browser's"} storage. Throwaway profile is found in your local storage.
<br /> <br />
Would you like to load it and use it? Would you like to use it?
</Text> </Text>
</Dialog.Body> </Dialog.Body>
<Dialog.Actions> <Dialog.Actions>
<Dialog.Action variant="outline" onClick={onSkip}> <Dialog.Cancel variant="outline">Skip</Dialog.Cancel>
Skip <Dialog.Action onClick={handleLoad}>
</Dialog.Action>
<Dialog.Action onClick={handleLoadThrowawayProfile}>
Load Throwaway Profile Load Throwaway Profile
</Dialog.Action> </Dialog.Action>
</Dialog.Actions> </Dialog.Actions>

View File

@ -21,7 +21,7 @@ export const MainSidebar = () => {
<CommunityInfo /> <CommunityInfo />
<Chats /> <Chats />
{!account && ( {account?.membership !== 'approved' && (
<> <>
<Separator /> <Separator />
<GetStarted /> <GetStarted />

View File

@ -1,7 +1,15 @@
import React from 'react' import React from 'react'
import { useAccount } from '../../protocol' 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' import type { Account } from '../../protocol'
@ -18,10 +26,12 @@ export const DisconnectDialog = (props: Props) => {
<Dialog.Body gap="5"> <Dialog.Body gap="5">
<Text>Do you want to disconnect your profile from this browser?</Text> <Text>Do you want to disconnect your profile from this browser?</Text>
<Flex direction="column" align="center" gap="2"> <Flex direction="column" align="center" gap="2">
<Avatar size={64} /> <Avatar size={64} name={account.username} />
<Heading weight="600">{account.username}</Heading> <Heading weight="600">{account.username}</Heading>
<Text color="gray">Chatkey: {account.chatKey}</Text> <Text color="gray">
<EmojiHash /> Chatkey: <EthAddress>{account.chatKey}</EthAddress>
</Text>
{/* <EmojiHash /> */}
</Flex> </Flex>
</Dialog.Body> </Dialog.Body>
<Dialog.Actions> <Dialog.Actions>

View File

@ -26,7 +26,7 @@ export const MemberItem = (props: Props) => {
/> />
<div> <div>
<Flex align="center" gap={1}> <Flex align="center" gap={1}>
<Text size="15" color="accent" truncate> <Text size="15" color="accent" truncate css={{ width: 184 }}>
{username} {username}
</Text> </Text>
{verified && ( {verified && (

View File

@ -14,12 +14,12 @@ export const UserItem = (props: Props) => {
const { account } = props const { account } = props
return ( return (
<Flex align="center" justify="between"> <Flex align="center" justify="between" gap={1}>
<Flex gap="2" align="center" css={{ height: 56 }}> <Flex gap="2" align="center" css={{ height: 56 }}>
<Avatar size={32} name={account.username} /> <Avatar size={32} name={account.username} />
<div> <div>
<Flex align="center" gap={1}> <Flex align="center" gap={1}>
<Text size="15" color="accent"> <Text size="15" color="accent" truncate css={{ width: 144 }}>
{account.username} {account.username}
</Text> </Text>
</Flex> </Flex>
@ -27,8 +27,6 @@ export const UserItem = (props: Props) => {
{account.chatKey} {account.chatKey}
</EthAddress> </EthAddress>
</div> </div>
</Flex>
<DialogTrigger> <DialogTrigger>
<DisconnectButton> <DisconnectButton>
<svg <svg
@ -51,6 +49,7 @@ export const UserItem = (props: Props) => {
<DisconnectDialog account={account} /> <DisconnectDialog account={account} />
</DialogTrigger> </DialogTrigger>
</Flex> </Flex>
</Flex>
) )
} }
@ -62,4 +61,5 @@ const DisconnectButton = styled('button', {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexShrink: 0,
}) })

View File

@ -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<T> = Dispatch<SetStateAction<T>>
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// 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<T>(readValue)
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = 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<T>(value: string | null): T | undefined {
try {
return value === 'undefined' ? undefined : JSON.parse(value ?? '')
} catch {
return undefined
}
}

View File

@ -19,8 +19,7 @@ type State = {
type Action = type Action =
| { type: 'INIT'; client: Client } | { type: 'INIT'; client: Client }
| { type: 'UPDATE_COMMUNITY'; community: Community['description'] } | { type: 'UPDATE_COMMUNITY'; community: Community['description'] }
| { type: 'SET_ACCOUNT'; account: Account } | { type: 'SET_ACCOUNT'; account: Account | undefined }
| { type: 'REMOVE_ACCOUNT' }
interface Props { interface Props {
options: ClientOptions options: ClientOptions
@ -35,6 +34,7 @@ const reducer = (state: State, action: Action): State => {
...state, ...state,
loading: false, loading: false,
client, client,
account: client.account,
community: client.community.description, community: client.community.description,
} }
} }
@ -44,9 +44,6 @@ const reducer = (state: State, action: Action): State => {
case 'SET_ACCOUNT': { case 'SET_ACCOUNT': {
return { ...state, account: action.account } return { ...state, account: action.account }
} }
case 'REMOVE_ACCOUNT': {
return { ...state, account: undefined }
}
} }
} }
@ -66,6 +63,7 @@ export const ProtocolProvider = (props: Props) => {
useEffect(() => { useEffect(() => {
const loadClient = async () => { const loadClient = async () => {
const client = await createClient(options) const client = await createClient(options)
dispatch({ type: 'INIT', client }) dispatch({ type: 'INIT', client })
} }
@ -76,9 +74,18 @@ export const ProtocolProvider = (props: Props) => {
useEffect(() => { useEffect(() => {
if (client) { if (client) {
return client.community.onChange(community => { const unsubscribe = [
client.onAccountChange(account => {
dispatch({ type: 'SET_ACCOUNT', account })
}),
client.community.onChange(community => {
dispatch({ type: 'UPDATE_COMMUNITY', community }) dispatch({ type: 'UPDATE_COMMUNITY', community })
}) }),
]
return () => {
unsubscribe.forEach(fn => fn())
}
} }
}, [client]) }, [client])

View File

@ -3,22 +3,14 @@ import { useProtocol } from './provider'
import type { Account } from '@status-im/js' import type { Account } from '@status-im/js'
export const useAccount = () => { export const useAccount = () => {
const { client, account, dispatch } = useProtocol() const { client, account } = useProtocol()
const createAccount = async () => { return {
const account = await client.createAccount() account,
dispatch({ type: 'SET_ACCOUNT', account }) createAccount: () => client.createAccount(),
// TODO: save account deleteAccount: () => client.deleteAccount(),
isMember: account ? client.community.isMember(account.chatKey) : false,
return account } as const
}
const deleteAccount = () => {
dispatch({ type: 'REMOVE_ACCOUNT' })
// TODO: remove from storage
}
return { account, createAccount, deleteAccount } as const
} }
export type { Account } export type { Account }

View File

@ -41,7 +41,7 @@ const Avatar = (props: Props) => {
size={size} size={size}
style={{ style={{
background: identiconRing, background: identiconRing,
padding: !identiconRing ? 0 : undefined, padding: identiconRing ? undefined : 0,
}} }}
> >
<Content style={{ background: color }}> <Content style={{ background: color }}>