diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0572cdcc19..f690e545e9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,11 +5,13 @@ on: branches: - master - release/* + - integration/* pull_request: branches: - master - release/* + - integration/* jobs: lint: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a374e99631..f632f2faa5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,11 +5,13 @@ on: branches: - master - release/* + - integration/* pull_request: branches: - master - release/* + - integration/* jobs: test: diff --git a/.gitignore b/.gitignore index aaa7274c44..5ce6dea7be 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .vscode node_modules dist +dev-user1 +dev-user2 releases prod .gitsecret/keys/random_seed diff --git a/docs/account_management.md b/docs/account_management.md index 0219a2c9ae..1d214ba046 100644 --- a/docs/account_management.md +++ b/docs/account_management.md @@ -36,7 +36,7 @@ Using the wallet, you can create a new account and address on the Fetch ledger: If you have an account on the Fetch network, for example having had one already on the Fetch wallet and want to access it again, have an account on another wallet (e.g. Cosmostation, Keplr, ...) and wish to bring it to the Fetch wallet, or having created an address using one of our tools (e.g. the [AEA framework](https://docs.fetch.ai/aea)), you can import it into the Fetch wallet: 1. On the [welcome page](#welcome-page), click **Import existing account**. -2. Enter your mnemonic seed (set of words) or private key (hexadecimal). +2. Enter your mnemonic seed (set of words) or private key (hexidecimal). !!! warning **KEEP IT SAFE!** Anyone with your mnemonic seed or private key can access your wallet and take your assets. @@ -82,7 +82,7 @@ To remove an account from your Fetch wallet: 4. Enter your wallet password. !!! warning - If you have not yet backed up your mnemonic seed, click on **Back-up account** and enter your password to view it.
+ If you have not yet backed up your mnemonic seed, click on **Back-up account** and enter you password to view it.
Then back it up safely. If you lose your mnemonic seed you will lose access to your account. 5. Click **Confirm** to remove the account from your wallet. diff --git a/package.json b/package.json index 0b3fb32093..554c2d7051 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "license": "Apache-2.0", "devDependencies": { "@octokit/core": "^3.5.1", + "@types/domhandler": "^2.4.2", "@types/jest": "^26.0.22", "@types/node": "^16.6.2", "@types/webpack": "^4.39.1", diff --git a/packages/background/package.json b/packages/background/package.json index a84660aaf9..9cec8f13fa 100644 --- a/packages/background/package.json +++ b/packages/background/package.json @@ -25,8 +25,11 @@ "@fetchai/blst-ts": "^0.3.1" }, "dependencies": { - "@cosmjs/launchpad": "^0.24.0-alpha.25", - "@cosmjs/proto-signing": "^0.24.0-alpha.25", + "@apollo/client": "^3.6.9", + "eciesjs": "^0.3.15", + "@cosmjs/launchpad": "^0.27.1", + "@cosmjs/encoding": "^0.28.13", + "@cosmjs/proto-signing": "^0.28.13", "@ethersproject/bytes": "^5.5.0", "@ethersproject/keccak256": "^5.5.0", "@ethersproject/wallet": "^5.5.0", diff --git a/packages/background/src/index.ts b/packages/background/src/index.ts index dc71c562c2..bce65e3e1b 100644 --- a/packages/background/src/index.ts +++ b/packages/background/src/index.ts @@ -16,6 +16,7 @@ import * as Tokens from "./tokens/internal"; import * as Interaction from "./interaction/internal"; import * as Permission from "./permission/internal"; import * as Umbral from "./umbral/internal"; +import * as Messaging from "./messaging/internal"; export * from "./persistent-memory"; export * from "./chains"; @@ -117,6 +118,9 @@ export function init( const umbralService = container.resolve(Umbral.UmbralService); Umbral.init(router, umbralService); + const messagingService = container.resolve(Messaging.MessagingService); + Messaging.init(router, messagingService); + const backgroundTxService = container.resolve( BackgroundTx.BackgroundTxService ); diff --git a/packages/background/src/messaging/constants.ts b/packages/background/src/messaging/constants.ts new file mode 100644 index 0000000000..a4f77b5c33 --- /dev/null +++ b/packages/background/src/messaging/constants.ts @@ -0,0 +1,2 @@ +export const ROUTE = "messaging"; +export const MESSAGE_CHANNEL_ID = "MESSAGING"; diff --git a/packages/background/src/messaging/handler.ts b/packages/background/src/messaging/handler.ts new file mode 100644 index 0000000000..abf7847692 --- /dev/null +++ b/packages/background/src/messaging/handler.ts @@ -0,0 +1,104 @@ +import { Env, Handler, InternalHandler, Message } from "@keplr-wallet/router"; +import { + DecryptMessagingMessage, + EncryptMessagingMessage, + GetMessagingPublicKey, + RegisterPublicKey, + SignMessagingPayload, +} from "./messages"; +import { MessagingService } from "./service"; + +export const getHandler: (service: MessagingService) => Handler = (service) => { + return (env: Env, msg: Message) => { + switch (msg.constructor) { + case GetMessagingPublicKey: + return handleGetMessagingPublicKey(service)( + env, + msg as GetMessagingPublicKey + ); + + case RegisterPublicKey: + return handleRegisterPublicKey(service)(env, msg as RegisterPublicKey); + + case EncryptMessagingMessage: + return handleEncryptMessagingMessage(service)( + env, + msg as EncryptMessagingMessage + ); + + case DecryptMessagingMessage: + return handleDecryptMessagingMessage(service)( + env, + msg as DecryptMessagingMessage + ); + + case SignMessagingPayload: + return handleSignMessagingPayload(service)( + env, + msg as SignMessagingPayload + ); + default: + throw new Error("Unknown msg type"); + } + }; +}; + +const handleGetMessagingPublicKey: ( + service: MessagingService +) => InternalHandler = (service) => { + return async (env, msg) => { + const pubKey = await service.getPublicKey( + env, + msg.chainId, + msg.targetAddress, + msg.accessToken + ); + return pubKey; + }; +}; + +const handleRegisterPublicKey: ( + service: MessagingService +) => InternalHandler = (service) => { + return async (env, msg) => { + return await service.registerPublicKey( + env, + msg.chainId, + msg.address, + msg.accessToken, + msg.privacySetting + ); + }; +}; + +const handleEncryptMessagingMessage: ( + service: MessagingService +) => InternalHandler = (service) => { + return async (env, msg) => { + console.log("msg encryptMessage", msg); + + return await service.encryptMessage( + env, + msg.chainId, + msg.targetAddress, + msg.message, + msg.accessToken + ); + }; +}; + +const handleDecryptMessagingMessage: ( + service: MessagingService +) => InternalHandler = (service) => { + return async (env, msg) => { + return await service.decryptMessage(env, msg.chainId, msg.cipherText); + }; +}; + +const handleSignMessagingPayload: ( + service: MessagingService +) => InternalHandler = (service) => { + return async (env, msg) => { + return await service.sign(env, msg.chainId, msg.payload); + }; +}; diff --git a/packages/background/src/messaging/index.ts b/packages/background/src/messaging/index.ts new file mode 100644 index 0000000000..5bcbc16ac4 --- /dev/null +++ b/packages/background/src/messaging/index.ts @@ -0,0 +1,2 @@ +export * from "./service"; +export * from "./messages"; diff --git a/packages/background/src/messaging/init.ts b/packages/background/src/messaging/init.ts new file mode 100644 index 0000000000..baf2d1fa0d --- /dev/null +++ b/packages/background/src/messaging/init.ts @@ -0,0 +1,21 @@ +import { Router } from "@keplr-wallet/router"; +import { ROUTE } from "./constants"; +import { getHandler } from "./handler"; +import { MessagingService } from "./service"; +import { + DecryptMessagingMessage, + EncryptMessagingMessage, + GetMessagingPublicKey, + RegisterPublicKey, + SignMessagingPayload, +} from "./messages"; + +export function init(router: Router, service: MessagingService): void { + router.registerMessage(GetMessagingPublicKey); + router.registerMessage(RegisterPublicKey); + router.registerMessage(EncryptMessagingMessage); + router.registerMessage(DecryptMessagingMessage); + router.registerMessage(SignMessagingPayload); + + router.addHandler(ROUTE, getHandler(service)); +} diff --git a/packages/background/src/messaging/internal.ts b/packages/background/src/messaging/internal.ts new file mode 100644 index 0000000000..85b858331f --- /dev/null +++ b/packages/background/src/messaging/internal.ts @@ -0,0 +1,2 @@ +export * from "./service"; +export * from "./init"; diff --git a/packages/background/src/messaging/memorandum-client.ts b/packages/background/src/messaging/memorandum-client.ts new file mode 100644 index 0000000000..1664cdd6ad --- /dev/null +++ b/packages/background/src/messaging/memorandum-client.ts @@ -0,0 +1,95 @@ +import { + ApolloClient, + InMemoryCache, + gql, + DefaultOptions, +} from "@apollo/client"; +import { PrivacySetting, PubKey } from "./types"; + +const defaultOptions: DefaultOptions = { + watchQuery: { + fetchPolicy: "no-cache", + errorPolicy: "ignore", + }, + query: { + fetchPolicy: "no-cache", + errorPolicy: "all", + }, +}; + +const client = new ApolloClient({ + uri: "https://messaging-server.sandbox-london-b.fetch-ai.com/graphql", + cache: new InMemoryCache(), + defaultOptions, +}); + +export const registerPubKey = async ( + accessToken: string, + messagingPubKey: string, + walletAddress: string, + privacySetting: PrivacySetting, + channelId: string +): Promise => { + try { + await client.mutate({ + mutation: gql(`mutation Mutation($publicKeyDetails: InputPublicKey!) { + updatePublicKey(publicKeyDetails: $publicKeyDetails) { + publicKey + privacySetting + } + }`), + variables: { + publicKeyDetails: { + publicKey: messagingPubKey, + address: walletAddress, + channelId, + privacySetting, + }, + }, + context: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + }); + } catch (e) { + console.log(e); + } +}; + +export const getPubKey = async ( + accessToken: string, + targetAddress: string, + channelId: string +): Promise => { + try { + const { data } = await client.query({ + query: gql(`query Query($address: String!, $channelId: ChannelId!) { + publicKey(address: $address, channelId: $channelId) { + publicKey + privacySetting + } + }`), + variables: { + address: targetAddress, + channelId, + }, + context: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + }); + + return { + publicKey: data.publicKey && data.publicKey.publicKey, + privacySetting: data.publicKey && data.publicKey.privacySetting, + }; + } catch (e) { + console.log(e); + return { + publicKey: undefined, + privacySetting: undefined, + }; + } +}; diff --git a/packages/background/src/messaging/messages.ts b/packages/background/src/messaging/messages.ts new file mode 100644 index 0000000000..cfa1acef52 --- /dev/null +++ b/packages/background/src/messaging/messages.ts @@ -0,0 +1,143 @@ +import { Message } from "@keplr-wallet/router"; +import { ROUTE } from "./constants"; +import { PrivacySetting, PubKey } from "./types"; + +export class GetMessagingPublicKey extends Message { + public static type() { + return "get-messaging-public-key"; + } + + constructor( + public readonly chainId: string, + public readonly accessToken: string, + public readonly targetAddress: string | null + ) { + super(); + } + + validateBasic(): void { + if (!this.chainId) { + throw new Error("Chain id is empty"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return GetMessagingPublicKey.type(); + } +} + +export class RegisterPublicKey extends Message { + public static type() { + return "register-public-key"; + } + + constructor( + public readonly chainId: string, + public readonly accessToken: string, + public readonly address: string, + public readonly privacySetting: PrivacySetting + ) { + super(); + } + + validateBasic(): void { + if (!this.chainId) { + throw new Error("Chain id is empty"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return RegisterPublicKey.type(); + } +} + +export class EncryptMessagingMessage extends Message { + public static type() { + return "encrypt-messaging-message"; + } + + constructor( + public readonly chainId: string, + public readonly targetAddress: string, + public readonly message: string, + public readonly accessToken: string + ) { + super(); + } + + validateBasic(): void { + if (!this.chainId) { + throw new Error("Chain id is empty"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return EncryptMessagingMessage.type(); + } +} + +export class DecryptMessagingMessage extends Message { + public static type() { + return "decrypt-messaging-message"; + } + + constructor( + public readonly chainId: string, + public readonly cipherText: string + ) { + super(); + } + + validateBasic(): void { + if (!this.chainId) { + throw new Error("Chain id is empty"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return DecryptMessagingMessage.type(); + } +} + +export class SignMessagingPayload extends Message { + public static type() { + return "sign-messaging-payload"; + } + + constructor( + public readonly chainId: string, + public readonly payload: string + ) { + super(); + } + + validateBasic(): void { + if (!this.chainId) { + throw new Error("Chain id is empty"); + } + } + + route(): string { + return ROUTE; + } + + type(): string { + return SignMessagingPayload.type(); + } +} diff --git a/packages/background/src/messaging/service.ts b/packages/background/src/messaging/service.ts new file mode 100644 index 0000000000..717a712ea1 --- /dev/null +++ b/packages/background/src/messaging/service.ts @@ -0,0 +1,238 @@ +import { delay, inject, singleton } from "tsyringe"; +import { KeyRingService } from "../keyring"; +import { Env } from "@keplr-wallet/router"; +import { Hash, PrivKeySecp256k1 } from "@keplr-wallet/crypto"; +import { decrypt, encrypt, PrivateKey } from "eciesjs"; +import { fromBase64, fromHex, toBase64, toHex } from "@cosmjs/encoding"; +import { getPubKey, registerPubKey } from "./memorandum-client"; +import { MESSAGE_CHANNEL_ID } from "./constants"; +import { PrivacySetting, PubKey } from "./types"; + +@singleton() +export class MessagingService { + // map of target address vs target public key + // assumption: chainId incorporated since each network will have a different + // bech32 prefix + private _publicKeyCache = new Map(); + + constructor( + @inject(delay(() => KeyRingService)) + protected readonly keyRingService: KeyRingService + ) {} + + /** + * Lookup the public key associated with the messaging service + * + * @param env The extension environment + * @param chainId The target chain id + * @param targetAddress Get the public key for the specified address (if specified), otherwise return senders public key + * @param accessToken accessToken token to authenticate in memorandum service + * @returns The base64 encoded compressed public key + */ + public async getPublicKey( + env: Env, + chainId: string, + targetAddress: string | null, + accessToken: string + ): Promise { + if (targetAddress === null) { + const sk = await this.getPrivateKey(env, chainId); + const privateKey = new PrivateKey(Buffer.from(sk)); + return { + publicKey: toHex(privateKey.publicKey.compressed), + privacySetting: undefined, + }; + } else { + return await this.lookupPublicKey(accessToken, targetAddress); + } + } + + /** + * Register public key in memorandum as messaging key + * + * @param env The extension environment + * @param chainId The target chain id + * @param address Wallet bech32 address + * @param accessToken accessToken token to authenticate in memorandum service + * @returns The hex encoded compressed public key + */ + public async registerPublicKey( + env: Env, + chainId: string, + address: string, + accessToken: string, + privacySetting: PrivacySetting + ): Promise { + const sk = await this.getPrivateKey(env, chainId); + const privateKey = new PrivateKey(Buffer.from(sk)); + const pubKey = toHex(privateKey.publicKey.compressed); + + const regPubKey = await this.lookupPublicKey(accessToken, address); + if ( + !regPubKey.privacySetting || + !regPubKey.publicKey || + regPubKey.privacySetting !== privacySetting + ) { + await registerPubKey( + accessToken, + pubKey, + address, + privacySetting, + MESSAGE_CHANNEL_ID + ); + this._publicKeyCache.set(address, { + publicKey: pubKey, + privacySetting, + }); + } + + return { + publicKey: pubKey, + privacySetting, + }; + } + + /** + * Decrypt a message that is targeted at the private key associated with the + * messaging service + * + * @param env The extention environment + * @param chainId The target chain id + * @param cipherText The base64 encoded cipher text to be processed + * @returns The base64 encoded clear text from the message + */ + async decryptMessage( + env: Env, + chainId: string, + cipherText: string + ): Promise { + const sk = await this.getPrivateKey(env, chainId); + const rawCipherText = Buffer.from(fromBase64(cipherText)); + + return toBase64(decrypt(Buffer.from(sk), rawCipherText)); + } + + /** + * Encrypt a message using the messaging protocol key + * + * @param _env The extension environment + * @param _chainId The target chain id + * @param targetAddress The target address + * @param message The base64 encoded message to be processed + * @param accessToken accessToken token to authenticate in memorandum service + * @returns The base64 encoded cipher text of the message + */ + async encryptMessage( + _env: Env, + _chainId: string, + targetAddress: string, + message: string, + accessToken: string + ): Promise { + const rawMessage = Buffer.from(fromBase64(message)); + + const targetPublicKey = await this.lookupPublicKey( + accessToken, + targetAddress + ); + + if (!targetPublicKey.publicKey) + throw new Error("Target pub key not registered"); + const rawTargetPublicKey = Buffer.from(fromHex(targetPublicKey.publicKey)); + + // encrypt the message + return toBase64(encrypt(rawTargetPublicKey, rawMessage)); + } + + /** + * Sign the payload + * + * @param env The extension environment + * @param chainId The target chain id + * @param payload The base64 encoded payload that should be signed + * @returns The base64 encoded signature for the payload + */ + async sign(env: Env, chainId: string, payload: string): Promise { + const sk = await this.getPrivateKey(env, chainId); + const privateKey = new PrivKeySecp256k1(sk); + + // decode the payload into raw bytes + const rawPayload = fromBase64(payload); + + // sign the payload + const rawSignature = privateKey.sign(rawPayload); + + // convert and return the signature + return toBase64(rawSignature); + } + + /** + * Lookup the public key for a target address + * + * Will first check the local cache, if not present will attempt to lookup the + * information from the memorandum service + * @param accessToken accessToken token to authenticate in memorandum service + * @param targetAddress The target address to find the public key for + * @returns The base64 encoded public key for the target address if successful + * @protected + */ + protected async lookupPublicKey( + accessToken: string, + targetAddress: string + ): Promise { + // Step 1. Query the cache + let targetPublicKey = this._publicKeyCache.get(targetAddress); + + if (targetPublicKey?.publicKey && targetPublicKey?.privacySetting) { + return targetPublicKey; + } + + // Step 2. Cache miss, fetch the public key from the memorandum service and + // update the cache + targetPublicKey = await getPubKey( + accessToken, + targetAddress, + MESSAGE_CHANNEL_ID + ); + if (!targetPublicKey) { + return { + publicKey: undefined, + privacySetting: undefined, + }; + } + + this._publicKeyCache.set(targetAddress, targetPublicKey); + + return targetPublicKey; + } + + /** + * Builds a private key from the signature of the current keychain + * + * @param env The environment of the extension + * @param chainId The target chain id + * @returns The generated private key object + * @private + */ + private async getPrivateKey(env: Env, chainId: string): Promise { + return Hash.sha256( + Buffer.from( + await this.keyRingService.sign( + env, + chainId, + Buffer.from( + JSON.stringify({ + account_number: 0, + chain_id: chainId, + fee: [], + memo: + "Create Messaging Signing Secret encryption key. Only approve requests by Keplr.", + msgs: [], + sequence: 0, + }) + ) + ) + ) + ); + } +} diff --git a/packages/background/src/messaging/types.ts b/packages/background/src/messaging/types.ts new file mode 100644 index 0000000000..91856b9d28 --- /dev/null +++ b/packages/background/src/messaging/types.ts @@ -0,0 +1,10 @@ +export enum PrivacySetting { + Contacts = "CONTACTS", + Everybody = "EVERYBODY", + Nobody = "NOBODY", +} + +export interface PubKey { + publicKey: string | undefined; + privacySetting: PrivacySetting | undefined; +} diff --git a/packages/extension/.eslintignore b/packages/extension/.eslintignore index 5c03475abb..1db6be6852 100644 --- a/packages/extension/.eslintignore +++ b/packages/extension/.eslintignore @@ -1,4 +1,5 @@ **/*.scss +**/*.gif **/*.html **/*.json **/*.png diff --git a/packages/extension/.prettierignore b/packages/extension/.prettierignore index 8ba97ec4ad..c6cb2de44c 100644 --- a/packages/extension/.prettierignore +++ b/packages/extension/.prettierignore @@ -1,5 +1,6 @@ @@ -1,11 +0,0 @@ **/*.png +**/*.gif **/*.svg **/*.ttf **/*.secret diff --git a/packages/extension/package.json b/packages/extension/package.json index 6aa68ac414..30fc06190f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -15,10 +15,18 @@ "version": "npx zx ./scripts/lerna-lifecyle-version.mjs" }, "dependencies": { - "@cosmjs/encoding": "^0.24.0-alpha.25", - "@cosmjs/launchpad": "^0.24.0-alpha.25", - "@cosmjs/proto-signing": "^0.24.0-alpha.25", + "@apollo/client": "^3.6.9", + "@cosmjs/amino": "^0.28.13", + "@cosmjs/encoding": "^0.28.13", + "@cosmjs/launchpad": "^0.27.1", + "@cosmjs/proto-signing": "^0.28.13", + "@cosmjs/stargate": "^0.28.13", + "@fetchai/eth-migration": "^0.8.8", + "@fetchai/wallet-types": "^0.1.1", "@fortawesome/fontawesome-free": "^5.11.2", + "@graphql-codegen/typescript": "^2.7.3", + "@graphql-codegen/typescript-operations": "^2.5.3", + "@graphql-codegen/typescript-react-apollo": "^3.3.3", "@keplr-wallet/analytics": "^0.9.14", "@keplr-wallet/background": "^0.9.15", "@keplr-wallet/common": "^0.9.10", @@ -33,8 +41,8 @@ "@keplr-wallet/stores": "^0.9.15", "@keplr-wallet/types": "^0.9.12", "@keplr-wallet/unit": "^0.9.12", - "@fetchai/wallet-types": "^0.1.1", - "@fetchai/eth-migration": "^0.8.8", + "@metamask/jazzicon": "^2.0.0", + "@reduxjs/toolkit": "^1.8.5", "@walletconnect/client": "^1.6.4", "aes-js": "^3.1.2", "amplitude-js": "^8.16.0", @@ -44,8 +52,12 @@ "buffer": "^6.0.3", "chart.js": "^2.9.3", "classnames": "^2.2.6", + "date-fns": "^2.29.3", "delay": "^4.4.0", + "eciesjs": "^0.3.15", "framer-motion": "^1.6.14", + "graphql": "^16.6.0", + "graphql-ws": "^5.10.1", "joi": "^17.2.1", "js-yaml": "^4.0.0", "mobx": "^6.1.7", @@ -59,14 +71,21 @@ "react-chartjs-2": "^2.10.0", "react-dom": "^16.14.0", "react-hook-form": "^3.29.3", + "react-html-parser": "^2.0.2", + "react-infinite-scroll-component": "^6.1.0", "react-intl": "^3.9.3", "react-modal": "^3.11.1", + "react-redux": "^8.0.2", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", + "react-textarea-autosize": "^8.3.4", "reactstrap": "^8.4.1", + "redux-persist": "^6.0.0", "scrypt-js": "^3.0.1", "secretjs": "^0.17.0", + "semantic-ui-react": "^2.1.3", "sha.js": "^2.4.11", + "type-graphql": "^1.1.1", "utility-types": "^3.10.0", "webextension-polyfill": "^0.7.0" }, @@ -77,8 +96,7 @@ "@types/classnames": "^2.2.9", "@types/firefox-webext-browser": "^70.0.1", "@types/js-yaml": "^4.0.0", - "@types/react": "^16.14.4", - "@types/react-dom": "^16.9.11", + "@types/react-html-parser": "^2.0.2", "@types/react-modal": "^3.10.0", "@types/react-router-dom": "^5.1.2", "@types/reactstrap": "^8.4.2", @@ -94,10 +112,10 @@ "sass-loader": "^7.3.1", "serialize-javascript": ">=3.1.0", "style-loader": "^1.0.0", + "wasm-loader": "^1.3.0", "webpack": "^4.39.2", "webpack-bundle-analyzer": "^3.9.0", "webpack-cli": "^3.3.7", - "wasm-loader": "^1.3.0", "webpack-dev-server": "^3.8.2", "write-file-webpack-plugin": "^4.5.1" } diff --git a/packages/extension/src/chatStore/index.ts b/packages/extension/src/chatStore/index.ts new file mode 100644 index 0000000000..1bc4280275 --- /dev/null +++ b/packages/extension/src/chatStore/index.ts @@ -0,0 +1,31 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { persistReducer } from "redux-persist"; +// import { composeWithDevTools } from 'redux-devtools-extension'; +import localStorage from "redux-persist/lib/storage"; +import { messageStore } from "./messages-slice"; +import { userStore } from "./user-slice"; + +const messagesConfig = { + key: "messages", + storage: localStorage, +}; + +const userConfig = { + key: "user", + storage: localStorage, +}; + +const customizedMiddleware = (getDefaultMiddleware: any) => + getDefaultMiddleware({ + serializableCheck: false, + }); +const persistedMessages = persistReducer(messagesConfig, messageStore); +const persistedUserDetails = persistReducer(userConfig, userStore); + +export const store = configureStore({ + reducer: { + messages: persistedMessages, + user: persistedUserDetails, + }, + middleware: customizedMiddleware, +}); diff --git a/packages/extension/src/chatStore/messages-slice.ts b/packages/extension/src/chatStore/messages-slice.ts new file mode 100644 index 0000000000..4599777fb2 --- /dev/null +++ b/packages/extension/src/chatStore/messages-slice.ts @@ -0,0 +1,100 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { Message } from "../graphQL/messages-queries"; + +export interface MessageMap { + [key: string]: Message; +} + +interface ContactState { + messageList: MessageMap; + lastMessage?: Message; + pubKey?: string; +} + +interface MessagesState { + [key: string]: ContactState; +} + +interface BlockedAddressState { + [key: string]: boolean; +} + +interface PubKey { + contact: string; + value: string; +} + +interface State { + chat: MessagesState; + blockedAddress: BlockedAddressState; + errorMessage?: { type: string; message: string; level: number }; +} + +const initialState: State = { chat: {}, blockedAddress: {} }; + +export const messagesSlice = createSlice({ + name: "messages", + initialState, + reducers: { + addMessageList: (state, action) => { + state.chat = action.payload; + state.errorMessage = { type: "", message: "", level: 0 }; + }, + updateAuthorMessages: (state: any, action: PayloadAction) => { + const { sender, id } = action.payload; + state.chat[sender].messages[id] = action.payload; + state.chat[sender].lastMessage = action.payload; + }, + updateSenderMessages: (state: any, action: PayloadAction) => { + const { target, id } = action.payload; + if (!state.chat[target]) { + state.chat[target] = { + messages: {}, + lastMessage: {}, + }; + } + state.chat[target].messages[id] = action.payload; + state.chat[target].lastMessage = action.payload; + }, + setAuthorPubKey: (state, action: PayloadAction) => { + const { contact, value } = action.payload; + state.chat[contact].pubKey = value; + }, + setBlockedList: (state, action) => { + const blockedList = action.payload; + state.blockedAddress = {}; + blockedList.map(({ blockedAddress }: { blockedAddress: string }) => { + state.blockedAddress[blockedAddress] = true; + }); + }, + setBlockedUser: (state, action) => { + const { blockedAddress } = action.payload; + state.blockedAddress[blockedAddress] = true; + }, + setUnblockedUser: (state, action) => { + const { blockedAddress } = action.payload; + state.blockedAddress[blockedAddress] = false; + }, + setMessageError: (state, action) => { + state.errorMessage = action.payload; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { + setMessageError, + addMessageList, + updateAuthorMessages, + updateSenderMessages, + setAuthorPubKey, + setBlockedList, + setBlockedUser, + setUnblockedUser, +} = messagesSlice.actions; + +export const userMessages = (state: any) => state.messages.chat; +export const userMessagesError = (state: any) => state.messages.errorMessage; +export const userBlockedAddresses = (state: any) => + state.messages.blockedAddress; +export const messageStore = messagesSlice.reducer; diff --git a/packages/extension/src/chatStore/user-slice.ts b/packages/extension/src/chatStore/user-slice.ts new file mode 100644 index 0000000000..4c75492c39 --- /dev/null +++ b/packages/extension/src/chatStore/user-slice.ts @@ -0,0 +1,44 @@ +import { createSlice } from "@reduxjs/toolkit"; + +export const userSlice = createSlice({ + name: "user", + initialState: { + notifications: [], + accessToken: "", + messagingPubKey: { + publicKey: null, + privacySetting: null, + }, + }, + reducers: { + resetUser: (state, _action) => { + state.notifications = []; + state.messagingPubKey = { + publicKey: null, + privacySetting: null, + }; + state.accessToken = ""; + }, + setNotifications: (state, action) => { + state.notifications = action.payload; + }, + setMessagingPubKey: (state, action) => { + state.messagingPubKey = action.payload; + }, + setAccessToken: (state, action) => { + state.accessToken = action.payload; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { + resetUser, + setMessagingPubKey, + setAccessToken, + setNotifications, +} = userSlice.actions; + +export const userDetails = (state: { user: any }) => state.user; + +export const userStore = userSlice.reducer; diff --git a/packages/extension/src/components/chat-error-popup/index.tsx b/packages/extension/src/components/chat-error-popup/index.tsx new file mode 100644 index 0000000000..71d5f66a1d --- /dev/null +++ b/packages/extension/src/components/chat-error-popup/index.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from "react"; +import style from "./style.module.scss"; +import { useSelector } from "react-redux"; +import { userMessagesError } from "../../chatStore/messages-slice"; + +export const ChatErrorPopup = () => { + const errorMessage = useSelector(userMessagesError); + const [confirmAction, setConfirmAction] = useState(false); + + useEffect(() => { + setConfirmAction(true); + }, [errorMessage]); + + const handleOk = () => { + setConfirmAction(false); + }; + + return errorMessage?.message?.length && confirmAction ? ( + <> +
+
+

Error

+
+ {errorMessage.message} +
+ {errorMessage.level < 2 && ( +
+ +
+ )} +
+ + ) : ( + <> + ); +}; diff --git a/packages/extension/src/components/chat-error-popup/style.module.scss b/packages/extension/src/components/chat-error-popup/style.module.scss new file mode 100644 index 0000000000..b91d6e0586 --- /dev/null +++ b/packages/extension/src/components/chat-error-popup/style.module.scss @@ -0,0 +1,100 @@ +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.popup { + width: 310px; + z-index: 2; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + top: 30%; + text-align: center; + // display: flex; + // flex-direction: column; + // padding: 0px 20px; + h4 { + // border-bottom: solid #DEE2E6 2px; + padding: 0 20px 10px 20px; + color: #525f7f; + margin: 10px 0 0 0; + font-size: 18px; + text-transform: capitalize; + } + + .textContainer { + padding: 0 15px; + font-size: 14px; + color: #808da0; + text-align: center; + label { + margin-left: 10px; + } + } + + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + border-bottom: solid #dee2e6 2px; + align-items: center; + + img { + width: 8px; + height: 8px; + margin-right: 20px; + cursor: pointer; + } + } +} + +.buttonContainer { + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 0 15px; + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } +} +.contactsContainer { + .displayText { + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 10px; + margin-bottom: 10px; + color: #525f7f; + } + .buttons { + display: flex; + justify-content: space-evenly; + align-items: center; + margin: 0 auto; + } + button { + border: 2px solid #3b82f6; + padding: 7px 18px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} diff --git a/packages/extension/src/components/chat-loader/index.tsx b/packages/extension/src/components/chat-loader/index.tsx new file mode 100644 index 0000000000..7a95d85cb4 --- /dev/null +++ b/packages/extension/src/components/chat-loader/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import loadingChatGif from "../../public/assets/chat-loading.gif"; + +export const ChatLoader = ({ message }: { message: string }) => { + return ( +
+ +
+ {message} +
+ ); +}; diff --git a/packages/extension/src/components/chat/store.tsx b/packages/extension/src/components/chat/store.tsx new file mode 100644 index 0000000000..34398bab5f --- /dev/null +++ b/packages/extension/src/components/chat/store.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from "react"; +import { Provider } from "react-redux"; +import { store } from "../../chatStore"; + +export const ChatStoreProvider: FunctionComponent = ({ children }) => { + return {children}; +}; diff --git a/packages/extension/src/components/chatMessage/index.tsx b/packages/extension/src/components/chatMessage/index.tsx new file mode 100644 index 0000000000..a54ce106d5 --- /dev/null +++ b/packages/extension/src/components/chatMessage/index.tsx @@ -0,0 +1,78 @@ +import classnames from "classnames"; +import React, { useEffect, useState } from "react"; +import { Container } from "reactstrap"; +import deliveredIcon from "../../public/assets/icon/delivered.png"; +import { decryptMessage } from "../../utils/decrypt-message"; +import style from "./style.module.scss"; +import { isToday, isYesterday, format } from "date-fns"; + +const formatTime = (timestamp: number): string => { + const date = new Date(timestamp); + return format(date, "p"); +}; + +export const ChatMessage = ({ + chainId, + message, + isSender, + timestamp, + showDate, +}: { + chainId: string; + isSender: boolean; + message: string; + timestamp: number; + showDate: boolean; +}) => { + const [decryptedMessage, setDecryptedMessage] = useState(""); + + useEffect(() => { + decryptMessage(chainId, message, isSender) + .then((message) => { + setDecryptedMessage(message); + }) + .catch((e) => { + setDecryptedMessage(e.message); + }); + }, [chainId, isSender, message]); + + const getDate = (timestamp: number): string => { + const d = new Date(timestamp); + if (isToday(d)) { + return "Today"; + } + if (isYesterday(d)) { + return "Yesterday"; + } + return format(d, "dd MMMM yyyy"); + }; + + return ( + <> +
+ {" "} + {showDate ? ( + {getDate(timestamp)} + ) : null} +
+
+ + {!decryptedMessage ? ( + + ) : ( +
{decryptedMessage}
+ )} +
+ {formatTime(timestamp)} + {isSender && } +
+
+
+ + ); +}; diff --git a/packages/extension/src/components/chatMessage/style.module.scss b/packages/extension/src/components/chatMessage/style.module.scss new file mode 100644 index 0000000000..3c313c835b --- /dev/null +++ b/packages/extension/src/components/chatMessage/style.module.scss @@ -0,0 +1,54 @@ +.messageBox { + border-radius: 0px 16px 16px 16px; + max-width: 300px; + padding-left: 8px; + padding-top: 8px; + width: fit-content; + padding-bottom: 8px; + box-shadow: 0px 1px 3px rgba(50, 50, 93, 0.15); + background-color: #ffffff; + padding-right: 8px; + margin-top: 10px; + font-weight: 400; + line-height: 17px; + color: #525f7f; + font-size: 15px; + margin: 4px 4px; +} + +.senderBox { + border-radius: 16px 0px 16px 16px; + background-color: #d0def5; +} +.currentDate { + color: #525f7f; + margin: 0 auto; + max-width: 200px; + padding: 4px; + border-radius: 10px; +} +.currentDateContainer { + text-align: center; +} + +.senderAlign { + display: flex; + justify-content: end; +} + +.receiverAlign { + display: flex; + justify-content: start; +} + +.message { + padding-right: 30px; + padding-bottom: 3px; + overflow-wrap: break-word; +} + +.timestamp { + display: flex; + align-items: flex-end; + justify-content: end; +} diff --git a/packages/extension/src/components/form/address-input.tsx b/packages/extension/src/components/form/address-input.tsx index b363d5dedb..1532fd1de9 100644 --- a/packages/extension/src/components/form/address-input.tsx +++ b/packages/extension/src/components/form/address-input.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useMemo, useState } from "react"; +import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; import { FormGroup, Label, @@ -39,6 +39,7 @@ export interface AddressInputProps { disableAddressBook?: boolean; disabled?: boolean; + value: string; } export const AddressInput: FunctionComponent = observer( @@ -50,21 +51,24 @@ export const AddressInput: FunctionComponent = observer( label, disableAddressBook, disabled = false, + value, }) => { const intl = useIntl(); - const [isAddressBookOpen, setIsAddressBookOpen] = useState(false); - const [inputId] = useState(() => { const bytes = new Uint8Array(4); crypto.getRandomValues(bytes); return `input-${Buffer.from(bytes).toString("hex")}`; }); - const isENSAddress = ObservableEnsFetcher.isValidENS( + const isENSAddress = ObservableEnsFetcher?.isValidENS( recipientConfig.rawRecipient ); - + useEffect(() => { + if (value) { + recipientConfig.setRawRecipient(value); + } + }, [recipientConfig, value]); const error = recipientConfig.getError(); const errorText: string | undefined = useMemo(() => { if (error) { @@ -105,6 +109,10 @@ export const AddressInput: FunctionComponent = observer( }, }; + const handleSearchInputChange = (e: any) => { + e.preventDefault(); + recipientConfig.setRawRecipient(e?.target?.value); + }; return ( = observer( )} value={recipientConfig.rawRecipient} onChange={(e) => { - recipientConfig.setRawRecipient(e.target.value); - e.preventDefault(); + handleSearchInputChange(e); }} autoComplete="off" disabled={disabled} diff --git a/packages/extension/src/components/switch-user/index.tsx b/packages/extension/src/components/switch-user/index.tsx new file mode 100644 index 0000000000..01e600efac --- /dev/null +++ b/packages/extension/src/components/switch-user/index.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { FunctionComponent } from "react"; +import { useHistory } from "react-router"; + +export const SwitchUser: FunctionComponent = () => { + const history = useHistory(); + + return ( +
+
{ + e.preventDefault(); + + history.push("/setting/set-keyring"); + }} + > +