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 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)

View File

@ -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
}
}
}
}

View File

@ -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)
}
}

View File

@ -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 }))

View File

@ -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
}

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 { 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')

View File

@ -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>

View File

@ -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>
)

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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 && (

View File

@ -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,
})

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: '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])

View File

@ -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 }

View File

@ -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 }}>