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
36322d5a41
commit
213ca26877
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }))
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 { 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')
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const MainSidebar = () => {
|
||||||
<CommunityInfo />
|
<CommunityInfo />
|
||||||
<Chats />
|
<Chats />
|
||||||
|
|
||||||
{!account && (
|
{account?.membership !== 'approved' && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<GetStarted />
|
<GetStarted />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -14,12 +14,12 @@ export const UserItem = (props: Props) => {
|
||||||
const { account } = props
|
const { account } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" justify="between">
|
<Flex align="center" justify="between" gap={1}>
|
||||||
<Flex gap="2" align="center" css={{ height: 56 }}>
|
<Flex gap="2" align="center" css={{ height: 56 }}>
|
||||||
<Avatar size={32} name={account.username} />
|
<Avatar size={32} name={account.username} />
|
||||||
<div>
|
<div>
|
||||||
<Flex align="center" gap={1}>
|
<Flex align="center" gap={1}>
|
||||||
<Text size="15" color="accent">
|
<Text size="15" color="accent" truncate css={{ width: 144 }}>
|
||||||
{account.username}
|
{account.username}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@ -27,8 +27,6 @@ export const UserItem = (props: Props) => {
|
||||||
{account.chatKey}
|
{account.chatKey}
|
||||||
</EthAddress>
|
</EthAddress>
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<DisconnectButton>
|
<DisconnectButton>
|
||||||
<svg
|
<svg
|
||||||
|
@ -51,6 +49,7 @@ export const UserItem = (props: Props) => {
|
||||||
<DisconnectDialog account={account} />
|
<DisconnectDialog account={account} />
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,4 +61,5 @@ const DisconnectButton = styled('button', {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 Action =
|
||||||
| { type: 'INIT'; client: Client }
|
| { type: 'INIT'; client: Client }
|
||||||
| { type: 'UPDATE_COMMUNITY'; community: Community['description'] }
|
| { type: 'UPDATE_COMMUNITY'; community: Community['description'] }
|
||||||
| { type: 'SET_ACCOUNT'; account: Account }
|
| { type: 'SET_ACCOUNT'; account: Account | undefined }
|
||||||
| { type: 'REMOVE_ACCOUNT' }
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
options: ClientOptions
|
options: ClientOptions
|
||||||
|
@ -35,6 +34,7 @@ const reducer = (state: State, action: Action): State => {
|
||||||
...state,
|
...state,
|
||||||
loading: false,
|
loading: false,
|
||||||
client,
|
client,
|
||||||
|
account: client.account,
|
||||||
community: client.community.description,
|
community: client.community.description,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,9 +44,6 @@ const reducer = (state: State, action: Action): State => {
|
||||||
case 'SET_ACCOUNT': {
|
case 'SET_ACCOUNT': {
|
||||||
return { ...state, account: action.account }
|
return { ...state, account: action.account }
|
||||||
}
|
}
|
||||||
case 'REMOVE_ACCOUNT': {
|
|
||||||
return { ...state, account: undefined }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,6 +63,7 @@ export const ProtocolProvider = (props: Props) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadClient = async () => {
|
const loadClient = async () => {
|
||||||
const client = await createClient(options)
|
const client = await createClient(options)
|
||||||
|
|
||||||
dispatch({ type: 'INIT', client })
|
dispatch({ type: 'INIT', client })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,9 +74,18 @@ export const ProtocolProvider = (props: Props) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client) {
|
if (client) {
|
||||||
return client.community.onChange(community => {
|
const unsubscribe = [
|
||||||
|
client.onAccountChange(account => {
|
||||||
|
dispatch({ type: 'SET_ACCOUNT', account })
|
||||||
|
}),
|
||||||
|
client.community.onChange(community => {
|
||||||
dispatch({ type: 'UPDATE_COMMUNITY', community })
|
dispatch({ type: 'UPDATE_COMMUNITY', community })
|
||||||
})
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe.forEach(fn => fn())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [client])
|
}, [client])
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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 }}>
|
||||||
|
|
Loading…
Reference in New Issue