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,29 +27,28 @@ export const UserItem = (props: Props) => {
|
|||
{account.chatKey}
|
||||
</EthAddress>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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 => {
|
||||
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])
|
||||
|
||||
|
|
|
@ -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