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 ca0d508f7f
commit c39b84a1e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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,29 +27,28 @@ export const UserItem = (props: Props) => {
{account.chatKey} {account.chatKey}
</EthAddress> </EthAddress>
</div> </div>
<DialogTrigger>
<DisconnectButton>
<svg
width="20"
height="18"
viewBox="0 0 20 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.985 8.36269C16.363 8.36269 16.5523 7.90572 16.285 7.63846L14.7017 6.05509C14.4531 5.80658 14.4531 5.40365 14.7017 5.15514C14.9502 4.90662 15.3531 4.90662 15.6016 5.15514L18.9956 8.54908C19.2441 8.79759 19.2441 9.20051 18.9956 9.44903L15.6016 12.843C15.3531 13.0915 14.9502 13.0915 14.7017 12.843C14.4531 12.5945 14.4531 12.1915 14.7017 11.943L16.285 10.3596C16.5523 10.0924 16.363 9.63542 15.985 9.63542L7.51527 9.63542C7.16382 9.63542 6.87891 9.35051 6.87891 8.99905C6.87891 8.6476 7.16382 8.36269 7.51527 8.36269L15.985 8.36269Z"
fill="#4360DF"
/>
<path
d="M11.1218 3.90956C11.1218 2.73805 10.1721 1.78835 9.00059 1.78835H3.90968C2.73817 1.78835 1.78847 2.73805 1.78847 3.90956V14.0914C1.78847 15.2629 2.73817 16.2126 3.90968 16.2126H9.00059C10.1721 16.2126 11.1218 15.2629 11.1218 14.0914V11.3338C11.1218 10.9824 11.4067 10.6974 11.7582 10.6974C12.1096 10.6974 12.3945 10.9824 12.3945 11.3338V14.0914C12.3945 15.9658 10.875 17.4853 9.00059 17.4853H3.90968C2.03526 17.4853 0.515744 15.9658 0.515744 14.0914V3.90956C0.515744 2.03514 2.03526 0.515625 3.90968 0.515625H9.00059C10.875 0.515625 12.3945 2.03514 12.3945 3.90956V6.66714C12.3945 7.01859 12.1096 7.3035 11.7582 7.3035C11.4067 7.3035 11.1218 7.01859 11.1218 6.66714V3.90956Z"
fill="#4360DF"
/>
</svg>
</DisconnectButton>
<DisconnectDialog account={account} />
</DialogTrigger>
</Flex> </Flex>
<DialogTrigger>
<DisconnectButton>
<svg
width="20"
height="18"
viewBox="0 0 20 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.985 8.36269C16.363 8.36269 16.5523 7.90572 16.285 7.63846L14.7017 6.05509C14.4531 5.80658 14.4531 5.40365 14.7017 5.15514C14.9502 4.90662 15.3531 4.90662 15.6016 5.15514L18.9956 8.54908C19.2441 8.79759 19.2441 9.20051 18.9956 9.44903L15.6016 12.843C15.3531 13.0915 14.9502 13.0915 14.7017 12.843C14.4531 12.5945 14.4531 12.1915 14.7017 11.943L16.285 10.3596C16.5523 10.0924 16.363 9.63542 15.985 9.63542L7.51527 9.63542C7.16382 9.63542 6.87891 9.35051 6.87891 8.99905C6.87891 8.6476 7.16382 8.36269 7.51527 8.36269L15.985 8.36269Z"
fill="#4360DF"
/>
<path
d="M11.1218 3.90956C11.1218 2.73805 10.1721 1.78835 9.00059 1.78835H3.90968C2.73817 1.78835 1.78847 2.73805 1.78847 3.90956V14.0914C1.78847 15.2629 2.73817 16.2126 3.90968 16.2126H9.00059C10.1721 16.2126 11.1218 15.2629 11.1218 14.0914V11.3338C11.1218 10.9824 11.4067 10.6974 11.7582 10.6974C12.1096 10.6974 12.3945 10.9824 12.3945 11.3338V14.0914C12.3945 15.9658 10.875 17.4853 9.00059 17.4853H3.90968C2.03526 17.4853 0.515744 15.9658 0.515744 14.0914V3.90956C0.515744 2.03514 2.03526 0.515625 3.90968 0.515625H9.00059C10.875 0.515625 12.3945 2.03514 12.3945 3.90956V6.66714C12.3945 7.01859 12.1096 7.3035 11.7582 7.3035C11.4067 7.3035 11.1218 7.01859 11.1218 6.66714V3.90956Z"
fill="#4360DF"
/>
</svg>
</DisconnectButton>
<DisconnectDialog account={account} />
</DialogTrigger>
</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 = [
dispatch({ type: 'UPDATE_COMMUNITY', community }) client.onAccountChange(account => {
}) dispatch({ type: 'SET_ACCOUNT', account })
}),
client.community.onChange(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 }}>