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:
parent
ca0d508f7f
commit
c39b84a1e5
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<typeof setInterval>
|
||||
|
||||
public activityCenter: ActivityCenter
|
||||
public account?: Account
|
||||
public community: Community
|
||||
|
||||
#account?: Account
|
||||
#accountCallbacks = new Set<(account?: Account) => void>()
|
||||
|
||||
storage: Storage
|
||||
|
||||
constructor(
|
||||
waku: Waku,
|
||||
wakuDisconnectionTimer: ReturnType<typeof setInterval>,
|
||||
|
@ -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<string>(
|
||||
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<Account> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
|
|
|
@ -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 (
|
||||
<DialogTrigger>
|
||||
|
@ -18,7 +17,7 @@ export const CommunityInfo = () => {
|
|||
<div>
|
||||
<Text>{displayName}</Text>
|
||||
<Text color="gray" size={12}>
|
||||
{members.length} members
|
||||
{client.community.members.length} members
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
|
|
@ -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 (
|
||||
<Flex direction="column" align="center" gap={5} css={{ padding: '30px 0' }}>
|
||||
|
@ -145,25 +134,11 @@ export const GetStarted = () => {
|
|||
Want to jump into the discussion?
|
||||
</Heading>
|
||||
<Grid gap={3} align="center" justify="center">
|
||||
{/* <DialogTrigger>
|
||||
<Button>Sync with Status profile</Button>
|
||||
<SyncStatusProfileDialog />
|
||||
</DialogTrigger> */}
|
||||
|
||||
{/* <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> */}
|
||||
<Button onClick={createAccount} disabled={membershipRequested}>
|
||||
{membershipRequested
|
||||
? 'Membership Requested'
|
||||
: 'Use Throwaway Profile'}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Flex>
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
<Dialog title="Throwaway Profile Found">
|
||||
<Dialog.Body gap="5">
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Avatar size={64} />
|
||||
<Avatar size={64} name={account.username} />
|
||||
<Heading weight="600">{account.username}</Heading>
|
||||
<Text color="gray">
|
||||
Chatkey: 0x63FaC9201494f0bd17B9892B9fae4d52fe3BD377
|
||||
</Text>
|
||||
<EmojiHash />
|
||||
|
||||
<EthAddress color="gray">{account.chatKey}</EthAddress>
|
||||
{/* <Text color="gray">Chatkey: {account.chatKey}</Text> */}
|
||||
{/* <EmojiHash /> */}
|
||||
</Flex>
|
||||
<Text>
|
||||
Throwaway profile is found in your local {"browser's"} storage.
|
||||
Throwaway profile is found in your local storage.
|
||||
<br />
|
||||
Would you like to load it and use it?
|
||||
Would you like to use it?
|
||||
</Text>
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Actions>
|
||||
<Dialog.Action variant="outline" onClick={onSkip}>
|
||||
Skip
|
||||
</Dialog.Action>
|
||||
<Dialog.Action onClick={handleLoadThrowawayProfile}>
|
||||
<Dialog.Cancel variant="outline">Skip</Dialog.Cancel>
|
||||
<Dialog.Action onClick={handleLoad}>
|
||||
Load Throwaway Profile
|
||||
</Dialog.Action>
|
||||
</Dialog.Actions>
|
||||
|
|
|
@ -21,7 +21,7 @@ export const MainSidebar = () => {
|
|||
<CommunityInfo />
|
||||
<Chats />
|
||||
|
||||
{!account && (
|
||||
{account?.membership !== 'approved' && (
|
||||
<>
|
||||
<Separator />
|
||||
<GetStarted />
|
||||
|
|
|
@ -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) => {
|
|||
<Dialog.Body gap="5">
|
||||
<Text>Do you want to disconnect your profile from this browser?</Text>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Avatar size={64} />
|
||||
<Avatar size={64} name={account.username} />
|
||||
<Heading weight="600">{account.username}</Heading>
|
||||
<Text color="gray">Chatkey: {account.chatKey}</Text>
|
||||
<EmojiHash />
|
||||
<Text color="gray">
|
||||
Chatkey: <EthAddress>{account.chatKey}</EthAddress>
|
||||
</Text>
|
||||
{/* <EmojiHash /> */}
|
||||
</Flex>
|
||||
</Dialog.Body>
|
||||
<Dialog.Actions>
|
||||
|
|
|
@ -26,7 +26,7 @@ export const MemberItem = (props: Props) => {
|
|||
/>
|
||||
<div>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text size="15" color="accent" truncate>
|
||||
<Text size="15" color="accent" truncate css={{ width: 184 }}>
|
||||
{username}
|
||||
</Text>
|
||||
{verified && (
|
||||
|
|
|
@ -14,12 +14,12 @@ export const UserItem = (props: Props) => {
|
|||
const { account } = props
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="between">
|
||||
<Flex align="center" justify="between" gap={1}>
|
||||
<Flex gap="2" align="center" css={{ height: 56 }}>
|
||||
<Avatar size={32} name={account.username} />
|
||||
<div>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text size="15" color="accent">
|
||||
<Text size="15" color="accent" truncate css={{ width: 144 }}>
|
||||
{account.username}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
@ -27,8 +27,6 @@ export const UserItem = (props: Props) => {
|
|||
{account.chatKey}
|
||||
</EthAddress>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<DialogTrigger>
|
||||
<DisconnectButton>
|
||||
<svg
|
||||
|
@ -51,6 +49,7 @@ export const UserItem = (props: Props) => {
|
|||
<DisconnectDialog account={account} />
|
||||
</DialogTrigger>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -62,4 +61,5 @@ const DisconnectButton = styled('button', {
|
|||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 => {
|
||||
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])
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -41,7 +41,7 @@ const Avatar = (props: Props) => {
|
|||
size={size}
|
||||
style={{
|
||||
background: identiconRing,
|
||||
padding: !identiconRing ? 0 : undefined,
|
||||
padding: identiconRing ? undefined : 0,
|
||||
}}
|
||||
>
|
||||
<Content style={{ background: color }}>
|
||||
|
|
Loading…
Reference in New Issue