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