From 9c78b41b270cb8bc8add2d77547f153c52912232 Mon Sep 17 00:00:00 2001 From: imvinay84 <84082477+imvinay84@users.noreply.github.com> Date: Fri, 10 Feb 2023 10:35:08 +0530 Subject: [PATCH] feat: Group chat (#196) Co-authored-by: jaswinder.khartri@fetch.ai <jaswinder.khartri@fetch.ai> Co-authored-by: jas2212 <93518923+jas2212@users.noreply.github.com> --- packages/extension/src/@types/chat.ts | 132 +++++ packages/extension/src/bootstrap.tsx | 6 +- .../chat-actions-dropdown/index.tsx | 101 ++++ .../chat-actions-dropdown}/style.module.scss | 0 .../chat-actions-popup/alert-popup.tsx | 50 ++ .../chat-actions-popup}/block-user-popup.tsx | 2 +- .../chat-actions-popup}/delete-chat-popup.tsx | 0 .../delete-group-popup.tsx} | 35 +- .../components/chat-actions-popup/index.tsx | 67 +++ .../chat-actions-popup/style.module.scss | 301 ++++++++++ .../unblock-user-popup.tsx | 8 +- .../src/components/chat-error-popup/index.tsx | 7 +- .../src/components/chat-loader/index.tsx | 2 +- .../src/components/chat-member/index.tsx | 118 ++++ .../components/chat-member/style.module.scss | 371 +++++++++++++ .../{chatMessage => chat-message}/index.tsx | 14 +- .../style.module.scss | 1 + .../src/components/chat-option/index.tsx | 15 + .../src/components/chat/chat-init-popup.tsx | 10 +- .../src/components/chat/chat-search-input.tsx | 13 +- .../src/components/chat/deactivated-chat.tsx | 4 +- .../extension/src/components/chat/store.tsx | 2 +- .../components/form/address-input.module.scss | 4 + .../src/components/form/address-input.tsx | 5 +- .../components/form/coin-input.module.scss | 4 + .../src/components/form/coin-input.tsx | 5 +- .../form/fee-buttons/fee-buttons.module.scss | 4 + .../form/fee-buttons/fee-buttons.tsx | 7 +- .../group-chat-actions-dropdown/index.tsx | 56 ++ .../style.module.scss | 309 +++++++++++ .../components/group-chat-message/index.tsx | 128 +++++ .../group-chat-message/style.module.scss | 80 +++ .../src/components/group-chat-popup/index.tsx | 81 +++ .../group-chat-popup/style.module.scss | 301 ++++++++++ .../components/tooltip/tooltip.module.scss | 3 +- packages/extension/src/graphQL/client.ts | 4 +- packages/extension/src/graphQL/groups-api.ts | 164 ++++++ .../extension/src/graphQL/groups-queries.ts | 60 ++ .../extension/src/graphQL/messages-api.ts | 112 +++- .../extension/src/graphQL/messages-queries.ts | 36 +- .../extension/src/graphQL/recieve-messages.ts | 23 +- packages/extension/src/index.tsx | 60 +- packages/extension/src/languages/en.json | 1 + .../src/layouts/bottom-nav/index.tsx | 18 +- .../extension/src/layouts/bottom-nav/tab.tsx | 4 +- .../src/layouts/header/chain-list.tsx | 10 +- .../extension/src/layouts/header/index.tsx | 4 +- .../src/pages/access/basic-access.tsx | 4 +- .../src/pages/access/viewing-key.tsx | 4 +- .../extension/src/pages/activity/index.tsx | 4 +- .../src/pages/chain/suggest/index.tsx | 6 +- .../chats-view-section.tsx | 185 ++++-- .../{chatSection => chat-section}/index.tsx | 40 +- .../new-user-section.tsx | 2 +- .../src/pages/chat-section/style.module.scss | 302 ++++++++++ .../username-section.tsx | 28 +- .../src/pages/chat/chat-group-history.tsx | 206 +++++++ .../src/pages/chat/chat-group-user.tsx | 120 ++++ .../extension/src/pages/chat/chat-user.tsx | 167 ++++++ packages/extension/src/pages/chat/index.tsx | 39 +- .../src/pages/chat/style.module.scss | 23 +- packages/extension/src/pages/chat/users.tsx | 334 ----------- .../src/pages/chatSection/actions-popup.tsx | 26 - .../pages/chatSection/chat-actions-popup.tsx | 73 --- .../src/pages/group-chat/add-member/index.tsx | 413 ++++++++++++++ .../group-chat/add-member/style.module.scss | 131 +++++ .../chat-section/chats-view-section.tsx | 340 ++++++++++++ .../pages/group-chat/chat-section/index.tsx | 190 +++++++ .../group-chat/chat-section/style.module.scss | 354 ++++++++++++ .../chat-section/username-section.tsx | 80 +++ .../group-chat/create-group-chat/index.tsx | 232 ++++++++ .../create-group-chat/style.module.scss | 126 +++++ .../pages/group-chat/edit-member/index.tsx | 525 ++++++++++++++++++ .../group-chat/edit-member/style.module.scss | 183 ++++++ .../pages/group-chat/review-details/index.tsx | 373 +++++++++++++ .../review-details/style.module.scss | 154 +++++ .../src/pages/ibc-transfer/index.tsx | 6 +- packages/extension/src/pages/ledger/grant.tsx | 23 +- packages/extension/src/pages/lock/index.tsx | 10 +- .../src/pages/main/account.module.scss | 10 + packages/extension/src/pages/main/account.tsx | 4 +- packages/extension/src/pages/main/asset.tsx | 12 +- .../src/pages/main/bip44-select-modal.tsx | 2 +- packages/extension/src/pages/main/deposit.tsx | 2 +- packages/extension/src/pages/main/index.tsx | 6 +- .../extension/src/pages/main/menu.module.scss | 5 +- packages/extension/src/pages/main/stake.tsx | 2 +- packages/extension/src/pages/main/token.tsx | 6 +- .../extension/src/pages/main/tx-button.tsx | 14 +- packages/extension/src/pages/more/index.tsx | 4 +- .../extension/src/pages/newchat/new-chat.tsx | 87 ++- .../src/pages/newchat/style.module.scss | 23 +- .../src/pages/register/advanced-bip44.tsx | 2 +- .../extension/src/pages/register/index.tsx | 6 +- .../src/pages/register/ledger/index.tsx | 2 +- .../migration/metamask-privatekey.tsx | 2 +- .../pages/register/mnemonic/new-mnemonic.tsx | 2 +- .../register/mnemonic/recover-mnemonic.tsx | 2 +- packages/extension/src/pages/send/index.tsx | 6 +- .../address-book/add-address-modal.tsx | 4 +- .../src/pages/setting/address-book/index.tsx | 6 +- .../src/pages/setting/chat/block/index.tsx | 12 +- .../src/pages/setting/chat/index.tsx | 14 +- .../src/pages/setting/chat/privacy/index.tsx | 17 +- .../pages/setting/chat/readRecipt/index.tsx | 15 +- .../src/pages/setting/clear/index.tsx | 4 +- .../src/pages/setting/clear/warning-view.tsx | 2 +- .../setting/connections/basic-access.tsx | 6 +- .../pages/setting/connections/viewing-key.tsx | 6 +- .../src/pages/setting/credit/index.tsx | 2 +- .../pages/setting/export-to-mobile/index.tsx | 10 +- .../src/pages/setting/export/index.tsx | 4 +- .../src/pages/setting/export/warning-view.tsx | 2 +- .../src/pages/setting/fiat/index.tsx | 2 +- .../extension/src/pages/setting/index.tsx | 4 +- .../src/pages/setting/keyring/change/name.tsx | 4 +- .../src/pages/setting/keyring/index.tsx | 12 +- .../src/pages/setting/language/index.tsx | 2 +- .../src/pages/setting/page-button.tsx | 2 +- .../src/pages/setting/token/add/index.tsx | 8 +- .../src/pages/setting/token/manage/index.tsx | 6 +- .../extension/src/pages/sign/details-tab.tsx | 2 +- packages/extension/src/pages/sign/index.tsx | 2 +- .../extension/src/public/assets/group710.svg | 4 + .../extension/src/public/assets/toggle.svg | 4 + .../src/{chatStore => stores/chats}/index.ts | 8 + .../chats}/messages-slice.ts | 103 ++-- .../src/stores/chats/new-group-slice.ts | 39 ++ .../{chatStore => stores/chats}/user-slice.ts | 0 packages/extension/src/utils/decrypt-group.ts | 38 ++ packages/extension/src/utils/encrypt-group.ts | 96 +++- packages/extension/src/utils/format.ts | 10 + packages/extension/src/utils/group-events.ts | 75 +++ packages/extension/src/utils/index.ts | 57 ++ packages/extension/src/utils/symmetric-key.ts | 91 +++ packages/extension/tsconfig.json | 11 + packages/extension/webpack.config.js | 15 +- 137 files changed, 7399 insertions(+), 944 deletions(-) create mode 100644 packages/extension/src/@types/chat.ts create mode 100644 packages/extension/src/components/chat-actions-dropdown/index.tsx rename packages/extension/src/{pages/chatSection => components/chat-actions-dropdown}/style.module.scss (100%) create mode 100644 packages/extension/src/components/chat-actions-popup/alert-popup.tsx rename packages/extension/src/{pages/chatSection => components/chat-actions-popup}/block-user-popup.tsx (96%) rename packages/extension/src/{pages/chatSection => components/chat-actions-popup}/delete-chat-popup.tsx (100%) rename packages/extension/src/{pages/setting/chat/block/unblock-user-popup.tsx => components/chat-actions-popup/delete-group-popup.tsx} (58%) create mode 100644 packages/extension/src/components/chat-actions-popup/index.tsx create mode 100644 packages/extension/src/components/chat-actions-popup/style.module.scss rename packages/extension/src/{pages/chatSection => components/chat-actions-popup}/unblock-user-popup.tsx (87%) create mode 100644 packages/extension/src/components/chat-member/index.tsx create mode 100644 packages/extension/src/components/chat-member/style.module.scss rename packages/extension/src/components/{chatMessage => chat-message}/index.tsx (84%) rename packages/extension/src/components/{chatMessage => chat-message}/style.module.scss (97%) create mode 100644 packages/extension/src/components/chat-option/index.tsx create mode 100644 packages/extension/src/components/group-chat-actions-dropdown/index.tsx create mode 100644 packages/extension/src/components/group-chat-actions-dropdown/style.module.scss create mode 100644 packages/extension/src/components/group-chat-message/index.tsx create mode 100644 packages/extension/src/components/group-chat-message/style.module.scss create mode 100644 packages/extension/src/components/group-chat-popup/index.tsx create mode 100644 packages/extension/src/components/group-chat-popup/style.module.scss create mode 100644 packages/extension/src/graphQL/groups-api.ts create mode 100644 packages/extension/src/graphQL/groups-queries.ts rename packages/extension/src/pages/{chatSection => chat-section}/chats-view-section.tsx (67%) rename packages/extension/src/pages/{chatSection => chat-section}/index.tsx (78%) rename packages/extension/src/pages/{chatSection => chat-section}/new-user-section.tsx (95%) create mode 100644 packages/extension/src/pages/chat-section/style.module.scss rename packages/extension/src/pages/{chatSection => chat-section}/username-section.tsx (74%) create mode 100644 packages/extension/src/pages/chat/chat-group-history.tsx create mode 100644 packages/extension/src/pages/chat/chat-group-user.tsx create mode 100644 packages/extension/src/pages/chat/chat-user.tsx delete mode 100644 packages/extension/src/pages/chat/users.tsx delete mode 100644 packages/extension/src/pages/chatSection/actions-popup.tsx delete mode 100644 packages/extension/src/pages/chatSection/chat-actions-popup.tsx create mode 100644 packages/extension/src/pages/group-chat/add-member/index.tsx create mode 100644 packages/extension/src/pages/group-chat/add-member/style.module.scss create mode 100644 packages/extension/src/pages/group-chat/chat-section/chats-view-section.tsx create mode 100644 packages/extension/src/pages/group-chat/chat-section/index.tsx create mode 100644 packages/extension/src/pages/group-chat/chat-section/style.module.scss create mode 100644 packages/extension/src/pages/group-chat/chat-section/username-section.tsx create mode 100644 packages/extension/src/pages/group-chat/create-group-chat/index.tsx create mode 100644 packages/extension/src/pages/group-chat/create-group-chat/style.module.scss create mode 100644 packages/extension/src/pages/group-chat/edit-member/index.tsx create mode 100644 packages/extension/src/pages/group-chat/edit-member/style.module.scss create mode 100644 packages/extension/src/pages/group-chat/review-details/index.tsx create mode 100644 packages/extension/src/pages/group-chat/review-details/style.module.scss create mode 100644 packages/extension/src/public/assets/group710.svg create mode 100644 packages/extension/src/public/assets/toggle.svg rename packages/extension/src/{chatStore => stores/chats}/index.ts (78%) rename packages/extension/src/{chatStore => stores/chats}/messages-slice.ts (72%) create mode 100644 packages/extension/src/stores/chats/new-group-slice.ts rename packages/extension/src/{chatStore => stores/chats}/user-slice.ts (100%) create mode 100644 packages/extension/src/utils/group-events.ts create mode 100644 packages/extension/src/utils/symmetric-key.ts diff --git a/packages/extension/src/@types/chat.ts b/packages/extension/src/@types/chat.ts new file mode 100644 index 0000000000..5f16d82807 --- /dev/null +++ b/packages/extension/src/@types/chat.ts @@ -0,0 +1,132 @@ +// Params Type Definitions + +export interface NewGroupDetails { + isEditGroup: boolean; + group: GroupDetails; +} +export interface GroupDetails { + contents: string; + description: string; + groupId: string; + members: GroupMembers[]; + name: string; + onlyAdminMessages: boolean; +} + +export interface GroupMembers { + address: string; + pubKey: string; + encryptedSymmetricKey: string; + isAdmin: boolean; +} + +export interface GroupMessagePayload { + message: string; + type: string; +} + +export interface PublicKeyDetails { + address: string; + channelId: string; + privacySetting: string; + publicKey: string; +} + +export interface NewMessageUpdate { + type: string; + message: Message; +} + +// Graphql Type Definitions +export interface Message { + id: string; + sender: string; + target: string; + contents: string; + groupId: string; + expiryTimestamp: string; + commitTimestamp: string; +} + +export interface GroupAddress { + address: string; + pubKey: string; + lastSeenTimestamp: string; + groupLastSeenTimestamp: string; + encryptedSymmetricKey: string; + isAdmin: boolean; + removedAt: Date; +} + +export interface Group { + id: string; // groupID + name: string; // contactAddress + isDm: boolean; + addresses: GroupAddress[]; + lastMessageContents: string; + lastMessageSender: string; + lastMessageTimestamp: string; + lastSeenTimestamp: string; + description?: string; + createdAt: string; + removedAt: Date; +} + +export interface Pagination { + page: number; + pageCount: number; + total: number; + lastPage: number; +} + +//Redux Selectors Type Definitions +export interface Messages { + [key: string]: Message; +} + +export interface Chat { + contactAddress: string; + messages: Messages; + pubKey?: string; + pagination: Pagination; +} + +//key is group ID +export interface Chats { + [key: string]: Chat; +} + +export interface BlockedAddressState { + [key: string]: boolean; +} + +export interface Groups { + [contactAddress: string]: Group; +} + +export interface NameAddress { + [key: string]: string; +} + +export enum GroupChatOptions { + groupInfo, + muteGroup, + leaveGroup, + deleteGroup, + chatSettings, +} + +export enum GroupChatMemberOptions { + addToAddressBook, + viewInAddressBook, + messageMember, + removeMember, + removeAdminStatus, + makeAdminStatus, + dissmisPopup, +} + +export enum CommonPopupOptions { + cancel, + ok, +} diff --git a/packages/extension/src/bootstrap.tsx b/packages/extension/src/bootstrap.tsx index d664ac5325..dd2edaf216 100644 --- a/packages/extension/src/bootstrap.tsx +++ b/packages/extension/src/bootstrap.tsx @@ -1,14 +1,14 @@ import React, { Suspense } from "react"; import ReactDOM from "react-dom"; -import { Banner } from "./components/banner"; +import { Banner } from "@components/banner"; const Application = React.lazy(() => import("./index")); const LoadingScreen: React.FC = () => { return ( <Banner - icon={require("./public/assets/temp-icon.svg")} - logo={require("./public/assets/logo-temp.png")} + icon={require("@assets/temp-icon.svg")} + logo={require("@assets/logo-temp.png")} /> ); }; diff --git a/packages/extension/src/components/chat-actions-dropdown/index.tsx b/packages/extension/src/components/chat-actions-dropdown/index.tsx new file mode 100644 index 0000000000..93f7389ba6 --- /dev/null +++ b/packages/extension/src/components/chat-actions-dropdown/index.tsx @@ -0,0 +1,101 @@ +import amplitude from "amplitude-js"; +import React from "react"; +import { useHistory } from "react-router"; +import style from "./style.module.scss"; + +export const ChatActionsDropdown = ({ + added, + blocked, + showDropdown, + handleClick, +}: { + added: boolean; + blocked: boolean; + showDropdown: boolean; + handleClick: (data: string) => void; +}) => { + return ( + <> + {showDropdown && ( + <div className={style.dropdown}> + {added ? <ViewContactOption /> : <AddContactOption />} + {blocked ? ( + <UnblockOption handleClick={handleClick} /> + ) : ( + <BlockOption handleClick={handleClick} /> + )} + {/* <div onClick={() => handleClick("delete")}>Delete chat</div> */} + </div> + )} + </> + ); +}; + +const ViewContactOption = () => { + const history = useHistory(); + return ( + <div + onClick={() => { + amplitude.getInstance().logEvent("Address book viewed", {}); + history.push("/setting/address-book"); + }} + > + View in address book + </div> + ); +}; + +const AddContactOption = () => { + const history = useHistory(); + const userName = history.location.pathname.split("/")[2]; + return ( + <div + onClick={() => { + amplitude.getInstance().logEvent("Add to address click", {}); + history.push({ + pathname: "/setting/address-book", + state: { + openModal: true, + addressInputValue: userName, + }, + }); + }} + > + Add to address book + </div> + ); +}; + +const BlockOption = ({ + handleClick, +}: { + handleClick: (data: string) => void; +}) => { + return ( + <div + onClick={() => { + amplitude.getInstance().logEvent("Block click", {}); + handleClick("block"); + }} + > + Block contact + </div> + ); +}; + +const UnblockOption = ({ + handleClick, +}: { + handleClick: (data: string) => void; +}) => { + return ( + <div + onClick={() => { + amplitude.getInstance().logEvent("Unblock click", {}); + handleClick("unblock"); + }} + > + Unblock contact + </div> + ); +}; diff --git a/packages/extension/src/pages/chatSection/style.module.scss b/packages/extension/src/components/chat-actions-dropdown/style.module.scss similarity index 100% rename from packages/extension/src/pages/chatSection/style.module.scss rename to packages/extension/src/components/chat-actions-dropdown/style.module.scss diff --git a/packages/extension/src/components/chat-actions-popup/alert-popup.tsx b/packages/extension/src/components/chat-actions-popup/alert-popup.tsx new file mode 100644 index 0000000000..f01fe02c01 --- /dev/null +++ b/packages/extension/src/components/chat-actions-popup/alert-popup.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import style from "./style.module.scss"; +import { CommonPopupOptions } from "@chatTypes"; +export const AlertPopup = ({ + heading, + description, + firstButtonTitle, + secondButtonTitle, + processing, + onClick, +}: { + setConfirmAction: React.Dispatch<React.SetStateAction<boolean>>; + heading: string; + description: string; + firstButtonTitle: string; + secondButtonTitle: string; + processing?: boolean; + onClick: (option: CommonPopupOptions) => void; +}) => { + return ( + <> + <div className={style.overlay} /> + <div className={style.popup}> + <h4>{heading}</h4> + <section> + <p style={{ whiteSpace: "pre-wrap" }} className={style.textContainer}> + {description} + </p> + </section> + <div className={style.buttonContainer}> + <button + type="button" + disabled={processing} + onClick={() => onClick(CommonPopupOptions.cancel)} + > + {firstButtonTitle} + </button> + <button + type="button" + className={style.btn} + disabled={processing} + onClick={() => onClick(CommonPopupOptions.ok)} + > + {secondButtonTitle} + </button> + </div> + </div> + </> + ); +}; diff --git a/packages/extension/src/pages/chatSection/block-user-popup.tsx b/packages/extension/src/components/chat-actions-popup/block-user-popup.tsx similarity index 96% rename from packages/extension/src/pages/chatSection/block-user-popup.tsx rename to packages/extension/src/components/chat-actions-popup/block-user-popup.tsx index 14176cd6da..28744be28d 100644 --- a/packages/extension/src/pages/chatSection/block-user-popup.tsx +++ b/packages/extension/src/components/chat-actions-popup/block-user-popup.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { useHistory } from "react-router"; -import { blockUser } from "../../graphQL/messages-api"; +import { blockUser } from "@graphQL/messages-api"; import style from "./style.module.scss"; export const BlockUserPopup = ({ diff --git a/packages/extension/src/pages/chatSection/delete-chat-popup.tsx b/packages/extension/src/components/chat-actions-popup/delete-chat-popup.tsx similarity index 100% rename from packages/extension/src/pages/chatSection/delete-chat-popup.tsx rename to packages/extension/src/components/chat-actions-popup/delete-chat-popup.tsx diff --git a/packages/extension/src/pages/setting/chat/block/unblock-user-popup.tsx b/packages/extension/src/components/chat-actions-popup/delete-group-popup.tsx similarity index 58% rename from packages/extension/src/pages/setting/chat/block/unblock-user-popup.tsx rename to packages/extension/src/components/chat-actions-popup/delete-group-popup.tsx index c58ffb49e0..edf336333d 100644 --- a/packages/extension/src/pages/setting/chat/block/unblock-user-popup.tsx +++ b/packages/extension/src/components/chat-actions-popup/delete-group-popup.tsx @@ -1,26 +1,23 @@ +import { deleteGroup } from "@graphQL/groups-api"; +import amplitude from "amplitude-js"; import React, { useState } from "react"; -import { unblockUser } from "../../../../graphQL/messages-api"; +import { useHistory } from "react-router"; import style from "./style.module.scss"; -export const UnblockUserPopup = ({ - userName, +export const DeleteGroupPopup = ({ setConfirmAction, }: { - userName: string; setConfirmAction: React.Dispatch<React.SetStateAction<boolean>>; }) => { const [processing, setProcessing] = useState(false); - - const handleBlock = async () => { + const history = useHistory(); + const handleDelete = async () => { setProcessing(true); - try { - await unblockUser(userName); - } catch (e) { - console.log(e); - } finally { - setProcessing(false); - setConfirmAction(false); - } + const groupId = history.location.pathname.split("/")[3]; + deleteGroup(groupId); + setConfirmAction(false); + amplitude.getInstance().logEvent("Delete group click", {}); + history.push("/chat"); }; const handleCancel = () => { @@ -31,11 +28,11 @@ export const UnblockUserPopup = ({ <> <div className={style.overlay} /> <div className={style.popup}> - <h4>Unblock User</h4> + <h4>Delete Group</h4> <section> <p className={style.textContainer}> - This contact will not be able to send you messages. The contact will - not be notified. + You will lose all your messages in this group. This action cannot be + undone </p> </section> <div className={style.buttonContainer}> @@ -45,10 +42,10 @@ export const UnblockUserPopup = ({ <button type="button" className={style.btn} - onClick={handleBlock} + onClick={handleDelete} disabled={processing} > - Unblock + Delete </button> </div> </div> diff --git a/packages/extension/src/components/chat-actions-popup/index.tsx b/packages/extension/src/components/chat-actions-popup/index.tsx new file mode 100644 index 0000000000..4bc692be48 --- /dev/null +++ b/packages/extension/src/components/chat-actions-popup/index.tsx @@ -0,0 +1,67 @@ +import { CommonPopupOptions } from "@chatTypes"; +import React, { useState } from "react"; +import { useHistory } from "react-router"; +import { AlertPopup } from "./alert-popup"; +import { BlockUserPopup } from "./block-user-popup"; +import { DeleteChatPopup } from "./delete-chat-popup"; +import { DeleteGroupPopup } from "./delete-group-popup"; +import { UnblockUserPopup } from "./unblock-user-popup"; + +export const ChatActionsPopup = ({ + action, + setConfirmAction, + handleAction, +}: { + action: string; + setConfirmAction: React.Dispatch<React.SetStateAction<boolean>>; + handleAction?: () => void; +}) => { + const [processing, setProcessing] = useState(false); + const history = useHistory(); + /// Target address for one to one chat + const targetAddress = history.location.pathname.split("/")[2]; + + const handleLeaveGroup = async () => { + setProcessing(true); + if (handleAction) handleAction(); + }; + + return ( + <> + {action === "block" && ( + <BlockUserPopup setConfirmAction={setConfirmAction} /> + )} + {action === "unblock" && ( + <UnblockUserPopup + setConfirmAction={setConfirmAction} + userName={targetAddress} + /> + )} + {action === "delete" && ( + <DeleteChatPopup setConfirmAction={setConfirmAction} /> + )} + {action === "deleteGroup" && ( + <DeleteGroupPopup setConfirmAction={setConfirmAction} /> + )} + {action === "leaveGroup" && ( + <AlertPopup + setConfirmAction={setConfirmAction} + heading={"Leave Group Chat?"} + description={ + "You won’t receive further messages from this group. \nThe group will be notified that you have left." + } + firstButtonTitle="Cancel" + secondButtonTitle="Leave" + processing={processing} + onClick={(action: CommonPopupOptions) => { + if (action === CommonPopupOptions.ok) { + handleLeaveGroup(); + } else { + setConfirmAction(false); + } + }} + /> + )} + </> + ); +}; diff --git a/packages/extension/src/components/chat-actions-popup/style.module.scss b/packages/extension/src/components/chat-actions-popup/style.module.scss new file mode 100644 index 0000000000..4ce143b947 --- /dev/null +++ b/packages/extension/src/components/chat-actions-popup/style.module.scss @@ -0,0 +1,301 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: center; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + cursor: pointer; + top: 30%; + z-index: 2; + // 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; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} diff --git a/packages/extension/src/pages/chatSection/unblock-user-popup.tsx b/packages/extension/src/components/chat-actions-popup/unblock-user-popup.tsx similarity index 87% rename from packages/extension/src/pages/chatSection/unblock-user-popup.tsx rename to packages/extension/src/components/chat-actions-popup/unblock-user-popup.tsx index ed1b7a4115..619cc11ca4 100644 --- a/packages/extension/src/pages/chatSection/unblock-user-popup.tsx +++ b/packages/extension/src/components/chat-actions-popup/unblock-user-popup.tsx @@ -1,16 +1,16 @@ import React, { useState } from "react"; -import { useHistory } from "react-router"; -import { unblockUser } from "../../graphQL/messages-api"; +import { unblockUser } from "@graphQL/messages-api"; import style from "./style.module.scss"; export const UnblockUserPopup = ({ + userName, setConfirmAction, }: { + userName: string; setConfirmAction: React.Dispatch<React.SetStateAction<boolean>>; }) => { const [processing, setProcessing] = useState(false); - const history = useHistory(); - const userName = history.location.pathname.split("/")[2]; + const handleUnblock = async () => { setProcessing(true); try { diff --git a/packages/extension/src/components/chat-error-popup/index.tsx b/packages/extension/src/components/chat-error-popup/index.tsx index c270806bb9..1152f5573b 100644 --- a/packages/extension/src/components/chat-error-popup/index.tsx +++ b/packages/extension/src/components/chat-error-popup/index.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useState } from "react"; import style from "./style.module.scss"; import { useSelector } from "react-redux"; -import { - setMessageError, - userMessagesError, -} from "../../chatStore/messages-slice"; -import { store } from "../../chatStore"; +import { setMessageError, userMessagesError } from "@chatStore/messages-slice"; +import { store } from "@chatStore/index"; export const ChatErrorPopup = () => { const errorMessage = useSelector(userMessagesError); diff --git a/packages/extension/src/components/chat-loader/index.tsx b/packages/extension/src/components/chat-loader/index.tsx index 7a95d85cb4..cc11087c64 100644 --- a/packages/extension/src/components/chat-loader/index.tsx +++ b/packages/extension/src/components/chat-loader/index.tsx @@ -1,5 +1,5 @@ import React from "react"; -import loadingChatGif from "../../public/assets/chat-loading.gif"; +import loadingChatGif from "@assets/chat-loading.gif"; export const ChatLoader = ({ message }: { message: string }) => { return ( diff --git a/packages/extension/src/components/chat-member/index.tsx b/packages/extension/src/components/chat-member/index.tsx new file mode 100644 index 0000000000..19ea11d782 --- /dev/null +++ b/packages/extension/src/components/chat-member/index.tsx @@ -0,0 +1,118 @@ +import { fromBech32 } from "@cosmjs/encoding"; +import jazzicon from "@metamask/jazzicon"; +import React, { ReactElement, useEffect, useState } from "react"; +import ReactHtmlParser from "react-html-parser"; +import { NameAddress } from "@chatTypes"; +import { formatAddress } from "@utils/format"; +import style from "./style.module.scss"; +import classnames from "classnames"; +import { fetchPublicKey } from "@utils/fetch-public-key"; +import { userDetails } from "@chatStore/user-slice"; +import { useSelector } from "react-redux"; +import { useStore } from "../../stores"; + +export const ChatMember = (props: { + address: NameAddress; + showSelectedIcon?: boolean; + isSelected?: boolean; + isShowAdmin?: boolean; + showPointer?: boolean; + onIconClick?: VoidCallback; + onClick?: VoidCallback; +}) => { + const { name, address } = props.address; + const { + isSelected, + isShowAdmin, + showSelectedIcon = true, + showPointer = false, + onIconClick, + onClick, + } = props; + + const user = useSelector(userDetails); + const { chainStore } = useStore(); + const current = chainStore.current; + + const [isActive, setIsActive] = useState(true); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const isUserActive = async () => { + try { + const pubKey = await fetchPublicKey( + user.accessToken, + current.chainId, + address + ); + if (!pubKey || !pubKey.publicKey || !(pubKey.publicKey.length > 0)) + setIsActive(false); + } catch (e) { + console.log("NewUser/isUserActive error", e); + } finally { + setIsLoading(false); + } + }; + isUserActive(); + }, [ + address, + user.accessToken, + user.messagingPubKey.privacySetting, + user.messagingPubKey.chatReadReceiptSetting, + current.chainId, + ]); + + function decideIconLabelView(): ReactElement { + if (isLoading) { + return <i className="fas fa-spinner fa-spin ml-1" />; + } + + if (isActive && isShowAdmin) { + return <div className={style.adminHeading}>Admin </div>; + } + + if (isActive && showSelectedIcon) { + return ( + <div> + <i + className={!isSelected ? "fa fa-user-plus" : "fa fa-times"} + style={{ + width: "24px", + height: "24px", + padding: "2px 0 0 12px", + cursor: "pointer", + alignItems: "end", + alignSelf: "end", + }} + aria-hidden="true" + onClick={onIconClick} + /> + </div> + ); + } + + return <></>; + } + + return ( + <div + className={classnames( + style.memberContainer, + showPointer || isActive ? style.showPointer : {} + )} + {...(isActive && { onClick: onClick })} + > + <div className={style.initials}> + {ReactHtmlParser( + jazzicon(24, parseInt(fromBech32(address).data.toString(), 16)) + .outerHTML + )} + </div> + <div className={style.memberInner}> + <div className={style.name}>{formatAddress(name)}</div> + {!isActive && <div className={style.name}>Inactive</div>} + </div> + {decideIconLabelView()} + </div> + ); +}; diff --git a/packages/extension/src/components/chat-member/style.module.scss b/packages/extension/src/components/chat-member/style.module.scss new file mode 100644 index 0000000000..7075003dbd --- /dev/null +++ b/packages/extension/src/components/chat-member/style.module.scss @@ -0,0 +1,371 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: center; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + cursor: pointer; + top: 30%; + z-index: 2; + // 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; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} + +// ----------------------------- + +.memberContainer { + display: flex; + align-items: center; + + .initials { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #d0def5; + color: #525f7f; + margin-right: 8px; + font-weight: 400; + font-size: 15px; + line-height: 20px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + .unread { + position: absolute; + top: -4px; + left: -4px; + width: 12px; + height: 12px; + background: #d43bf6; + border-radius: 50%; + } + } + .memberInner { + width: 80%; + .name { + font-weight: 400; + font-size: 15px; + color: #525f7f; + height: 20px; + } + .memberText { + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + white-space: nowrap; + font-weight: 400; + font-size: 15px; + color: #808da0; + height: 20px; + } + } +} + +.showPointer { + cursor: pointer; +} + +.adminHeading { + text-align: center; + vertical-align: middle; + line-height: 30px; + align-items: center; + font-weight: 400; + font-size: 15px; + height: 30px; + width: 80px; + border: 1.5px solid black; + border-color: #d43bf6; + background-color: #f8e8fb; + color: #d43bf6; +} diff --git a/packages/extension/src/components/chatMessage/index.tsx b/packages/extension/src/components/chat-message/index.tsx similarity index 84% rename from packages/extension/src/components/chatMessage/index.tsx rename to packages/extension/src/components/chat-message/index.tsx index 32b88f9b2f..f3b270ad71 100644 --- a/packages/extension/src/components/chatMessage/index.tsx +++ b/packages/extension/src/components/chat-message/index.tsx @@ -1,13 +1,13 @@ import classnames from "classnames"; import React, { useEffect, useState } from "react"; import { Container } from "reactstrap"; -import deliveredIcon from "../../public/assets/icon/chat-unseen-status.png"; -import chatSeenIcon from "../../public/assets/icon/chat-seen-status.png"; -import { decryptMessage } from "../../utils/decrypt-message"; +import deliveredIcon from "@assets/icon/chat-unseen-status.png"; +import chatSeenIcon from "@assets/icon/chat-seen-status.png"; +import { decryptMessage } from "@utils/decrypt-message"; import style from "./style.module.scss"; import { isToday, isYesterday, format } from "date-fns"; -import { store } from "../../chatStore/index"; -import { setMessageError } from "../../chatStore/messages-slice"; +import { store } from "@chatStore/index"; +import { setMessageError } from "@chatStore/messages-slice"; const formatTime = (timestamp: number): string => { const date = new Date(timestamp); @@ -82,10 +82,10 @@ export const ChatMessage = ({ <div className={style.timestamp}> {formatTime(timestamp)} {isSender && groupLastSeenTimestamp < timestamp && ( - <img alt="delivered" src={deliveredIcon} /> + <img draggable={false} alt="delivered" src={deliveredIcon} /> )} {isSender && groupLastSeenTimestamp >= timestamp && ( - <img alt="seen" src={chatSeenIcon} /> + <img draggable={false} alt="seen" src={chatSeenIcon} /> )} </div> </Container> diff --git a/packages/extension/src/components/chatMessage/style.module.scss b/packages/extension/src/components/chat-message/style.module.scss similarity index 97% rename from packages/extension/src/components/chatMessage/style.module.scss rename to packages/extension/src/components/chat-message/style.module.scss index e95c9415d1..e4cb75765a 100644 --- a/packages/extension/src/components/chatMessage/style.module.scss +++ b/packages/extension/src/components/chat-message/style.module.scss @@ -45,6 +45,7 @@ padding-right: 30px; padding-bottom: 3px; overflow-wrap: break-word; + white-space: pre-wrap; } .timestamp { diff --git a/packages/extension/src/components/chat-option/index.tsx b/packages/extension/src/components/chat-option/index.tsx new file mode 100644 index 0000000000..40f7fe49ea --- /dev/null +++ b/packages/extension/src/components/chat-option/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +export const ChatOption = ({ + title, + onClick, +}: { + title: string; + onClick: () => void; +}) => { + return ( + <div onClick={() => onClick()}> + <h6>{title}</h6> + </div> + ); +}; diff --git a/packages/extension/src/components/chat/chat-init-popup.tsx b/packages/extension/src/components/chat/chat-init-popup.tsx index 75a71aa35d..a4abc6adb3 100644 --- a/packages/extension/src/components/chat/chat-init-popup.tsx +++ b/packages/extension/src/components/chat/chat-init-popup.tsx @@ -6,10 +6,10 @@ import amplitude from "amplitude-js"; import React, { useState } from "react"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { store } from "../../chatStore"; -import { setMessageError } from "../../chatStore/messages-slice"; -import { setMessagingPubKey, userDetails } from "../../chatStore/user-slice"; -import privacyIcon from "../../public/assets/hello.png"; +import { store } from "@chatStore/index"; +import { setMessageError } from "@chatStore/messages-slice"; +import { setMessagingPubKey, userDetails } from "@chatStore/user-slice"; +import privacyIcon from "@assets/hello.png"; import { useStore } from "../../stores"; import style from "./style.module.scss"; @@ -82,7 +82,7 @@ export const ChatInitPopup = ({ <> <div className={style.overlay} /> <div className={style.popupContainer}> - <img src={privacyIcon} /> + <img draggable={false} src={privacyIcon} /> <br /> <div className={style.infoContainer}> <h3>We have just added Chat!</h3> diff --git a/packages/extension/src/components/chat/chat-search-input.tsx b/packages/extension/src/components/chat/chat-search-input.tsx index 1abb4e3d7a..17d2de041d 100644 --- a/packages/extension/src/components/chat/chat-search-input.tsx +++ b/packages/extension/src/components/chat/chat-search-input.tsx @@ -1,8 +1,8 @@ import amplitude from "amplitude-js"; import React from "react"; import { useHistory } from "react-router"; -import newChatIcon from "../../public/assets/icon/new-chat.png"; -import searchIcon from "../../public/assets/icon/search.png"; +import newChatIcon from "@assets/icon/new-chat.png"; +import searchIcon from "@assets/icon/search.png"; import style from "./style.module.scss"; export const ChatSearchInput = ({ @@ -18,7 +18,7 @@ export const ChatSearchInput = ({ return ( <div className={style.searchContainer}> <div className={style.searchBox}> - <img src={searchIcon} alt="search" /> + <img draggable={false} src={searchIcon} alt="search" /> <input placeholder="Search by name or address" value={searchInput} @@ -32,7 +32,12 @@ export const ChatSearchInput = ({ history.push("/newChat"); }} > - <img className={style.newChatIcon} src={newChatIcon} alt="" /> + <img + draggable={false} + className={style.newChatIcon} + src={newChatIcon} + alt="" + /> </div> </div> ); diff --git a/packages/extension/src/components/chat/deactivated-chat.tsx b/packages/extension/src/components/chat/deactivated-chat.tsx index 89fddb6071..f48c5e8efa 100644 --- a/packages/extension/src/components/chat/deactivated-chat.tsx +++ b/packages/extension/src/components/chat/deactivated-chat.tsx @@ -2,7 +2,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React from "react"; import { useHistory } from "react-router"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { Menu } from "../../pages/main/menu"; import { SwitchUser } from "../switch-user"; import style from "./style.module.scss"; @@ -20,7 +20,7 @@ export const DeactivatedChat = () => { <div className={style.lockedInnerContainer}> <img className={style.imgLock} - src={require("../../public/assets/img/icons8-lock.svg")} + src={require("@assets/img/icons8-lock.svg")} alt="lock" /> diff --git a/packages/extension/src/components/chat/store.tsx b/packages/extension/src/components/chat/store.tsx index 34398bab5f..194a0f6648 100644 --- a/packages/extension/src/components/chat/store.tsx +++ b/packages/extension/src/components/chat/store.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent } from "react"; import { Provider } from "react-redux"; -import { store } from "../../chatStore"; +import { store } from "@chatStore/index"; export const ChatStoreProvider: FunctionComponent = ({ children }) => { return <Provider store={store}>{children}</Provider>; diff --git a/packages/extension/src/components/form/address-input.module.scss b/packages/extension/src/components/form/address-input.module.scss index 17363b6837..c15c2076f4 100644 --- a/packages/extension/src/components/form/address-input.module.scss +++ b/packages/extension/src/components/form/address-input.module.scss @@ -36,3 +36,7 @@ box-shadow: $input-focus-alternative-box-shadow !important; } } +.error-text { + color: #fb8c72; + font: 10px; +} diff --git a/packages/extension/src/components/form/address-input.tsx b/packages/extension/src/components/form/address-input.tsx index 1532fd1de9..5858b0c31f 100644 --- a/packages/extension/src/components/form/address-input.tsx +++ b/packages/extension/src/components/form/address-input.tsx @@ -3,7 +3,6 @@ import { FormGroup, Label, Input, - FormFeedback, ModalBody, Modal, InputGroup, @@ -174,9 +173,7 @@ export const AddressInput: FunctionComponent<AddressInputProps> = observer( <FormText>{recipientConfig.recipient}</FormText> ) : null} {errorText != null ? ( - <FormFeedback style={{ display: "block" }}> - {errorText} - </FormFeedback> + <div className={styleAddressInput.errorText}>{errorText}</div> ) : null} </FormGroup> </React.Fragment> diff --git a/packages/extension/src/components/form/coin-input.module.scss b/packages/extension/src/components/form/coin-input.module.scss index a8cb520f21..83bc3e92dd 100644 --- a/packages/extension/src/components/form/coin-input.module.scss +++ b/packages/extension/src/components/form/coin-input.module.scss @@ -79,3 +79,7 @@ } } } +.error-text { + color: #fb8c72; + font: 10px; +} diff --git a/packages/extension/src/components/form/coin-input.tsx b/packages/extension/src/components/form/coin-input.tsx index 9e1f00357a..84df9067a6 100644 --- a/packages/extension/src/components/form/coin-input.tsx +++ b/packages/extension/src/components/form/coin-input.tsx @@ -8,7 +8,6 @@ import { DropdownItem, DropdownMenu, DropdownToggle, - FormFeedback, FormGroup, Input, Label, @@ -198,9 +197,7 @@ export const CoinInput: FunctionComponent<CoinInputProps> = observer( autoComplete="off" /> {errorText != null ? ( - <FormFeedback style={{ display: "block" }}> - {errorText} - </FormFeedback> + <div className={styleCoinInput.errorText}>{errorText}</div> ) : null} </FormGroup> </React.Fragment> diff --git a/packages/extension/src/components/form/fee-buttons/fee-buttons.module.scss b/packages/extension/src/components/form/fee-buttons/fee-buttons.module.scss index 52114c0424..20ac2b7e5c 100644 --- a/packages/extension/src/components/form/fee-buttons/fee-buttons.module.scss +++ b/packages/extension/src/components/form/fee-buttons/fee-buttons.module.scss @@ -37,3 +37,7 @@ $border-radius: 6px; } } } +.set-gas-button { + height: 50px; + float: right; +} diff --git a/packages/extension/src/components/form/fee-buttons/fee-buttons.tsx b/packages/extension/src/components/form/fee-buttons/fee-buttons.tsx index a626daa30f..a431fce645 100644 --- a/packages/extension/src/components/form/fee-buttons/fee-buttons.tsx +++ b/packages/extension/src/components/form/fee-buttons/fee-buttons.tsx @@ -271,10 +271,13 @@ export const FeeButtonsInner: FunctionComponent< </FormText> ) : null} {errorText != null ? ( - <FormFeedback style={{ display: "block" }}>{errorText}</FormFeedback> + <FormFeedback style={{ display: "block", width: "80%" }}> + {errorText} + </FormFeedback> ) : null} - <div style={{ position: "absolute", right: 0 }}> + <div style={{ right: 0 }}> <Button + className={styleFeeButtons.setGasButton} size="sm" color="link" onClick={(e) => { diff --git a/packages/extension/src/components/group-chat-actions-dropdown/index.tsx b/packages/extension/src/components/group-chat-actions-dropdown/index.tsx new file mode 100644 index 0000000000..302ff89d3d --- /dev/null +++ b/packages/extension/src/components/group-chat-actions-dropdown/index.tsx @@ -0,0 +1,56 @@ +import { GroupChatOptions } from "@chatTypes"; +import { ChatOption } from "@components/chat-option"; +import React from "react"; +import style from "./style.module.scss"; + +export const GroupChatActionsDropdown = ({ + showDropdown, + handleClick, + isAdmin, + isMemberRemoved, +}: { + showDropdown: boolean; + isAdmin: boolean; + isMemberRemoved: boolean; + handleClick: (option: GroupChatOptions) => void; +}) => { + const options = [ + { title: "Group info", option: GroupChatOptions.groupInfo }, + //{ title: "Mute group", option: GroupChatOptions.muteGroup }, + ]; + + /// Add Leave group option when member info available in group detail + if (!isMemberRemoved) { + options.push({ title: "Leave group", option: GroupChatOptions.leaveGroup }); + } + + if (isAdmin && !isMemberRemoved) { + options.push({ + title: "Chat settings", + option: GroupChatOptions.chatSettings, + }); + } + + if (isMemberRemoved) { + options.push({ + title: "Delete group", + option: GroupChatOptions.deleteGroup, + }); + } + + return ( + <> + {showDropdown && ( + <div className={style.dropdown}> + {options.map(({ title, option }) => ( + <ChatOption + key={title} + title={title} + onClick={() => handleClick(option)} + /> + ))} + </div> + )} + </> + ); +}; diff --git a/packages/extension/src/components/group-chat-actions-dropdown/style.module.scss b/packages/extension/src/components/group-chat-actions-dropdown/style.module.scss new file mode 100644 index 0000000000..f78811b27e --- /dev/null +++ b/packages/extension/src/components/group-chat-actions-dropdown/style.module.scss @@ -0,0 +1,309 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: center; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + h6 { + padding: 0 10px; + color: black; + margin: 0; + font-size: 1rem; + font-weight: 400; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + cursor: pointer; + top: 30%; + z-index: 2; + // 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; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} diff --git a/packages/extension/src/components/group-chat-message/index.tsx b/packages/extension/src/components/group-chat-message/index.tsx new file mode 100644 index 0000000000..412caeede8 --- /dev/null +++ b/packages/extension/src/components/group-chat-message/index.tsx @@ -0,0 +1,128 @@ +import classnames from "classnames"; +import React, { useEffect, useState } from "react"; +import { Container } from "reactstrap"; +import deliveredIcon from "@assets/icon/chat-unseen-status.png"; +import chatSeenIcon from "@assets/icon/chat-seen-status.png"; +import style from "./style.module.scss"; +import { isToday, isYesterday, format } from "date-fns"; +import { decryptGroupMessage } from "@utils/decrypt-group"; +import { GroupMessagePayload, NameAddress } from "@chatTypes"; +import { GroupMessageType } from "@utils/encrypt-group"; +import { getUserName, getEventMessage } from "@utils/index"; +import { useStore } from "../../stores"; + +const formatTime = (timestamp: number): string => { + const date = new Date(timestamp); + return format(date, "p"); +}; + +export const GroupChatMessage = ({ + chainId, + senderAddress, + encryptedSymmetricKey, + addresses, + message, + isSender, + timestamp, + showDate, + groupLastSeenTimestamp, +}: { + chainId: string; + senderAddress: string; + encryptedSymmetricKey: string; + addresses: NameAddress; + isSender: boolean; + message: string; + timestamp: number; + showDate: boolean; + groupLastSeenTimestamp: number; +}) => { + const [ + decryptedMessage, + setDecryptedMessage, + ] = useState<GroupMessagePayload>(); + + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + + useEffect(() => { + async function loadDecryptedMessage() { + const decryptedMsg = await decryptGroupMessage( + message, + chainId, + encryptedSymmetricKey + ); + setDecryptedMessage(decryptedMsg); + } + loadDecryptedMessage(); + }, [chainId, 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 ( + <> + <div className={style.currentDateContainer}> + {" "} + {showDate ? ( + <span className={style.currentDate}>{getDate(timestamp)}</span> + ) : null} + </div> + {decryptedMessage && + (decryptedMessage.type == GroupMessageType.event.toString() || + decryptedMessage.type === GroupMessageType[GroupMessageType.event]) ? ( + <div className={style.currentEventContainer}> + <span className={style.currentEvent}> + {getEventMessage( + accountInfo.bech32Address, + addresses, + decryptedMessage.message + )} + </span> + </div> + ) : ( + <div className={isSender ? style.senderAlign : style.receiverAlign}> + <Container + fluid + className={classnames(style.messageBox, { + [style.senderBox]: isSender, + })} + > + {!isSender && ( + <div className={style.title}> + {getUserName( + accountInfo.bech32Address, + addresses, + senderAddress + )} + </div> + )} + {!decryptedMessage ? ( + <i className="fas fa-spinner fa-spin ml-1" /> + ) : ( + <div className={style.message}>{decryptedMessage.message}</div> + )} + <div className={style.timestamp}> + {formatTime(timestamp)} + {isSender && groupLastSeenTimestamp < timestamp && ( + <img draggable={false} alt="delivered" src={deliveredIcon} /> + )} + {isSender && groupLastSeenTimestamp >= timestamp && ( + <img draggable={false} alt="seen" src={chatSeenIcon} /> + )} + </div> + </Container> + </div> + )} + </> + ); +}; diff --git a/packages/extension/src/components/group-chat-message/style.module.scss b/packages/extension/src/components/group-chat-message/style.module.scss new file mode 100644 index 0000000000..6dee8c6004 --- /dev/null +++ b/packages/extension/src/components/group-chat-message/style.module.scss @@ -0,0 +1,80 @@ +.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: black; + font-size: 15px; + margin: 4px 4px; +} + +.title { + color: #525f7f; + font-size: 15px; + padding-right: 30px; + padding-bottom: 3px; + overflow-wrap: break-word; +} + +.senderBox { + border-radius: 16px 0px 16px 16px; + background-color: #d0def5; +} + +.currentDateContainer { + text-align: center; +} + +.currentDate { + color: #525f7f; + margin: 0 auto; + max-width: 200px; + padding: 4px; + border-radius: 10px; +} + +.currentEventContainer { + text-align: center; + margin: 4px; +} + +.currentEvent { + color: #525f7f; + margin: 0 auto; + max-width: 200px; + padding: 4px; + border-radius: 10px; + overflow-wrap: break-word; +} + +.senderAlign { + display: flex; + justify-content: end; +} + +.receiverAlign { + display: flex; + justify-content: start; +} + +.message { + padding-right: 30px; + padding-bottom: 3px; + overflow-wrap: break-word; + white-space: pre-wrap; +} + +.timestamp { + display: flex; + align-items: flex-end; + justify-content: end; + font-size: 12px; +} diff --git a/packages/extension/src/components/group-chat-popup/index.tsx b/packages/extension/src/components/group-chat-popup/index.tsx new file mode 100644 index 0000000000..60f8a7986e --- /dev/null +++ b/packages/extension/src/components/group-chat-popup/index.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import style from "./style.module.scss"; +import { GroupChatMemberOptions, GroupMembers } from "@chatTypes"; +import { ChatOption } from "@components/chat-option"; +import { formatAddress } from "@utils/format"; + +export const GroupChatPopup = ({ + name, + selectedMember, + isLoginUserAdmin, + isAdded, + isFromReview, + onClick, +}: { + name: string; + selectedMember: GroupMembers | undefined; + isLoginUserAdmin: boolean; + isAdded: boolean; + isFromReview: boolean; + onClick: (option: GroupChatMemberOptions) => void; +}) => { + return ( + <> + <div + className={style.overlay} + onClick={() => onClick(GroupChatMemberOptions.dissmisPopup)} + /> + <div className={style.popup}> + <i + className={"fa fa-times"} + style={{ + width: "24px", + height: "24px", + cursor: "pointer", + position: "absolute", + float: "right", + right: "0px", + top: "10px", + }} + aria-hidden="true" + onClick={() => onClick(GroupChatMemberOptions.dissmisPopup)} + /> + { + <ChatOption + title={`Message ${formatAddress(name)}`} + onClick={() => onClick(GroupChatMemberOptions.messageMember)} + /> + } + {isAdded ? ( + <ChatOption + title={"View in Address Book"} + onClick={() => onClick(GroupChatMemberOptions.viewInAddressBook)} + /> + ) : ( + <ChatOption + title={"Add to Address Book"} + onClick={() => onClick(GroupChatMemberOptions.addToAddressBook)} + /> + )} + {isLoginUserAdmin && !selectedMember?.isAdmin && !isFromReview && ( + <ChatOption + title={"Give admin status"} + onClick={() => onClick(GroupChatMemberOptions.makeAdminStatus)} + /> + )} + {isLoginUserAdmin && selectedMember?.isAdmin && !isFromReview && ( + <ChatOption + title={"Remove admin status"} + onClick={() => onClick(GroupChatMemberOptions.removeAdminStatus)} + /> + )} + {isLoginUserAdmin && !isFromReview && ( + <ChatOption + title={`Remove ${formatAddress(name)}`} + onClick={() => onClick(GroupChatMemberOptions.removeMember)} + /> + )} + </div> + </> + ); +}; diff --git a/packages/extension/src/components/group-chat-popup/style.module.scss b/packages/extension/src/components/group-chat-popup/style.module.scss new file mode 100644 index 0000000000..075e160638 --- /dev/null +++ b/packages/extension/src/components/group-chat-popup/style.module.scss @@ -0,0 +1,301 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: center; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding: 16px; + cursor: pointer; + top: 32%; + z-index: 2; + // display: flex; + // flex-direction: column; + // padding: 0px 20px; + h6 { + padding: 0 8px 8px 8px; + color: black; + margin: 0; + font-size: 1rem; + font-weight: 400; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} diff --git a/packages/extension/src/components/tooltip/tooltip.module.scss b/packages/extension/src/components/tooltip/tooltip.module.scss index 7d0e277dac..fd271ea323 100644 --- a/packages/extension/src/components/tooltip/tooltip.module.scss +++ b/packages/extension/src/components/tooltip/tooltip.module.scss @@ -15,7 +15,8 @@ $bright: rgba(255, 255, 255, 0.75); padding: 4px; border-radius: 6px; color: white; - + word-break: break-word; + margin-right: 10px; z-index: 1000; transition: opacity 0.45s; } diff --git a/packages/extension/src/graphQL/client.ts b/packages/extension/src/graphQL/client.ts index d0f484cdf8..66df56af08 100644 --- a/packages/extension/src/graphQL/client.ts +++ b/packages/extension/src/graphQL/client.ts @@ -1,8 +1,8 @@ import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { createClient } from "graphql-ws"; -import { store } from "../chatStore"; -import { setIsChatSubscriptionActive } from "../chatStore/messages-slice"; +import { store } from "@chatStore/index"; +import { setIsChatSubscriptionActive } from "@chatStore/messages-slice"; import { GRAPHQL_URL } from "../config.ui.var"; export const client = new ApolloClient({ diff --git a/packages/extension/src/graphQL/groups-api.ts b/packages/extension/src/graphQL/groups-api.ts new file mode 100644 index 0000000000..13d8708b78 --- /dev/null +++ b/packages/extension/src/graphQL/groups-api.ts @@ -0,0 +1,164 @@ +import { gql } from "@apollo/client"; +import { GroupDetails, PublicKeyDetails } from "@chatTypes"; +import { store } from "@chatStore/index"; +import { removeGroup, setMessageError } from "@chatStore/messages-slice"; +import { client } from "./client"; +import { + Group, + leaveGroupMutation, + UpdateGroupLastSeen, + UpdatePublicKey, +} from "./groups-queries"; + +export const updatePublicKey = async (publicKeyDetails: PublicKeyDetails) => { + const state = store.getState(); + const { data, errors } = await client.mutate({ + mutation: gql(UpdatePublicKey), + fetchPolicy: "no-cache", + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + variables: { + publicKeyDetails, + }, + }); + + if (errors) console.log("errors", errors); + return data.updatePublicKey; +}; + +export const createGroup = async (groupDetails: GroupDetails) => { + const state = store.getState(); + + try { + const { data, errors } = await client.mutate({ + mutation: gql(Group), + fetchPolicy: "no-cache", + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + variables: { groupDetails }, + }); + if (errors) { + store.dispatch( + setMessageError({ + type: "Group", + message: errors || "Something went wrong, Group can't be created", + level: 1, + }) + ); + return null; + } + return data.group; + } catch (e: any) { + store.dispatch( + setMessageError({ + type: "Group", + message: e?.message || "Something went wrong, Group can't be created", + level: 1, + }) + ); + return null; + } +}; + +export const leaveGroup = async (groupId: string) => { + const state = store.getState(); + + try { + const { data, errors } = await client.mutate({ + mutation: gql(leaveGroupMutation), + fetchPolicy: "no-cache", + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + variables: { groupId }, + }); + if (errors) { + store.dispatch( + setMessageError({ + type: "Group", + message: errors || "Something went wrong, Group can't be left", + level: 1, + }) + ); + return null; + } + return data; + } catch (e: any) { + store.dispatch( + setMessageError({ + type: "Group", + message: e?.message || "Something went wrong, Group can't be left", + level: 1, + }) + ); + return null; + } +}; + +export const deleteGroup = async (groupId: string) => { + const state = store.getState(); + + try { + const { data, errors } = await client.mutate({ + mutation: gql(`mutation Mutation($groupId: String) { + deleteGroup(groupId: $groupId) + }`), + fetchPolicy: "no-cache", + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + variables: { groupId }, + }); + if (errors) { + store.dispatch( + setMessageError({ + type: "Group", + message: errors || "Something went wrong, Group can't be deleted", + level: 1, + }) + ); + return null; + } + store.dispatch(removeGroup(groupId)); + return data.group; + } catch (e: any) { + store.dispatch( + setMessageError({ + type: "Group", + message: e?.message || "Something went wrong, Group can't be deleted", + level: 1, + }) + ); + return null; + } +}; + +//already generated by vinay +export const updateGroupLastSeen = async ( + groupId: string, + lastSeenTimestamp: string +) => { + const state = store.getState(); + const { data, errors } = await client.mutate({ + mutation: gql(UpdateGroupLastSeen), + fetchPolicy: "no-cache", + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + variables: { groupId, lastSeenTimestamp }, + }); + if (errors) console.log("errors", errors); + return data.updateGroupLastSeen; +}; diff --git a/packages/extension/src/graphQL/groups-queries.ts b/packages/extension/src/graphQL/groups-queries.ts new file mode 100644 index 0000000000..96feeea126 --- /dev/null +++ b/packages/extension/src/graphQL/groups-queries.ts @@ -0,0 +1,60 @@ +export const UpdatePublicKey = `mutation UpdatePublicKey($publicKeyDetails: InputPublicKey!) { + updatePublicKey(publicKeyDetails: $publicKeyDetails) { + address + channelId + createdAt + id + privacySetting + publicKey + updatedAt + } +}`; + +export const Group = `mutation Mutation($groupDetails: GroupDetails!) { + group(groupDetails: $groupDetails) { + id + name + isDm + description + lastMessageContents + lastMessageSender + lastMessageTimestamp + lastSeenTimestamp + addresses { + address + pubKey + lastSeenTimestamp + groupLastSeenTimestamp + encryptedSymmetricKey + isAdmin + removedAt + } + createdAt + removedAt + } +}`; + +export const leaveGroupMutation = `mutation Mutation($groupId: String) { + leaveGroup(groupId: $groupId) +}`; + +export const UpdateGroupLastSeen = `mutation Mutation($groupId: String!, $lastSeenTimestamp: Date!) { + updateGroupLastSeen(groupId: $groupId, lastSeenTimestamp: $lastSeenTimestamp) { + addresses { + address + encryptedSymmetricKey + isAdmin + lastSeenTimestamp + pubKey + } + createdAt + description + id + isDm + lastMessageContents + lastMessageSender + lastMessageTimestamp + lastSeenTimestamp + name + } +}`; diff --git a/packages/extension/src/graphQL/messages-api.ts b/packages/extension/src/graphQL/messages-api.ts index b9e0302ade..e2e9a06663 100644 --- a/packages/extension/src/graphQL/messages-api.ts +++ b/packages/extension/src/graphQL/messages-api.ts @@ -3,7 +3,7 @@ import { getMainDefinition, ObservableSubscription, } from "@apollo/client/utilities"; -import { store } from "../chatStore"; +import { store } from "@chatStore/index"; import { setBlockedList, setBlockedUser, @@ -12,43 +12,50 @@ import { updateMessages, updateLatestSentMessage, updateGroupsData, -} from "../chatStore/messages-slice"; +} from "@chatStore/messages-slice"; import { CHAT_PAGE_COUNT, GROUP_PAGE_COUNT } from "../config.ui.var"; -import { encryptAllData } from "../utils/encrypt-message"; -import { encryptGroupTimestamp } from "../utils/encrypt-group"; +import { encryptAllData } from "@utils/encrypt-message"; +import { + encryptGroupMessage, + encryptGroupTimestamp, + GroupMessageType, +} from "@utils/encrypt-group"; import { client, createWSLink, httpLink } from "./client"; import { block, blockedList, groups, groupsWithAddresses, - groupReadUnread, + listenGroups, listenMessages, mailbox, mailboxWithTimestamp, - NewMessageUpdate, sendMessages, unblock, updateGroupLastSeen, } from "./messages-queries"; import { recieveGroups } from "./recieve-messages"; +import { NewMessageUpdate } from "@chatTypes"; let querySubscription: ObservableSubscription; -let queryGroupReadUnreadSubscription: ObservableSubscription; +let queryGroupSubscription: ObservableSubscription; interface messagesVariables { page?: number; pageCount?: number; groupId: string; + isDm: boolean; afterTimestamp?: string; } export const fetchMessages = async ( groupId: string, + isDm: boolean, afterTimestamp: string | null | undefined, page: number ) => { const state = store.getState(); let variables: messagesVariables = { - groupId: groupId, + groupId, + isDm, }; if (!!afterTimestamp) { variables = { ...variables, afterTimestamp: afterTimestamp }; @@ -252,7 +259,63 @@ export const deliverMessages = async ( } }; -export const messageListener = () => { +export const deliverGroupMessages = async ( + accessToken: string, + chainId: string, + newMessage: any, + encryptedSymmetricKey: string, + messageType: GroupMessageType, + senderAddress: string, + groupId: string +) => { + const state = store.getState(); + try { + if (newMessage) { + const encryptedData = await encryptGroupMessage( + chainId, + newMessage, + messageType, + encryptedSymmetricKey, + senderAddress, + groupId, + accessToken + ); + const { data } = await client.mutate({ + mutation: gql(sendMessages), + variables: { + messages: [ + { + contents: `${encryptedData}`, + }, + ], + }, + context: { + headers: { + Authorization: `Bearer ${state.user.accessToken}`, + }, + }, + }); + + if (data?.dispatchMessages?.length > 0) { + store.dispatch(updateLatestSentMessage(data?.dispatchMessages[0])); + return data?.dispatchMessages[0]; + } + return null; + } + } catch (e: any) { + store.dispatch( + setMessageError({ + type: "delivery", + message: + e?.message || "Something went wrong, Message can't be delivered", + level: 1, + }) + ); + return null; + } +}; + +export const messageListener = (userAddress: string) => { const state = store.getState(); const wsLink = createWSLink(state.user.accessToken); const splitLink = split( @@ -281,8 +344,15 @@ export const messageListener = () => { }) .subscribe({ next({ data }: { data: { newMessageUpdate: NewMessageUpdate } }) { + const { target, groupId } = data.newMessageUpdate.message; + /// Distinguish between Group and Single chat + const id = groupId.split("-").length == 2 ? target : userAddress; store.dispatch(updateMessages(data.newMessageUpdate.message)); - recieveGroups(0, data.newMessageUpdate.message.target); + + /// Adding timeout for temporaray as Remove At Group subscription not working + setTimeout(() => { + recieveGroups(0, id); + }, 100); }, error(err) { console.error("err", err); @@ -300,7 +370,7 @@ export const messageListener = () => { }); }; -export const groupReadUnreadListener = (userAddress: string) => { +export const groupsListener = (userAddress: string) => { const state = store.getState(); const wsLink = createWSLink(state.user.accessToken); const splitLink = split( @@ -318,9 +388,9 @@ export const groupReadUnreadListener = (userAddress: string) => { link: splitLink, cache: new InMemoryCache(), }); - queryGroupReadUnreadSubscription = newClient + queryGroupSubscription = newClient .subscribe({ - query: gql(groupReadUnread), + query: gql(listenGroups), context: { headers: { authorization: `Bearer ${state.user.accessToken}`, @@ -331,10 +401,15 @@ export const groupReadUnreadListener = (userAddress: string) => { next({ data }: { data: any }) { const group = data.groupUpdate.group; - group.userAddress = - group.id.split("-")[0].toLowerCase() !== userAddress.toLowerCase() - ? group.id.split("-")[0] - : group.id.split("-")[1]; + if (group.isDm) { + const ids = group.id.split("-"); + group.userAddress = + ids[0].toLowerCase() !== userAddress.toLowerCase() + ? ids[0] + : ids[1]; + } else { + group.userAddress = group.id; + } store.dispatch(updateGroupsData(group)); }, error(err) { @@ -355,8 +430,7 @@ export const groupReadUnreadListener = (userAddress: string) => { export const messageAndGroupListenerUnsubscribe = () => { if (querySubscription) querySubscription.unsubscribe(); - if (queryGroupReadUnreadSubscription) - queryGroupReadUnreadSubscription.unsubscribe(); + if (queryGroupSubscription) queryGroupSubscription.unsubscribe(); }; export const updateGroupTimestamp = async ( diff --git a/packages/extension/src/graphQL/messages-queries.ts b/packages/extension/src/graphQL/messages-queries.ts index f30bbdf2d4..01f29524e4 100644 --- a/packages/extension/src/graphQL/messages-queries.ts +++ b/packages/extension/src/graphQL/messages-queries.ts @@ -3,6 +3,7 @@ export const sendMessages = `mutation Mutation($messages: [InputMessage!]!) { id sender target + groupId contents expiryTimestamp commitTimestamp @@ -12,8 +13,8 @@ export const sendMessages = `mutation Mutation($messages: [InputMessage!]!) { // TODO(!!!): I expect these also need types associated for all of the queries // here -export const mailboxWithTimestamp = `query Query($groupId: String, $afterTimestamp: Date) { - mailbox(groupId: $groupId, afterTimestamp: $afterTimestamp) { +export const mailboxWithTimestamp = `query Query($groupId: String, $isDm: Boolean, $afterTimestamp: Date) { + mailbox(groupId: $groupId, isDm: $isDm, afterTimestamp: $afterTimestamp) { messages { commitTimestamp contents @@ -26,8 +27,8 @@ export const mailboxWithTimestamp = `query Query($groupId: String, $afterTimesta } }`; -export const mailbox = `query Mailbox($groupId: String, $page: Int, $pageCount: Int) { - mailbox(groupId: $groupId, page: $page, pageCount: $pageCount) { +export const mailbox = `query Mailbox($groupId: String, $isDm: Boolean, $page: Int, $pageCount: Int) { + mailbox(groupId: $groupId, isDm: $isDm, page: $page, pageCount: $pageCount) { messages { id target @@ -64,7 +65,9 @@ export const groups = `query Query($addressQueryString: String, $page: Int, $pag groupLastSeenTimestamp encryptedSymmetricKey isAdmin + removedAt } + removedAt createdAt } pagination { @@ -94,7 +97,9 @@ export const groupsWithAddresses = `query Query($page: Int, $pageCount: Int, $ad groupLastSeenTimestamp encryptedSymmetricKey isAdmin + removedAt } + removedAt createdAt } pagination { @@ -106,26 +111,12 @@ export const groupsWithAddresses = `query Query($page: Int, $pageCount: Int, $ad } }`; -export interface Message { - id: string; - sender: string; - target: string; - contents: string; - groupId: string; - expiryTimestamp: string; - commitTimestamp: string; -} - -export interface NewMessageUpdate { - type: string; - message: Message; -} - export const listenMessages = `subscription NewMessageUpdate { newMessageUpdate { type message { id + groupId sender target contents @@ -140,12 +131,13 @@ export interface Addresses { lastSeenTimestamp: string; } -export const groupReadUnread = `subscription GroupUpdate { +export const listenGroups = `subscription GroupUpdate { groupUpdate { group { id name isDm + description lastMessageContents lastMessageSender lastMessageTimestamp @@ -157,8 +149,10 @@ export const groupReadUnread = `subscription GroupUpdate { groupLastSeenTimestamp encryptedSymmetricKey isAdmin + removedAt } createdAt + removedAt } } }`; @@ -210,7 +204,9 @@ export const updateGroupLastSeen = `mutation Mutation($groupId: String!, $lastSe groupLastSeenTimestamp encryptedSymmetricKey isAdmin + removedAt } createdAt + removedAt } }`; diff --git a/packages/extension/src/graphQL/recieve-messages.ts b/packages/extension/src/graphQL/recieve-messages.ts index cf99d71cc5..4b77a60bc2 100644 --- a/packages/extension/src/graphQL/recieve-messages.ts +++ b/packages/extension/src/graphQL/recieve-messages.ts @@ -1,9 +1,9 @@ -import { store } from "../chatStore"; +import { store } from "@chatStore/index"; import { setGroups, updateChatList, setIsChatGroupPopulated, -} from "../chatStore/messages-slice"; +} from "@chatStore/messages-slice"; import { CHAT_PAGE_COUNT } from "../config.ui.var"; import { fetchGroups, fetchMessages } from "./messages-api"; @@ -11,15 +11,16 @@ export const recieveMessages = async ( userAddress: string, afterTimestamp: string | null | undefined, page: number, + _isDm: boolean, _groupId: string ) => { const { messages, pagination } = await fetchMessages( _groupId, + _isDm, afterTimestamp, page ); const messagesObj: any = {}; - if (messages) { messages.map((message: any) => { messagesObj[message.id] = message; @@ -31,7 +32,7 @@ export const recieveMessages = async ( /// fetching the read records after unread to avoid the pagination stuck if (!!afterTimestamp) { const tmpPage = Math.floor(messages.length / CHAT_PAGE_COUNT); - await recieveMessages(userAddress, null, tmpPage, _groupId); + await recieveMessages(userAddress, null, tmpPage, _isDm, _groupId); } } return messagesObj; @@ -51,10 +52,16 @@ export const recieveGroups = async ( const groupsObj: any = {}; if (groups && groups.length) { groups.map((group: any) => { - const contactAddress = - group.id.split("-")[0].toLowerCase() !== userAddress.toLowerCase() - ? group.id.split("-")[0] - : group.id.split("-")[1]; + let contactAddress; + + if (group.isDm) { + contactAddress = + group.id.split("-")[0].toLowerCase() !== userAddress.toLowerCase() + ? group.id.split("-")[0] + : group.id.split("-")[1]; + } else { + contactAddress = group.id; + } groupsObj[contactAddress] = group; }); store.dispatch(setGroups({ groups: groupsObj, pagination })); diff --git a/packages/extension/src/index.tsx b/packages/extension/src/index.tsx index e2689212d5..94d9c87111 100644 --- a/packages/extension/src/index.tsx +++ b/packages/extension/src/index.tsx @@ -16,14 +16,14 @@ import { RegisterPage } from "./pages/register"; import { SendPage } from "./pages/send"; import { SetKeyRingPage } from "./pages/setting/keyring"; -import { Banner } from "./components/banner"; +import { Banner } from "@components/banner"; -import { ConfirmProvider } from "./components/confirm"; -import { LoadingIndicatorProvider } from "./components/loading-indicator"; +import { ConfirmProvider } from "@components/confirm"; +import { LoadingIndicatorProvider } from "@components/loading-indicator"; import { NotificationProvider, NotificationStoreProvider, -} from "./components/notification"; +} from "@components/notification"; import { configure } from "mobx"; import { observer } from "mobx-react-lite"; @@ -55,17 +55,22 @@ import { AdditonalIntlMessages, LanguageToFiatCurrency } from "./config.ui"; import { Keplr } from "@keplr-wallet/provider"; import { InExtensionMessageRequester } from "@keplr-wallet/router-extension"; -import { LogPageViewWrapper } from "./components/analytics"; +import { LogPageViewWrapper } from "@components/analytics"; import manifest from "./manifest.json"; import { ChatPage } from "./pages/chat"; -import { ChatSection } from "./pages/chatSection"; +import { ChatSection } from "./pages/chat-section"; import { ExportToMobilePage } from "./pages/setting/export-to-mobile"; -import { ChatStoreProvider } from "./components/chat/store"; +import { ChatStoreProvider } from "@components/chat/store"; import { NewChat } from "./pages/newchat/new-chat"; import { ChatSettings } from "./pages/setting/chat"; import { BlockList } from "./pages/setting/chat/block"; import { Privacy } from "./pages/setting/chat/privacy"; import { ReadRecipt } from "./pages/setting/chat/readRecipt"; +import { CreateGroupChat } from "./pages/group-chat/create-group-chat"; +import { AddMember } from "./pages/group-chat/add-member"; +import { ReviewGroupChat } from "./pages/group-chat/review-details"; +import { GroupChatSection } from "./pages/group-chat/chat-section"; +import { EditMember } from "./pages/group-chat/edit-member"; window.keplr = new Keplr( manifest.version, @@ -74,10 +79,10 @@ window.keplr = new Keplr( ); // Make sure that icon file will be included in bundle -require("./public/assets/temp-icon.svg"); -require("./public/assets/icon/icon-16.png"); -require("./public/assets/icon/icon-48.png"); -require("./public/assets/icon/icon-128.png"); +require("@assets/temp-icon.svg"); +require("@assets/icon/icon-16.png"); +require("@assets/icon/icon-48.png"); +require("@assets/icon/icon-128.png"); configure({ enforceActions: "always", // Make mobx to strict mode. @@ -117,8 +122,8 @@ const StateRenderer: FunctionComponent = observer(() => { return ( <div style={{ height: "100%" }}> <Banner - icon={require("./public/assets/temp-icon.svg")} - logo={require("./public/assets/logo-temp.png")} + icon={require("@assets/temp-icon.svg")} + logo={require("@assets/logo-temp.png")} /> </div> ); @@ -126,8 +131,8 @@ const StateRenderer: FunctionComponent = observer(() => { return ( <div style={{ height: "100%" }}> <Banner - icon={require("./public/assets/temp-icon.svg")} - logo={require("./public/assets/logo-temp.png")} + icon={require("@assets/temp-icon.svg")} + logo={require("@assets/logo-temp.png")} /> </div> ); @@ -156,6 +161,31 @@ const Application: FunctionComponent = () => { <Route exact path="/activity" component={ActivityPage} /> <Route exact path="/chat" component={ChatPage} /> <Route exact path="/chat/:name" component={ChatSection} /> + <Route + exact + path="/chat/group-chat/create" + component={CreateGroupChat} + /> + <Route + exact + path="/chat/group-chat/add-member" + component={AddMember} + /> + <Route + exact + path="/chat/group-chat/edit-member" + component={EditMember} + /> + <Route + exact + path="/chat/group-chat/review-details" + component={ReviewGroupChat} + /> + <Route + exact + path="/chat/group-chat-section/:name" + component={GroupChatSection} + /> <Route exact path="/more" component={MorePage} /> <Route exact diff --git a/packages/extension/src/languages/en.json b/packages/extension/src/languages/en.json index 7c1ef117c6..9f9d833186 100644 --- a/packages/extension/src/languages/en.json +++ b/packages/extension/src/languages/en.json @@ -28,6 +28,7 @@ "main.ibc.transfer.button": "Transfer", "main.address.copied": "Address copied!", + "main.name.copied": "Name copied!", "main.menu.settings": "Settings", "main.menu.address-book": "Address Book", diff --git a/packages/extension/src/layouts/bottom-nav/index.tsx b/packages/extension/src/layouts/bottom-nav/index.tsx index e4637c50c2..1506191cdf 100644 --- a/packages/extension/src/layouts/bottom-nav/index.tsx +++ b/packages/extension/src/layouts/bottom-nav/index.tsx @@ -1,15 +1,15 @@ import React, { useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { userChatActive } from "../../chatStore/user-slice"; +import { userChatActive } from "@chatStore/user-slice"; import { CHAIN_ID_FETCHHUB } from "../../config.ui.var"; -import chatTabBlueIcon from "../../public/assets/icon/chat-blue.png"; -import chatTabGreyIcon from "../../public/assets/icon/chat-grey.png"; -import clockTabBlueIcon from "../../public/assets/icon/clock-blue.png"; -import clockTabGreyIcon from "../../public/assets/icon/clock-grey.png"; -import homeTabBlueIcon from "../../public/assets/icon/home-blue.png"; -import homeTabGreyIcon from "../../public/assets/icon/home-grey.png"; -import moreTabBlueIcon from "../../public/assets/icon/more-blue.png"; -import moreTabGreyIcon from "../../public/assets/icon/more-grey.png"; +import chatTabBlueIcon from "@assets/icon/chat-blue.png"; +import chatTabGreyIcon from "@assets/icon/chat-grey.png"; +import clockTabBlueIcon from "@assets/icon/clock-blue.png"; +import clockTabGreyIcon from "@assets/icon/clock-grey.png"; +import homeTabBlueIcon from "@assets/icon/home-blue.png"; +import homeTabGreyIcon from "@assets/icon/home-grey.png"; +import moreTabBlueIcon from "@assets/icon/more-blue.png"; +import moreTabGreyIcon from "@assets/icon/more-grey.png"; import { useStore } from "../../stores"; import style from "./style.module.scss"; import { Tab } from "./tab"; diff --git a/packages/extension/src/layouts/bottom-nav/tab.tsx b/packages/extension/src/layouts/bottom-nav/tab.tsx index 174663bc55..a71f10bb22 100644 --- a/packages/extension/src/layouts/bottom-nav/tab.tsx +++ b/packages/extension/src/layouts/bottom-nav/tab.tsx @@ -2,7 +2,7 @@ import amplitude from "amplitude-js"; import React from "react"; import { useLocation, useHistory } from "react-router-dom"; import { UncontrolledTooltip } from "reactstrap"; -// import { ToolTip } from "../../components/tooltip"; +// import { ToolTip } from "@components/tooltip"; import style from "./style.module.scss"; @@ -45,7 +45,7 @@ export const Tab = ({ } }} > - <img src={isActive ? activeTabIcon : icon} alt="tab" /> + <img draggable={false} src={isActive ? activeTabIcon : icon} alt="tab" /> <div className={style.title}>{title}</div> {disabled && ( <UncontrolledTooltip placement="top" target={title}> diff --git a/packages/extension/src/layouts/header/chain-list.tsx b/packages/extension/src/layouts/header/chain-list.tsx index 29cfa45322..9d1f74d32a 100644 --- a/packages/extension/src/layouts/header/chain-list.tsx +++ b/packages/extension/src/layouts/header/chain-list.tsx @@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite"; import React, { FunctionComponent } from "react"; import { useIntl } from "react-intl"; import { useHistory } from "react-router"; -import { store } from "../../chatStore"; +import { store } from "@chatStore/index"; import { resetChatList, setIsChatSubscriptionActive, -} from "../../chatStore/messages-slice"; -import { resetUser } from "../../chatStore/user-slice"; -import { useConfirm } from "../../components/confirm"; -import { messageAndGroupListenerUnsubscribe } from "../../graphQL/messages-api"; +} from "@chatStore/messages-slice"; +import { resetUser } from "@chatStore/user-slice"; +import { useConfirm } from "@components/confirm"; +import { messageAndGroupListenerUnsubscribe } from "@graphQL/messages-api"; import { useStore } from "../../stores"; import style from "./chain-list.module.scss"; diff --git a/packages/extension/src/layouts/header/index.tsx b/packages/extension/src/layouts/header/index.tsx index 8f6b6b8503..c70f8855b7 100644 --- a/packages/extension/src/layouts/header/index.tsx +++ b/packages/extension/src/layouts/header/index.tsx @@ -1,12 +1,12 @@ import React, { FunctionComponent, ReactNode } from "react"; -import { Header as CompHeader } from "../../components/header"; +import { Header as CompHeader } from "@components/header"; import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; import style from "./style.module.scss"; -import { ToolTip } from "../../components/tooltip"; +import { ToolTip } from "@components/tooltip"; import { ChainList } from "./chain-list"; import { Menu, useMenu, MenuButton } from "../menu"; diff --git a/packages/extension/src/pages/access/basic-access.tsx b/packages/extension/src/pages/access/basic-access.tsx index 6091b1ebb3..c56778bfef 100644 --- a/packages/extension/src/pages/access/basic-access.tsx +++ b/packages/extension/src/pages/access/basic-access.tsx @@ -7,7 +7,7 @@ import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; import style from "./style.module.scss"; -import { EmptyLayout } from "../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import { FormattedMessage } from "react-intl"; export const AccessPage: FunctionComponent = observer(() => { @@ -60,7 +60,7 @@ export const AccessPage: FunctionComponent = observer(() => { <EmptyLayout style={{ height: "100%", paddingTop: "80px" }}> <div className={style.container}> <img - src={require("../../public/assets/temp-icon.svg")} + src={require("@assets/temp-icon.svg")} alt="logo" style={{ height: "92px" }} /> diff --git a/packages/extension/src/pages/access/viewing-key.tsx b/packages/extension/src/pages/access/viewing-key.tsx index db7d144bf6..2757d0292b 100644 --- a/packages/extension/src/pages/access/viewing-key.tsx +++ b/packages/extension/src/pages/access/viewing-key.tsx @@ -9,7 +9,7 @@ import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; import style from "./style.module.scss"; -import { EmptyLayout } from "../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import { FormattedMessage } from "react-intl"; export const Secret20ViewingKeyAccessPage: FunctionComponent = observer(() => { @@ -48,7 +48,7 @@ export const Secret20ViewingKeyAccessPage: FunctionComponent = observer(() => { <EmptyLayout style={{ height: "100%", paddingTop: "80px" }}> <div className={style.container}> <img - src={require("../../public/assets/temp-icon.svg")} + src={require("@assets/temp-icon.svg")} alt="logo" style={{ height: "92px" }} /> diff --git a/packages/extension/src/pages/activity/index.tsx b/packages/extension/src/pages/activity/index.tsx index 07c56d19cb..3998e649a1 100644 --- a/packages/extension/src/pages/activity/index.tsx +++ b/packages/extension/src/pages/activity/index.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent } from "react"; import { useHistory } from "react-router"; -import { SwitchUser } from "../../components/switch-user"; -import { HeaderLayout } from "../../layouts"; +import { SwitchUser } from "@components/switch-user"; +import { HeaderLayout } from "@layouts/index"; export const ActivityPage: FunctionComponent = () => { const history = useHistory(); diff --git a/packages/extension/src/pages/chain/suggest/index.tsx b/packages/extension/src/pages/chain/suggest/index.tsx index fc069e5f3e..044436c7c4 100644 --- a/packages/extension/src/pages/chain/suggest/index.tsx +++ b/packages/extension/src/pages/chain/suggest/index.tsx @@ -3,7 +3,7 @@ import { useHistory } from "react-router"; import { Button, Alert } from "reactstrap"; import style from "./style.module.scss"; -import { EmptyLayout } from "../../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import { FormattedMessage } from "react-intl"; import { useInteractionInfo } from "@keplr-wallet/hooks"; import { observer } from "mobx-react-lite"; @@ -32,7 +32,7 @@ export const ChainSuggestedPage: FunctionComponent = observer(() => { <EmptyLayout style={{ height: "100%", paddingTop: "80px" }}> <div className={style.container}> <img - src={require("../../../public/assets/temp-icon.svg")} + src={require("@assets/temp-icon.svg")} alt="logo" style={{ height: "92px" }} /> @@ -55,7 +55,7 @@ export const ChainSuggestedPage: FunctionComponent = observer(() => { <Alert className={style.warning} color="warning"> <div className={style.imgContainer}> <img - src={require("../../../public/assets/img/icons8-test-tube.svg")} + src={require("@assets/img/icons8-test-tube.svg")} alt="experiment" /> </div> diff --git a/packages/extension/src/pages/chatSection/chats-view-section.tsx b/packages/extension/src/pages/chat-section/chats-view-section.tsx similarity index 67% rename from packages/extension/src/pages/chatSection/chats-view-section.tsx rename to packages/extension/src/pages/chat-section/chats-view-section.tsx index e9f131dd26..10b5b4e7d4 100644 --- a/packages/extension/src/pages/chatSection/chats-view-section.tsx +++ b/packages/extension/src/pages/chat-section/chats-view-section.tsx @@ -11,27 +11,18 @@ import { useSelector } from "react-redux"; import { useHistory } from "react-router"; import ReactTextareaAutosize from "react-textarea-autosize"; import { InputGroup } from "reactstrap"; -import { - Group, - GroupAddress, - Groups, - MessagesState, - userChatGroups, - userMessages, -} from "../../chatStore/messages-slice"; -import { userDetails } from "../../chatStore/user-slice"; -import { ChatMessage } from "../../components/chatMessage"; -import { ToolTip } from "../../components/tooltip"; +import { Chats, Group, GroupAddress, Groups } from "@chatTypes"; +import { userChatGroups, userMessages } from "@chatStore/messages-slice"; +import { userDetails } from "@chatStore/user-slice"; +import { ChatMessage } from "@components/chat-message"; +import { ToolTip } from "@components/tooltip"; import { CHAT_PAGE_COUNT } from "../../config.ui.var"; -import { - deliverMessages, - updateGroupTimestamp, -} from "../../graphQL/messages-api"; -import { recieveGroups, recieveMessages } from "../../graphQL/recieve-messages"; -import { useOnScreen } from "../../hooks/use-on-screen"; -import paperAirplaneIcon from "../../public/assets/icon/paper-airplane.png"; +import { deliverMessages, updateGroupTimestamp } from "@graphQL/messages-api"; +import { recieveGroups, recieveMessages } from "@graphQL/recieve-messages"; +import { useOnScreen } from "@hooks/use-on-screen"; +import paperAirplaneIcon from "@assets/icon/paper-airplane.png"; import { useStore } from "../../stores"; -import { decryptGroupTimestamp } from "../../utils/decrypt-group"; +import { decryptGroupTimestamp } from "@utils/decrypt-group"; import { NewUserSection } from "./new-user-section"; import style from "./style.module.scss"; @@ -53,7 +44,7 @@ export const ChatsViewSection = ({ let enterKeyCount = 0; const user = useSelector(userDetails); const userGroups: Groups = useSelector(userChatGroups); - const userChats: MessagesState = useSelector(userMessages); + const userChats: Chats = useSelector(userMessages); const { chainStore, accountStore } = useStore(); const current = chainStore.current; @@ -78,17 +69,12 @@ export const ChatsViewSection = ({ const [loadingMessages, setLoadingMessages] = useState(false); const [newMessage, setNewMessage] = useState(""); + const [lastUnreadMesageId, setLastUnreadMesageId] = useState(""); - //Scrolling Logic - // const messagesEndRef: any = useRef(); const messagesStartRef: any = createRef(); const messagesScrollRef: any = useRef(null); const isOnScreen = useOnScreen(messagesStartRef); - // const scrollToBottom = () => { - // if (messagesEndRef.current) messagesEndRef.current.scrollIntoView(true); - // }; - useEffect(() => { const updatedMessages = Object.values(preLoadedChats?.messages).sort( (a, b) => { @@ -97,8 +83,7 @@ export const ChatsViewSection = ({ ); setMessages(updatedMessages); - if (preLoadedChats && preLoadedChats.pagination) - setPagination(preLoadedChats.pagination); + setPagination(preLoadedChats.pagination); const lastMessage = updatedMessages && updatedMessages.length > 0 @@ -124,18 +109,47 @@ export const ChatsViewSection = ({ } }, [preLoadedChats]); - const recieveData = async (tempGroup: Group | undefined) => { - const groupAdd = { - ...tempGroup?.addresses.find((val) => val?.address == targetAddress), - }; - - const groupAddress = { ...groupAdd }; + // const recieveData = async (tempGroup: Group | undefined) => { + // const groupAdd = { + // ...tempGroup?.addresses.find((val) => val?.address == targetAddress), + // }; + + // const groupAddress = { ...groupAdd }; + // if (groupAddress && groupAddress.groupLastSeenTimestamp) { + // const data = await decryptGroupTimestamp( + // current.chainId, + // groupAddress.groupLastSeenTimestamp, + // false + // ); + // Object.assign(groupAddress, { + // groupLastSeenTimestamp: new Date(data).getTime(), + // }); + // } + // if (groupAddress && groupAddress.lastSeenTimestamp) { + // const data = await decryptGroupTimestamp( + // current.chainId, + // groupAddress.lastSeenTimestamp, + // false + // ); + + // Object.assign(groupAddress, { + // lastSeenTimestamp: new Date(data).getTime(), + // }); + // } + + // return groupAddress; + // }; + const decryptGrpAddresses = async ( + groupAddress: GroupAddress, + isSender: boolean + ) => { if (groupAddress && groupAddress.groupLastSeenTimestamp) { const data = await decryptGroupTimestamp( current.chainId, groupAddress.groupLastSeenTimestamp, - false + isSender ); + Object.assign(groupAddress, { groupLastSeenTimestamp: new Date(data).getTime(), }); @@ -144,9 +158,8 @@ export const ChatsViewSection = ({ const data = await decryptGroupTimestamp( current.chainId, groupAddress.lastSeenTimestamp, - false + isSender ); - Object.assign(groupAddress, { lastSeenTimestamp: new Date(data).getTime(), }); @@ -155,6 +168,39 @@ export const ChatsViewSection = ({ return groupAddress; }; + const decryptGrp = async (group: Group) => { + const tempGroup = { ...group }; + let tempSenderAddress: GroupAddress | undefined; + let tempReceiverAddress: GroupAddress | undefined; + + /// Shallow copy + /// Decrypting sender data + const senderAddress = { + ...group.addresses.find((val) => val.address !== targetAddress), + }; + if (senderAddress) + tempSenderAddress = await decryptGrpAddresses( + senderAddress as GroupAddress, + true + ); + + /// Decrypting receiver data + const receiverAddress = { + ...group.addresses.find((val) => val.address === targetAddress), + }; + if (receiverAddress) + tempReceiverAddress = await decryptGrpAddresses( + receiverAddress as GroupAddress, + false + ); + + /// Storing decryptin address into the group object and updating the UI + if (tempSenderAddress && tempReceiverAddress) { + const tempGroupAddress = [tempSenderAddress, tempReceiverAddress]; + tempGroup.addresses = tempGroupAddress; + setGroup(tempGroup); + } + }; useEffect(() => { /// Shallow copy const tempGroup = { @@ -162,18 +208,18 @@ export const ChatsViewSection = ({ group.id.includes(targetAddress) ), }; - - recieveData(tempGroup as Group).then((groupAddress) => { - const sample = (tempGroup as Group)?.addresses.map((value) => { - if (value.address === targetAddress) { - return groupAddress; - } - return value; - }); - if (tempGroup) tempGroup.addresses = sample as GroupAddress[]; - - setGroup(tempGroup as Group); - }); + decryptGrp(tempGroup as Group); + // recieveData(tempGroup as Group).then((groupAddress) => { + // const sample = (tempGroup as Group)?.addresses.map((value) => { + // if (value.address === targetAddress) { + // return groupAddress; + // } + // return value; + // }); + // if (tempGroup) tempGroup.addresses = sample as GroupAddress[]; + + // setGroup(tempGroup as Group); + // }); }, [userGroups]); const messagesEndRef: any = useCallback( @@ -213,6 +259,7 @@ export const ChatsViewSection = ({ ? receiver?.lastSeenTimestamp : null, page, + group.isDm, group.id ); setLoadingMessages(false); @@ -242,6 +289,18 @@ export const ChatsViewSection = ({ const receiver = group?.addresses.find( (val) => val.address === targetAddress ); + useEffect(() => { + const time = group?.addresses.find((val) => val.address !== targetAddress) + ?.lastSeenTimestamp; + if (lastUnreadMesageId === "") { + const firstMessageUnseen = messages + .filter((message) => message.commitTimestamp > Number(time)) + .sort(); + if (firstMessageUnseen.length > 0) { + setLastUnreadMesageId(firstMessageUnseen[0].id); + } + } + }, [messages, group]); const handleSendMessage = async (e: any) => { e.preventDefault(); @@ -258,6 +317,7 @@ export const ChatsViewSection = ({ if (message) { const updatedMessagesList = [...messages, message]; setMessages(updatedMessagesList); + setLastUnreadMesageId(""); setNewMessage(""); } // scrollToBottom(); @@ -290,11 +350,6 @@ export const ChatsViewSection = ({ }`} > <div className={style.messages}> - {pagination?.lastPage > pagination?.page && ( - <div ref={messagesStartRef} className={style.loader}> - Fetching older Chats <i className="fas fa-spinner fa-spin ml-2" /> - </div> - )} {pagination?.lastPage <= pagination?.page && ( <> {isNewUser && ( @@ -309,6 +364,14 @@ export const ChatsViewSection = ({ </p> </> )} + {pagination?.lastPage > pagination?.page && + (pagination?.page === -1 || + messages.length === 30 || + messages.length == 0) && ( + <div ref={messagesStartRef} className={style.loader}> + Fetching older Chats <i className="fas fa-spinner fa-spin ml-2" /> + </div> + )} {messages?.map((message: any, index) => { const check = showDateFunction(message?.commitTimestamp); return ( @@ -318,7 +381,7 @@ export const ChatsViewSection = ({ chainId={current.chainId} showDate={check} message={message?.contents} - isSender={message?.target === targetAddress} // if target was the user we are chatting with + isSender={message?.sender === accountInfo.bech32Address} // if I am the sender of this message timestamp={message?.commitTimestamp || 1549312452} groupLastSeenTimestamp={ receiver && receiver.groupLastSeenTimestamp @@ -333,12 +396,18 @@ export const ChatsViewSection = ({ Number(message?.commitTimestamp) > Number(receiver?.lastSeenTimestamp) && message?.sender === targetAddress && ( - <div ref={messagesEndRef} className={messagesEndRef} /> + <div className={messagesEndRef} /> //ref={messagesEndRef} )} + {lastUnreadMesageId === message.id && ( + <div ref={messagesEndRef} className={"AAAAA"} /> + )} </div> ); })} - <div ref={messagesEndRef} className={"AAAAA"} /> + + {lastUnreadMesageId === "" && ( + <div ref={messagesEndRef} className={"AAAAA"} /> + )} </div> <InputGroup className={style.inputText}> @@ -377,7 +446,7 @@ export const ChatsViewSection = ({ className={style["send-message-icon"]} onClick={handleSendMessage} > - <img src={paperAirplaneIcon} alt="" /> + <img draggable={false} src={paperAirplaneIcon} alt="" /> </div> ) : ( "" diff --git a/packages/extension/src/pages/chatSection/index.tsx b/packages/extension/src/pages/chat-section/index.tsx similarity index 78% rename from packages/extension/src/pages/chatSection/index.tsx rename to packages/extension/src/pages/chat-section/index.tsx index 5b43d3a8aa..cebfb1112b 100644 --- a/packages/extension/src/pages/chatSection/index.tsx +++ b/packages/extension/src/pages/chat-section/index.tsx @@ -7,26 +7,25 @@ import { import React, { FunctionComponent, useEffect, useState } from "react"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { userBlockedAddresses } from "../../chatStore/messages-slice"; -import { userDetails } from "../../chatStore/user-slice"; -import { ChatErrorPopup } from "../../components/chat-error-popup"; -import { ChatLoader } from "../../components/chat-loader"; -import { SwitchUser } from "../../components/switch-user"; +import { userBlockedAddresses } from "@chatStore/messages-slice"; +import { userDetails } from "@chatStore/user-slice"; +import { ChatActionsPopup } from "@components/chat-actions-popup"; +import { ChatErrorPopup } from "@components/chat-error-popup"; +import { ChatLoader } from "@components/chat-loader"; +import { SwitchUser } from "@components/switch-user"; import { EthereumEndpoint } from "../../config.ui"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useStore } from "../../stores"; -import { fetchPublicKey } from "../../utils/fetch-public-key"; +import { fetchPublicKey } from "@utils/fetch-public-key"; import { Menu } from "../main/menu"; -import { ActionsPopup } from "./actions-popup"; - -import { Dropdown } from "./chat-actions-popup"; +import { ChatActionsDropdown } from "@components/chat-actions-dropdown"; import { ChatsViewSection } from "./chats-view-section"; import { UserNameSection } from "./username-section"; export const openValue = true; export const ChatSection: FunctionComponent = () => { const history = useHistory(); - const userName = history.location.pathname.split("/")[2]; + const targetAddress = history.location.pathname.split("/")[2]; const blockedUsers = useSelector(userBlockedAddresses); const user = useSelector(userDetails); @@ -75,7 +74,7 @@ export const ChatSection: FunctionComponent = () => { const contactName = (addresses: any) => { let val = ""; for (let i = 0; i < addresses.length; i++) { - if (addresses[i].address == userName) { + if (addresses[i].address == targetAddress) { val = addresses[i].name; } } @@ -97,16 +96,16 @@ export const ChatSection: FunctionComponent = () => { const pubAddr = await fetchPublicKey( user.accessToken, current.chainId, - userName + targetAddress ); setTargetPubKey(pubAddr?.publicKey || ""); }; setPublicAddress(); - }, [user.accessToken, current.chainId, userName]); + }, [user.accessToken, current.chainId, targetAddress]); const isNewUser = (): boolean => { const addressExists = addresses.find( - (item: any) => item.address === userName + (item: any) => item.address === targetAddress ); return !Boolean(addressExists); }; @@ -127,23 +126,26 @@ export const ChatSection: FunctionComponent = () => { handleDropDown={handleDropDown} addresses={addresses} /> - <Dropdown + <ChatActionsDropdown added={contactName(addresses).length > 0} showDropdown={showDropdown} handleClick={handleClick} - blocked={blockedUsers[userName]} + blocked={blockedUsers[targetAddress]} /> <ChatsViewSection isNewUser={isNewUser()} - isBlocked={blockedUsers[userName]} + isBlocked={blockedUsers[targetAddress]} targetPubKey={targetPubKey} setLoadingChats={setLoadingChats} handleClick={handleClick} /> {confirmAction && ( - <ActionsPopup action={action} setConfirmAction={setConfirmAction} /> + <ChatActionsPopup + action={action} + setConfirmAction={setConfirmAction} + /> )} </div> )} diff --git a/packages/extension/src/pages/chatSection/new-user-section.tsx b/packages/extension/src/pages/chat-section/new-user-section.tsx similarity index 95% rename from packages/extension/src/pages/chatSection/new-user-section.tsx rename to packages/extension/src/pages/chat-section/new-user-section.tsx index 56a62834fb..51dd00ce53 100644 --- a/packages/extension/src/pages/chatSection/new-user-section.tsx +++ b/packages/extension/src/pages/chat-section/new-user-section.tsx @@ -3,7 +3,7 @@ import amplitude from "amplitude-js"; import React from "react"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { userBlockedAddresses } from "../../chatStore/messages-slice"; +import { userBlockedAddresses } from "@chatStore/messages-slice"; import style from "./style.module.scss"; export const NewUserSection = ({ diff --git a/packages/extension/src/pages/chat-section/style.module.scss b/packages/extension/src/pages/chat-section/style.module.scss new file mode 100644 index 0000000000..6f96f8da3f --- /dev/null +++ b/packages/extension/src/pages/chat-section/style.module.scss @@ -0,0 +1,302 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: start; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; + word-break: break-word; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + cursor: pointer; + top: 30%; + z-index: 2; + // 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; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} diff --git a/packages/extension/src/pages/chatSection/username-section.tsx b/packages/extension/src/pages/chat-section/username-section.tsx similarity index 74% rename from packages/extension/src/pages/chatSection/username-section.tsx rename to packages/extension/src/pages/chat-section/username-section.tsx index 6f01547638..d8001249d2 100644 --- a/packages/extension/src/pages/chatSection/username-section.tsx +++ b/packages/extension/src/pages/chat-section/username-section.tsx @@ -2,14 +2,13 @@ import React from "react"; import { useIntl } from "react-intl"; import { useHistory } from "react-router"; -import { useNotification } from "../../components/notification"; -import { ToolTip } from "../../components/tooltip"; -import chevronLeft from "../../public/assets/icon/chevron-left.png"; -import moreIcon from "../../public/assets/icon/more-grey.png"; -import { formatAddress } from "../../utils/format"; +import { useNotification } from "@components/notification"; +import { ToolTip } from "@components/tooltip"; +import chevronLeft from "@assets/icon/chevron-left.png"; +import moreIcon from "@assets/icon/more-grey.png"; +import { formatAddress } from "@utils/format"; import style from "./style.module.scss"; -export let openValue = true; export const UserNameSection = ({ handleDropDown, addresses, @@ -54,21 +53,17 @@ export const UserNameSection = ({ <div className={style.leftBox}> <img alt="" + draggable="false" className={style.backBtn} src={chevronLeft} onClick={() => { history.goBack(); - openValue = false; }} /> <span className={style.recieverName}> <ToolTip tooltip={ - <div className={style.user} style={{ minWidth: "300px" }}> - {contactName(addresses).length - ? contactName(addresses) - : userName} - </div> + contactName(addresses).length ? contactName(addresses) : userName } theme="dark" trigger="hover" @@ -76,9 +71,11 @@ export const UserNameSection = ({ placement: "top", }} > - {contactName(addresses).length - ? formatAddress(contactName(addresses)) - : formatAddress(userName)} + <div className={style.user}> + {contactName(addresses).length + ? formatAddress(contactName(addresses)) + : formatAddress(userName)} + </div> </ToolTip> </span> <span className={style.copyIcon} onClick={() => copyAddress(userName)}> @@ -88,6 +85,7 @@ export const UserNameSection = ({ <div className={style.rightBox}> <img alt="" + draggable="false" style={{ cursor: "pointer" }} className={style.more} src={moreIcon} diff --git a/packages/extension/src/pages/chat/chat-group-history.tsx b/packages/extension/src/pages/chat/chat-group-history.tsx new file mode 100644 index 0000000000..1a46842239 --- /dev/null +++ b/packages/extension/src/pages/chat/chat-group-history.tsx @@ -0,0 +1,206 @@ +import React, { createRef, useEffect, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import { useHistory } from "react-router"; +import { + userChatGroupPagination, + userChatGroups, +} from "@chatStore/messages-slice"; +import { recieveGroups } from "@graphQL/recieve-messages"; +import { useOnScreen } from "@hooks/use-on-screen"; +import { useStore } from "../../stores"; +import { formatAddress } from "@utils/format"; +import style from "./style.module.scss"; +import { userDetails } from "@chatStore/user-slice"; +import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; +import { Groups, NameAddress, Pagination } from "@chatTypes"; +import { ChatUser } from "./chat-user"; +import { ChatGroupUser } from "./chat-group-user"; + +export const ChatsGroupHistory: React.FC<{ + chainId: string; + searchString: string; + addresses: NameAddress; + setLoadingChats: any; +}> = ({ chainId, addresses, setLoadingChats, searchString }) => { + const history = useHistory(); + const userState = useSelector(userDetails); + const groups: Groups = useSelector(userChatGroups); + const groupsPagination: Pagination = useSelector(userChatGroupPagination); + const [loadingGroups, setLoadingGroups] = useState(false); + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + + //Scrolling Logic + const messagesEndRef: any = createRef(); + const messagesEncRef: any = useRef(null); + const isOnScreen = useOnScreen(messagesEndRef); + + useEffect(() => { + const getChats = async () => { + await loadUserGroups(); + messagesEncRef.current.scrollIntoView(true); + }; + if (isOnScreen) getChats(); + }, [isOnScreen]); + + const loadUserGroups = async () => { + if (!loadingGroups) { + const page = groupsPagination?.page + 1 || 0; + setLoadingGroups(true); + await recieveGroups(page, accountInfo.bech32Address); + setLoadingGroups(false); + setLoadingChats(false); + } + }; + + const filterGroups = (contact: string) => { + const searchValue = searchString.trim(); + const group = groups[contact]; + + /// For Group search + if (!group.isDm) { + if (searchValue.length > 0) { + return group.name.toLowerCase().includes(searchValue.toLowerCase()); + } + + return true; + } + + /// For DM + const contactAddressBookName = addresses[contact]; + + if (userState?.messagingPubKey.privacySetting === PrivacySetting.Contacts) { + if (searchString.length > 0) { + if ( + !contactAddressBookName + ?.toLowerCase() + .includes(searchValue.toLowerCase()) + ) + return false; + } + + return !!contactAddressBookName; + } else { + /// PrivacySetting.Everybody + if (searchString.length > 0) { + if ( + !contactAddressBookName + ?.toLowerCase() + .includes(searchValue.toLowerCase()) && + !contact.toLowerCase().includes(searchValue.toLowerCase()) + ) + return false; + } + return true; + } + }; + + if (!Object.keys(groups).length) + return ( + <div className={style.groupsArea}> + <div className={style.resultText}> + No results. Don't worry you can create a new chat by clicking on + the icon beside the search box. + </div> + </div> + ); + + if ( + !Object.keys(groups).filter((contact) => filterGroups(contact)).length && + userState.messagingPubKey.privacySetting && + userState.messagingPubKey.privacySetting === PrivacySetting.Contacts + ) + return ( + <div className={style.groupsArea}> + <div className={style.resultText}> + If you are searching for an address not in your address book, you + can't see them due to your selected privacy settings being + "contact only". Please add the address to your address book + to be able to chat with them or change your privacy settings. + <br /> + <a + href="#" + style={{ + textDecoration: "underline", + }} + onClick={(e) => { + e.preventDefault(); + history.push("/setting/chat/privacy"); + }} + > + Go to chat privacy settings + </a> + </div> + </div> + ); + + if (!Object.keys(groups).filter((contact) => filterGroups(contact)).length) + return ( + <div className={style.groupsArea}> + <div className={style.resultText}> + No results found. Please refine your search. + </div> + </div> + ); + + return ( + <div className={style.groupsArea}> + {Object.keys(groups) + .sort( + (a, b) => + parseFloat(groups[b].lastMessageTimestamp) - + parseFloat(groups[a].lastMessageTimestamp) + ) + .filter((contact) => filterGroups(contact)) + .map((contact, index) => { + // translate the contact address into the address book name if it exists + const contactAddressBookName = addresses[contact]; + + if (groups[contact].isDm) + return ( + <div key={groups[contact].id}> + <ChatUser + group={groups[contact]} + contactName={ + contactAddressBookName + ? formatAddress(contactAddressBookName) + : formatAddress(contact) + } + targetAddress={contact} + chainId={chainId} + /> + {index === Object.keys(groups).length - 10 && ( + <div ref={messagesEncRef} /> + )} + </div> + ); + + const groupAddresses = groups[contact].addresses; + const userGroupAddress = groupAddresses.find( + (address) => address.address == accountInfo.bech32Address + ); + const encryptedSymmetricKey = + userGroupAddress?.encryptedSymmetricKey || ""; + return ( + <div key={groups[contact].id}> + <ChatGroupUser + chainId={chainId} + encryptedSymmetricKey={encryptedSymmetricKey} + group={groups[contact]} + addresses={addresses} + /> + {index === Object.keys(groups).length - 10 && ( + <div ref={messagesEncRef} /> + )} + </div> + ); + })} + {groupsPagination?.lastPage > groupsPagination?.page && ( + <div className={style.loader} ref={messagesEndRef}> + Fetching older Chats <i className="fas fa-spinner fa-spin ml-2" /> + </div> + )} + </div> + ); +}; diff --git a/packages/extension/src/pages/chat/chat-group-user.tsx b/packages/extension/src/pages/chat/chat-group-user.tsx new file mode 100644 index 0000000000..22c1673f79 --- /dev/null +++ b/packages/extension/src/pages/chat/chat-group-user.tsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from "react"; +import { useHistory } from "react-router"; +import rightArrowIcon from "@assets/icon/right-arrow.png"; +import style from "./style.module.scss"; +import amplitude from "amplitude-js"; +import { Group, GroupMessagePayload, NameAddress } from "@chatTypes"; +import { decryptGroupMessage } from "@utils/decrypt-group"; +import { GroupMessageType } from "@utils/encrypt-group"; +import { getUserName, getEventMessage } from "@utils/index"; +import { useStore } from "../../stores"; + +export const ChatGroupUser: React.FC<{ + chainId: string; + group: Group; + encryptedSymmetricKey: string; + addresses: NameAddress; +}> = ({ chainId, group, encryptedSymmetricKey, addresses }) => { + const [ + decryptedMessage, + setDecryptedMessage, + ] = useState<GroupMessagePayload>(); + const history = useHistory(); + + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + + const handleClick = () => { + amplitude.getInstance().logEvent("Open Group click", { + from: "Chat history", + }); + history.push(`/chat/group-chat-section/${group.id}`); + }; + + useEffect(() => { + async function loadDecryptedMessage() { + const decryptedMsg = await decryptGroupMessage( + group.lastMessageContents, + chainId, + encryptedSymmetricKey + ); + setDecryptedMessage(decryptedMsg); + } + if (group) { + loadDecryptedMessage(); + } + }, [chainId, encryptedSymmetricKey, group]); + + function getLastMessage(): string { + /// Show last message if user removed/leave from group + if (group.removedAt) { + return "You left or have been removed"; + } + + /// Show event type last message + if ( + decryptedMessage && + (decryptedMessage.type == GroupMessageType.event.toString() || + decryptedMessage.type === GroupMessageType[GroupMessageType.event]) + ) { + return getEventMessage( + accountInfo.bech32Address, + addresses, + decryptedMessage.message + ); + } + + /// Show last message + return `${getUserName( + accountInfo.bech32Address, + addresses, + group.lastMessageSender + )}: ${decryptedMessage?.message}`; + } + + return ( + <div + className={style.group} + style={{ position: "relative" }} + onClick={handleClick} + > + {/* {Number(sender?.lastSeenTimestamp) < + Number(receiver?.lastSeenTimestamp) && + group.lastMessageSender === targetAddress && + Number(group.lastMessageTimestamp) > + Number(sender?.lastSeenTimestamp) && ( + <span + style={{ + height: "12px", + width: "12px", + backgroundColor: "#d027e5", + borderRadius: "20px", + bottom: "20px", + left: "6px", + position: "absolute", + zIndex: 1, + }} + /> + )} */} + <div className={style.initials}> + <img + className={style.groupImage} + src={require("@assets/group710.svg")} + /> + </div> + <div className={style.messageInner}> + <div className={style.name}>{group.name}</div> + <div className={style.messageText}>{getLastMessage()}</div> + </div> + <div> + <img + draggable={false} + src={rightArrowIcon} + style={{ width: "80%" }} + alt="message" + /> + </div> + </div> + ); +}; diff --git a/packages/extension/src/pages/chat/chat-user.tsx b/packages/extension/src/pages/chat/chat-user.tsx new file mode 100644 index 0000000000..09b0ef6629 --- /dev/null +++ b/packages/extension/src/pages/chat/chat-user.tsx @@ -0,0 +1,167 @@ +import { fromBech32 } from "@cosmjs/encoding"; +import jazzicon from "@metamask/jazzicon"; +import React, { useEffect, useState } from "react"; +import ReactHtmlParser from "react-html-parser"; +import { useHistory } from "react-router"; +import rightArrowIcon from "@assets/icon/right-arrow.png"; +import { decryptGroupTimestamp } from "@utils/decrypt-group"; +import { decryptMessage } from "@utils/decrypt-message"; +import style from "./style.module.scss"; +import amplitude from "amplitude-js"; +import { Group, GroupAddress } from "@chatTypes"; + +export const ChatUser: React.FC<{ + chainId: string; + group: Group; + contactName: string; + targetAddress: string; +}> = ({ chainId, group, contactName, targetAddress }) => { + const [message, setMessage] = useState(""); + const [groupData, setGroupData] = useState(group); + + const history = useHistory(); + + const handleClick = () => { + amplitude.getInstance().logEvent("Open DM click", { + from: "Chat history", + }); + history.push(`/chat/${targetAddress}`); + }; + + /// Current wallet user + const sender = groupData?.addresses.find( + (val) => val?.address !== targetAddress + ); + /// Target user + const receiver = groupData?.addresses.find( + (val) => val?.address === targetAddress + ); + + const decryptGrpAddresses = async ( + groupAddress: GroupAddress, + isSender: boolean + ) => { + if (groupAddress && groupAddress.groupLastSeenTimestamp) { + const data = await decryptGroupTimestamp( + chainId, + groupAddress.groupLastSeenTimestamp, + isSender + ); + + Object.assign(groupAddress, { + groupLastSeenTimestamp: new Date(data).getTime(), + }); + } + if (groupAddress && groupAddress.lastSeenTimestamp) { + const data = await decryptGroupTimestamp( + chainId, + groupAddress.lastSeenTimestamp, + isSender + ); + Object.assign(groupAddress, { + lastSeenTimestamp: new Date(data).getTime(), + }); + } + + return groupAddress; + }; + + const decryptGrp = async (group: Group) => { + const tempGroup = { ...group }; + let tempSenderAddress: GroupAddress | undefined; + let tempReceiverAddress: GroupAddress | undefined; + + /// Shallow copy + /// Decrypting sender data + const senderAddress = { + ...group.addresses.find((val) => val.address !== targetAddress), + }; + if (senderAddress) + tempSenderAddress = await decryptGrpAddresses( + senderAddress as GroupAddress, + group.lastMessageSender === targetAddress + ); + + /// Decrypting receiver data + const receiverAddress = { + ...group.addresses.find((val) => val.address === targetAddress), + }; + if (receiverAddress) + tempReceiverAddress = await decryptGrpAddresses( + receiverAddress as GroupAddress, + group.lastMessageSender !== targetAddress + ); + + /// Storing decryptin address into the group object and updating the UI + if (tempSenderAddress && tempReceiverAddress) { + const tempGroupAddress = [tempSenderAddress, tempReceiverAddress]; + tempGroup.addresses = tempGroupAddress; + setGroupData(tempGroup); + } + }; + + const decryptMsg = async ( + chainId: string, + contents: string, + isSender: boolean + ) => { + const message = await decryptMessage(chainId, contents, isSender); + setMessage(message.content.text); + }; + + useEffect(() => { + if (group) { + decryptMsg( + chainId, + group.lastMessageContents, + group.lastMessageSender !== targetAddress + ); + decryptGrp(group); + } + }, [chainId, targetAddress, group]); + + return ( + <div + className={style.group} + style={{ position: "relative" }} + onClick={handleClick} + > + {Number(sender?.lastSeenTimestamp) < + Number(receiver?.lastSeenTimestamp) && + group.lastMessageSender === targetAddress && + Number(group.lastMessageTimestamp) > + Number(sender?.lastSeenTimestamp) && ( + <span + style={{ + height: "12px", + width: "12px", + backgroundColor: "#d027e5", + borderRadius: "20px", + bottom: "22px", + left: "8px", + position: "absolute", + zIndex: 1, + }} + /> + )} + <div className={style.initials}> + {ReactHtmlParser( + jazzicon(28, parseInt(fromBech32(targetAddress).data.toString(), 16)) + .outerHTML + )} + </div> + <div className={style.messageInner}> + <div className={style.name}>{contactName}</div> + <div className={style.messageText}>{message}</div> + </div> + <div> + <img + draggable={false} + src={rightArrowIcon} + style={{ width: "80%" }} + alt="message" + /> + </div> + </div> + ); +}; diff --git a/packages/extension/src/pages/chat/index.tsx b/packages/extension/src/pages/chat/index.tsx index a0d379f6e7..2d1c0c017f 100644 --- a/packages/extension/src/pages/chat/index.tsx +++ b/packages/extension/src/pages/chat/index.tsx @@ -9,38 +9,39 @@ import { import { InExtensionMessageRequester } from "@keplr-wallet/router-extension"; import React, { FunctionComponent, useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { store } from "../../chatStore"; +import { NameAddress } from "@chatTypes"; +import { store } from "@chatStore/index"; import { setMessageError, userChatStorePopulated, userChatSubscriptionActive, -} from "../../chatStore/messages-slice"; +} from "@chatStore/messages-slice"; import { setAccessToken, setMessagingPubKey, userDetails, -} from "../../chatStore/user-slice"; -import { ChatInitPopup } from "../../components/chat/chat-init-popup"; -import { ChatSearchInput } from "../../components/chat/chat-search-input"; -import { DeactivatedChat } from "../../components/chat/deactivated-chat"; -import { SwitchUser } from "../../components/switch-user"; +} from "@chatStore/user-slice"; +import { ChatErrorPopup } from "@components/chat-error-popup"; +import { ChatLoader } from "@components/chat-loader"; +import { ChatInitPopup } from "@components/chat/chat-init-popup"; +import { ChatSearchInput } from "@components/chat/chat-search-input"; +import { DeactivatedChat } from "@components/chat/deactivated-chat"; +import { SwitchUser } from "@components/switch-user"; import { EthereumEndpoint } from "../../config.ui"; import { AUTH_SERVER } from "../../config.ui.var"; import { fetchBlockList, - groupReadUnreadListener, + groupsListener, messageListener, -} from "../../graphQL/messages-api"; -import { recieveGroups } from "../../graphQL/recieve-messages"; -import { HeaderLayout } from "../../layouts"; +} from "@graphQL/messages-api"; +import { recieveGroups } from "@graphQL/recieve-messages"; +import { HeaderLayout } from "@layouts/index"; import { useStore } from "../../stores"; -import { getJWT } from "../../utils/auth"; -import { fetchPublicKey } from "../../utils/fetch-public-key"; +import { getJWT } from "@utils/auth"; +import { fetchPublicKey } from "@utils/fetch-public-key"; import { Menu } from "../main/menu"; import style from "./style.module.scss"; -import { ChatsGroupSection, NameAddress } from "./users"; -import { ChatLoader } from "../../components/chat-loader"; -import { ChatErrorPopup } from "../../components/chat-error-popup"; +import { ChatsGroupHistory } from "./chat-group-history"; const ChatView = () => { const userState = useSelector(userDetails); @@ -104,8 +105,8 @@ const ChatView = () => { setLoadingChats(true); try { if (!chatSubscriptionActive) { - groupReadUnreadListener(walletAddress); - messageListener(); + groupsListener(walletAddress); + messageListener(walletAddress); } if (!chatStorePopulated) { @@ -248,7 +249,7 @@ const ChatView = () => { {loadingChats ? ( <ChatLoader message="Loading chats, please wait..." /> ) : ( - <ChatsGroupSection + <ChatsGroupHistory searchString={inputVal} setLoadingChats={setLoadingChats} chainId={current.chainId} diff --git a/packages/extension/src/pages/chat/style.module.scss b/packages/extension/src/pages/chat/style.module.scss index f31a98fd3c..75e6199bd1 100644 --- a/packages/extension/src/pages/chat/style.module.scss +++ b/packages/extension/src/pages/chat/style.module.scss @@ -4,7 +4,9 @@ margin-left: -8px; margin-right: -12px; margin-bottom: -12px; + padding-top: 5px; height: auto; + .title { text-align: left; font-size: 20px; @@ -22,6 +24,7 @@ gap: 10px; overflow-y: auto; overflow-x: clip; + .group { display: flex; justify-content: space-between; @@ -29,9 +32,17 @@ padding: 0 12px; cursor: pointer; + .groupImage { + text-align: center; + align-self: center; + width: 28px; + height: 28px; + object-fit: cover; + } + .initials { - width: 24px; - height: 24px; + width: 28px; + height: 28px; border-radius: 50%; background-color: #d0def5; color: #525f7f; @@ -43,6 +54,7 @@ align-items: center; justify-content: center; position: relative; + .unread { position: absolute; top: -4px; @@ -53,13 +65,19 @@ border-radius: 50%; } } + .messageInner { width: 80%; + .name { font-weight: 400; font-size: 15px; color: #525f7f; height: 20px; + overflow: hidden; + text-overflow: ellipsis; + line-height: 21px; + max-height: 48px; } .messageText { text-overflow: ellipsis; @@ -73,6 +91,7 @@ } } } + .resultText { color: #808da0; font-weight: 400; diff --git a/packages/extension/src/pages/chat/users.tsx b/packages/extension/src/pages/chat/users.tsx deleted file mode 100644 index 9e4ad90c71..0000000000 --- a/packages/extension/src/pages/chat/users.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { fromBech32 } from "@cosmjs/encoding"; -import jazzicon from "@metamask/jazzicon"; -import React, { createRef, useEffect, useRef, useState } from "react"; -import ReactHtmlParser from "react-html-parser"; -import { useSelector } from "react-redux"; -import { useHistory } from "react-router"; -import { - Group, - GroupAddress, - Groups, - Pagination, - userChatGroupPagination, - userChatGroups, -} from "../../chatStore/messages-slice"; -import { recieveGroups } from "../../graphQL/recieve-messages"; -import { useOnScreen } from "../../hooks/use-on-screen"; -import rightArrowIcon from "../../public/assets/icon/right-arrow.png"; -import { useStore } from "../../stores"; -import { decryptGroupTimestamp } from "../../utils/decrypt-group"; -import { decryptMessage } from "../../utils/decrypt-message"; -import { formatAddress } from "../../utils/format"; -import style from "./style.module.scss"; -import amplitude from "amplitude-js"; -import { userDetails } from "../../chatStore/user-slice"; -import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; - -const User: React.FC<{ - chainId: string; - group: Group; - contactName: string; - targetAddress: string; -}> = ({ chainId, group, contactName, targetAddress }) => { - const [message, setMessage] = useState(""); - const [groupData, setGroupData] = useState(group); - - const history = useHistory(); - - const handleClick = () => { - amplitude.getInstance().logEvent("Open DM click", { - from: "Chat history", - }); - history.push(`/chat/${targetAddress}`); - }; - - /// Current wallet user - const sender = groupData?.addresses.find( - (val) => val?.address !== targetAddress - ); - /// Target user - const receiver = groupData?.addresses.find( - (val) => val?.address === targetAddress - ); - - const decryptGrpAddresses = async ( - groupAddress: GroupAddress, - isSender: boolean - ) => { - if (groupAddress && groupAddress.groupLastSeenTimestamp) { - const data = await decryptGroupTimestamp( - chainId, - groupAddress.groupLastSeenTimestamp, - isSender - ); - - Object.assign(groupAddress, { - groupLastSeenTimestamp: new Date(data).getTime(), - }); - } - if (groupAddress && groupAddress.lastSeenTimestamp) { - const data = await decryptGroupTimestamp( - chainId, - groupAddress.lastSeenTimestamp, - isSender - ); - Object.assign(groupAddress, { - lastSeenTimestamp: new Date(data).getTime(), - }); - } - - return groupAddress; - }; - - const decryptGrp = async (group: Group) => { - const tempGroup = { ...group }; - let tempSenderAddress: GroupAddress | undefined; - let tempReceiverAddress: GroupAddress | undefined; - - /// Shallow copy - /// Decrypting sender data - const senderAddress = { - ...group.addresses.find((val) => val.address !== targetAddress), - }; - if (senderAddress) - tempSenderAddress = await decryptGrpAddresses( - senderAddress as GroupAddress, - group.lastMessageSender === targetAddress - ); - - /// Decrypting receiver data - const receiverAddress = { - ...group.addresses.find((val) => val.address === targetAddress), - }; - if (receiverAddress) - tempReceiverAddress = await decryptGrpAddresses( - receiverAddress as GroupAddress, - group.lastMessageSender !== targetAddress - ); - - /// Storing decryptin address into the group object and updating the UI - if (tempSenderAddress && tempReceiverAddress) { - const tempGroupAddress = [tempSenderAddress, tempReceiverAddress]; - tempGroup.addresses = tempGroupAddress; - setGroupData(tempGroup); - } - }; - - const decryptMsg = async ( - chainId: string, - contents: string, - isSender: boolean - ) => { - const message = await decryptMessage(chainId, contents, isSender); - setMessage(message.content.text); - }; - - useEffect(() => { - if (group) { - decryptMsg( - chainId, - group.lastMessageContents, - group.lastMessageSender !== targetAddress - ); - decryptGrp(group); - } - }, [chainId, targetAddress, group]); - - return ( - <div - className={style.group} - style={{ position: "relative" }} - onClick={handleClick} - > - {Number(sender?.lastSeenTimestamp) < - Number(receiver?.lastSeenTimestamp) && - group.lastMessageSender === targetAddress && - Number(group.lastMessageTimestamp) > - Number(sender?.lastSeenTimestamp) && ( - <span - style={{ - height: "12px", - width: "12px", - backgroundColor: "#d027e5", - borderRadius: "20px", - bottom: "20px", - left: "6px", - position: "absolute", - zIndex: 1, - }} - /> - )} - <div className={style.initials}> - {ReactHtmlParser( - jazzicon(24, parseInt(fromBech32(targetAddress).data.toString(), 16)) - .outerHTML - )} - </div> - <div className={style.messageInner}> - <div className={style.name}>{contactName}</div> - <div className={style.messageText}>{message}</div> - </div> - <div> - <img src={rightArrowIcon} style={{ width: "80%" }} alt="message" /> - </div> - </div> - ); -}; - -export interface NameAddress { - [key: string]: string; -} - -export const ChatsGroupSection: React.FC<{ - chainId: string; - searchString: string; - addresses: NameAddress; - setLoadingChats: any; -}> = ({ chainId, addresses, setLoadingChats, searchString }) => { - const history = useHistory(); - const userState = useSelector(userDetails); - const groups: Groups = useSelector(userChatGroups); - const groupsPagination: Pagination = useSelector(userChatGroupPagination); - const [loadingGroups, setLoadingGroups] = useState(false); - const { chainStore, accountStore } = useStore(); - const current = chainStore.current; - const accountInfo = accountStore.getAccount(current.chainId); - - //Scrolling Logic - const messagesEndRef: any = createRef(); - const messagesEncRef: any = useRef(null); - const isOnScreen = useOnScreen(messagesEndRef); - - useEffect(() => { - const getChats = async () => { - await loadUserGroups(); - messagesEncRef.current.scrollIntoView(true); - }; - if (isOnScreen) getChats(); - }, [isOnScreen]); - - const loadUserGroups = async () => { - if (!loadingGroups) { - const page = groupsPagination?.page + 1 || 0; - setLoadingGroups(true); - await recieveGroups(page, accountInfo.bech32Address); - setLoadingGroups(false); - setLoadingChats(false); - } - }; - - const filterGroups = (contact: string) => { - const contactAddressBookName = addresses[contact]; - - if (userState?.messagingPubKey.privacySetting === PrivacySetting.Contacts) { - if (searchString.length > 0) { - if ( - !contactAddressBookName - ?.toLowerCase() - .includes(searchString.trim().toLowerCase()) - ) - return false; - } - - return !!contactAddressBookName; - } else { - /// PrivacySetting.Everybody - if (searchString.length > 0) { - if ( - !contactAddressBookName - ?.toLowerCase() - .includes(searchString.trim().toLowerCase()) && - !contact.toLowerCase().includes(searchString.trim().toLowerCase()) - ) - return false; - } - return true; - } - }; - - if (!Object.keys(groups).length) - return ( - <div className={style.groupsArea}> - <div className={style.resultText}> - No results. Don't worry you can create a new chat by clicking on - the icon beside the search box. - </div> - </div> - ); - - if ( - !Object.keys(groups).filter((contact) => filterGroups(contact)).length && - userState.messagingPubKey.privacySetting && - userState.messagingPubKey.privacySetting === PrivacySetting.Contacts - ) - return ( - <div className={style.groupsArea}> - <div className={style.resultText}> - If you are searching for an address not in your address book, you - can't see them due to your selected privacy settings being - "contact only". Please add the address to your address book - to be able to chat with them or change your privacy settings. - <br /> - <a - href="#" - style={{ - textDecoration: "underline", - }} - onClick={(e) => { - e.preventDefault(); - history.push("/setting/chat/privacy"); - }} - > - Go to chat privacy settings - </a> - </div> - </div> - ); - - if (!Object.keys(groups).filter((contact) => filterGroups(contact)).length) - return ( - <div className={style.groupsArea}> - <div className={style.resultText}> - No results found. Please refine your search. - </div> - </div> - ); - - return ( - <div className={style.groupsArea}> - {Object.keys(groups) - .sort( - (a, b) => - parseFloat(groups[b].lastMessageTimestamp) - - parseFloat(groups[a].lastMessageTimestamp) - ) - .filter((contact) => filterGroups(contact)) - .map((contact, index) => { - // translate the contact address into the address book name if it exists - const contactAddressBookName = addresses[contact]; - return ( - <div key={groups[contact].id}> - <User - group={groups[contact]} - contactName={ - contactAddressBookName - ? formatAddress(contactAddressBookName) - : formatAddress(contact) - } - targetAddress={contact} - chainId={chainId} - /> - {index === Object.keys(groups).length - 10 && ( - <div ref={messagesEncRef} /> - )} - </div> - ); - })} - {groupsPagination?.lastPage > groupsPagination?.page && ( - <div className={style.loader} ref={messagesEndRef}> - Fetching older Chats <i className="fas fa-spinner fa-spin ml-2" /> - </div> - )} - </div> - ); -}; diff --git a/packages/extension/src/pages/chatSection/actions-popup.tsx b/packages/extension/src/pages/chatSection/actions-popup.tsx deleted file mode 100644 index 11fc12672c..0000000000 --- a/packages/extension/src/pages/chatSection/actions-popup.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import { BlockUserPopup } from "./block-user-popup"; -import { DeleteChatPopup } from "./delete-chat-popup"; -import { UnblockUserPopup } from "./unblock-user-popup"; - -export const ActionsPopup = ({ - action, - setConfirmAction, -}: { - action: string; - setConfirmAction: React.Dispatch<React.SetStateAction<boolean>>; -}) => { - return ( - <> - {action === "block" && ( - <BlockUserPopup setConfirmAction={setConfirmAction} /> - )} - {action === "unblock" && ( - <UnblockUserPopup setConfirmAction={setConfirmAction} /> - )} - {action === "delete" && ( - <DeleteChatPopup setConfirmAction={setConfirmAction} /> - )} - </> - ); -}; diff --git a/packages/extension/src/pages/chatSection/chat-actions-popup.tsx b/packages/extension/src/pages/chatSection/chat-actions-popup.tsx deleted file mode 100644 index 74c200ac74..0000000000 --- a/packages/extension/src/pages/chatSection/chat-actions-popup.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import amplitude from "amplitude-js"; -import React from "react"; -import { useHistory } from "react-router"; -import style from "./style.module.scss"; - -export const Dropdown = ({ - added, - blocked, - showDropdown, - handleClick, -}: { - added: boolean; - blocked: boolean; - showDropdown: boolean; - handleClick: (data: string) => void; -}) => { - const history = useHistory(); - const userName = history.location.pathname.split("/")[2]; - - return ( - <> - {showDropdown && ( - <div className={style.dropdown}> - {added ? ( - <div - onClick={() => { - amplitude.getInstance().logEvent("Address book viewed", {}); - history.push("/setting/address-book"); - }} - > - View in address book - </div> - ) : ( - <div - onClick={() => { - amplitude.getInstance().logEvent("Add to address click", {}); - history.push({ - pathname: "/setting/address-book", - state: { - openModal: true, - addressInputValue: userName, - }, - }); - }} - > - Add to address book - </div> - )} - {blocked ? ( - <div - onClick={() => { - amplitude.getInstance().logEvent("Unblock click", {}); - handleClick("unblock"); - }} - > - Unblock contact - </div> - ) : ( - <div - onClick={() => { - amplitude.getInstance().logEvent("Block click", {}); - handleClick("block"); - }} - > - Block contact - </div> - )} - {/* <div onClick={() => handleClick("delete")}>Delete chat</div> */} - </div> - )} - </> - ); -}; diff --git a/packages/extension/src/pages/group-chat/add-member/index.tsx b/packages/extension/src/pages/group-chat/add-member/index.tsx new file mode 100644 index 0000000000..b32d5cc2dd --- /dev/null +++ b/packages/extension/src/pages/group-chat/add-member/index.tsx @@ -0,0 +1,413 @@ +import searchIcon from "@assets/icon/search.png"; +import { store } from "@chatStore/index"; +import { setGroups, userChatGroups } from "@chatStore/messages-slice"; +import { newGroupDetails, setNewGroupInfo } from "@chatStore/new-group-slice"; +import { userDetails } from "@chatStore/user-slice"; +import { + Group, + GroupDetails, + GroupMembers, + Groups, + NameAddress, + NewGroupDetails, +} from "@chatTypes"; +import { ChatLoader } from "@components/chat-loader"; +import { ChatMember } from "@components/chat-member"; +import { fromBech32 } from "@cosmjs/encoding"; +import { createGroup } from "@graphQL/groups-api"; +import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; +import { ExtensionKVStore } from "@keplr-wallet/common"; +import { Bech32Address } from "@keplr-wallet/cosmos"; +import { + useAddressBookConfig, + useIBCTransferConfig, +} from "@keplr-wallet/hooks"; +import { HeaderLayout } from "@layouts/index"; +import jazzicon from "@metamask/jazzicon"; +import { observer } from "mobx-react-lite"; +import React, { FunctionComponent, useEffect, useState } from "react"; +import ReactHtmlParser from "react-html-parser"; +import { useSelector } from "react-redux"; +import { useHistory } from "react-router"; +import { Button } from "reactstrap"; +import { EthereumEndpoint } from "../../../config.ui"; +import { useStore } from "../../../stores"; +import { encryptGroupMessage, GroupMessageType } from "@utils/encrypt-group"; +import { fetchPublicKey } from "@utils/fetch-public-key"; +import { formatAddress, formatGroupName } from "@utils/format"; +import { + decryptEncryptedSymmetricKey, + encryptSymmetricKey, +} from "@utils/symmetric-key"; +import style from "./style.module.scss"; +import { recieveMessages } from "@graphQL/recieve-messages"; +import { addMemberEvent } from "@utils/group-events"; +import { ToolTip } from "@components/tooltip"; +import { DeactivatedChat } from "@components/chat/deactivated-chat"; +import amplitude from "amplitude-js"; + +export const AddMember: FunctionComponent = observer(() => { + const history = useHistory(); + const user = useSelector(userDetails); + /// Current Group State + const newGroupState: NewGroupDetails = useSelector(newGroupDetails); + /// Group Info + const groups: Groups = useSelector(userChatGroups); + const group: Group = groups[newGroupState.group.groupId]; + + const [selectedMembers, setSelectedMembers] = useState<GroupMembers[]>( + newGroupState.group.members || [] + ); + const [newAddedMembers, setNewAddedMembers] = useState<string[]>([]); + + const [isLoading, setIsLoading] = useState<boolean>(false); + const [inputVal, setInputVal] = useState(""); + const [addresses, setAddresses] = useState<NameAddress[]>([]); + const [randomAddress, setRandomAddress] = useState<NameAddress | undefined>(); + + const { chainStore, accountStore, queriesStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + const walletAddress = accountInfo.bech32Address; + // address book values + const queries = queriesStore.get(chainStore.current.chainId); + const ibcTransferConfigs = useIBCTransferConfig( + chainStore, + chainStore.current.chainId, + accountInfo.msgOpts.ibcTransfer, + accountInfo.bech32Address, + queries.queryBalances, + EthereumEndpoint + ); + + const [selectedChainId] = useState( + ibcTransferConfigs.channelConfig?.channel + ? ibcTransferConfigs.channelConfig.channel.counterpartyChainId + : current.chainId + ); + const addressBookConfig = useAddressBookConfig( + new ExtensionKVStore("address-book"), + chainStore, + selectedChainId, + { + setRecipient: (): void => { + // noop + }, + setMemo: (): void => { + // noop + }, + } + ); + + const userAddresses: NameAddress[] = addressBookConfig.addressBookDatas + .filter((data) => { + if (newGroupState.isEditGroup) { + const isAlreadyMember = selectedMembers.find( + (element) => element.address === data.address + ); + /// removing already added member + if (!isAlreadyMember) return { name: data.name, address: data.address }; + } else return { name: data.name, address: data.address }; + }) + .sort(function (a, b) { + return a.name.localeCompare(b.name); + }); + + useEffect(() => { + setAddresses(userAddresses.filter((a) => a.address !== walletAddress)); + + /// Adding login user into the list + if (!newGroupState.isEditGroup) handleAddRemoveMember(walletAddress, true); + }, [addressBookConfig.addressBookDatas]); + + const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { + setInputVal(e.target.value); + const searchedVal = e.target.value.toLowerCase(); + const addresses = userAddresses.filter( + (address: NameAddress) => + address.address !== walletAddress && + (address.name.toLowerCase().includes(searchedVal) || + address.address.toLowerCase().includes(searchedVal)) + ); + + if ( + addresses.length === 0 && + searchedVal && + searchedVal !== walletAddress && + user?.messagingPubKey.privacySetting === PrivacySetting.Everybody + ) { + try { + //check if searchedVal is valid address + Bech32Address.validate( + searchedVal, + chainStore.current.bech32Config.bech32PrefixAccAddr + ); + const address: NameAddress = { + name: formatAddress(searchedVal), + address: searchedVal, + }; + + /// validating the address in selected member in case of edit + /// to avoid display the alread added member address + if (newGroupState.isEditGroup) { + const isAlreadyMember = selectedMembers.find( + (element) => element.address === searchedVal + ); + + if (isAlreadyMember) { + setAddresses([]); + setRandomAddress(undefined); + return; + } + } + + setRandomAddress(address); + setAddresses([]); + } catch (e) { + setAddresses([]); + setRandomAddress(undefined); + } + } else { + setRandomAddress(undefined); + setAddresses(addresses); + } + }; + + const isMemberExist = (contactAddress: string) => + !!selectedMembers.find((element) => element.address === contactAddress); + + const handleAddRemoveMember = async ( + contactAddress: string, + isAdmin?: boolean + ) => { + if (!isMemberExist(contactAddress)) { + const pubAddr = await fetchPublicKey( + user.accessToken, + current.chainId, + contactAddress + ); + if (pubAddr && pubAddr.publicKey) { + let encryptedSymmetricKey = ""; + if (group) { + const userGroupAddress = group.addresses.find( + (address) => address.address == walletAddress + ); + //get symmetricKey of group using + const symmetricKey = await decryptEncryptedSymmetricKey( + current.chainId, + userGroupAddress?.encryptedSymmetricKey || "" + ); + encryptedSymmetricKey = await encryptSymmetricKey( + current.chainId, + user.accessToken, + symmetricKey, + contactAddress + ); + } + + const tempMember: GroupMembers = { + address: contactAddress, + pubKey: pubAddr.publicKey, + encryptedSymmetricKey, + isAdmin: isAdmin || false, + }; + const tempMembers = [...selectedMembers, tempMember]; + + store.dispatch(setNewGroupInfo({ members: tempMembers })); + setSelectedMembers(tempMembers); + setNewAddedMembers([...newAddedMembers, contactAddress]); + } + } else { + const tempMembers = selectedMembers.filter( + (item) => item.address !== contactAddress + ); + store.dispatch(setNewGroupInfo({ members: tempMembers })); + setSelectedMembers(tempMembers); + + /// Removing new address + const newMembers = newAddedMembers.filter( + (item) => item !== contactAddress + ); + setNewAddedMembers(newMembers); + } + }; + + async function handleUpdateGroup() { + if (newAddedMembers.length === 0) { + history.goBack(); + return; + } + + setIsLoading(true); + const groupAddresses = newGroupState.group.members; + const userGroupAddress = groupAddresses.find( + (address) => address.address == accountInfo.bech32Address + ); + const encryptedSymmetricKey = userGroupAddress?.encryptedSymmetricKey || ""; + const contents = await encryptGroupMessage( + current.chainId, + addMemberEvent(accountInfo.bech32Address, newAddedMembers.join()), + GroupMessageType.event, + encryptedSymmetricKey, + accountInfo.bech32Address, + newGroupState.group.groupId, + user.accessToken + ); + const updatedGroupInfo: GroupDetails = { + description: newGroupState.group.description ?? "", + groupId: newGroupState.group.groupId, + contents: contents, + members: selectedMembers, + name: newGroupState.group.name, + onlyAdminMessages: false, + }; + const group = await createGroup(updatedGroupInfo); + setIsLoading(false); + + if (group) { + /// updating the group(chat history) object + const groups: any = { [group.id]: group }; + store.dispatch(setGroups({ groups })); + /// fetching the group messages again + await recieveMessages(group.id, null, 0, group.isDm, group.id); + amplitude.getInstance().logEvent("New members added", { + from: "Group Info", + }); + history.goBack(); + } + } + + if ( + user.messagingPubKey.privacySetting && + user.messagingPubKey.privacySetting === PrivacySetting.Nobody + ) { + return <DeactivatedChat />; + } + + return ( + <HeaderLayout + showChainName={false} + canChangeChainInfo={false} + alternativeTitle={"New Group Chat"} + onBackButton={() => { + history.goBack(); + }} + > + {!addressBookConfig.isLoaded ? ( + <ChatLoader message="Loading contacts, please wait..." /> + ) : ( + <div className={style.newMemberContainer}> + <div className={style.searchContainer}> + <div className={style.searchBox}> + <img draggable={false} src={searchIcon} alt="search" /> + <input + placeholder="Search by name or address" + value={inputVal} + onChange={handleSearch} + /> + </div> + </div> + <div className={style.membersContainer}> + {randomAddress && ( + <ChatMember + address={randomAddress} + key={randomAddress.address} + isSelected={isMemberExist(randomAddress.address)} + onIconClick={() => handleAddRemoveMember(randomAddress.address)} + /> + )} + {addresses.map((address: NameAddress) => { + return ( + <ChatMember + address={address} + key={address.address} + isSelected={isMemberExist(address.address)} + onIconClick={() => handleAddRemoveMember(address.address)} + /> + ); + })} + </div> + {addresses.length === 0 && !randomAddress && ( + <div> + <div className={style.resultText}> + No results in your contacts. + </div> + {user?.messagingPubKey.privacySetting === + PrivacySetting.Contacts && ( + <div className={style.resultText}> + If you are searching for an address not in your address book, + you can't see them due to your selected privacy settings + being "contact only". Please add the address to your + address book to be able to chat with them or change your + privacy settings. + <br /> + <a + href="#" + style={{ + textDecoration: "underline", + }} + onClick={(e) => { + e.preventDefault(); + history.push("/setting/chat/privacy"); + }} + > + Go to chat privacy settings + </a> + </div> + )} + </div> + )} + </div> + )} + <div className={style.groupContainer}> + <div className={style.initials}> + {ReactHtmlParser( + jazzicon( + 24, + parseInt(fromBech32(walletAddress).data.toString(), 16) + ).outerHTML + )} + <div className={style.groupHeader}> + <span className={style.groupName}> + <ToolTip + tooltip={newGroupState.group.name} + theme="dark" + trigger="hover" + options={{ + placement: "top", + }} + > + <div className={style.user}> + {formatGroupName(newGroupState.group.name)} + </div> + </ToolTip> + </span> + <span className={style.groupMembers}> + {`${selectedMembers.length} member${ + selectedMembers.length > 1 ? "s" : "" + }`} + </span> + </div> + </div> + + <Button + className={style.button} + color="primary" + data-loading={isLoading} + disabled={ + newGroupState.isEditGroup ? newAddedMembers.length === 0 : undefined + } + onClick={() => { + if (newGroupState.isEditGroup) { + handleUpdateGroup(); + } else { + history.push("/chat/group-chat/review-details"); + } + }} + > + {newGroupState.isEditGroup ? "Update" : "Review"} + </Button> + </div> + </HeaderLayout> + ); +}); diff --git a/packages/extension/src/pages/group-chat/add-member/style.module.scss b/packages/extension/src/pages/group-chat/add-member/style.module.scss new file mode 100644 index 0000000000..dc612708d9 --- /dev/null +++ b/packages/extension/src/pages/group-chat/add-member/style.module.scss @@ -0,0 +1,131 @@ +.searchContainer { + padding: 10px; + background-color: #ffffff; + width: 100%; + display: flex; + align-items: center; + .searchBox { + display: flex; + padding: 5px; + flex: 1; + background-color: #f2f3f6; + border-radius: 4px; + align-items: center; + gap: 10px; + img { + width: 16px; + height: 16px; + } + input { + border: none; + outline: none; + background: transparent; + flex-grow: 1; + &::placeholder { + color: #525f7f; + } + } + } +} +.membersContainer { + padding: 10px; + background-color: #ffffff; + padding-top: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.newMemberContainer { + margin-left: -12px; + margin-right: -12px; + + height: 70vh; + overflow-y: auto; +} + +.resultText { + color: #808da0; + font-weight: 400; + font-size: 15px; + margin-top: 10px; + margin-bottom: 16px; + text-align: center; +} +.searchHelp { + font-size: 13px; + color: #808da0; + margin-top: 5px; + text-align: center; +} + +.contacts { + display: flex; +} + +.button { + padding: 8px; + color: #fff; + text-align: center; + background-color: #3b82f6; + border: 1px solid #3b82f6; + border-radius: 4px; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: 0.875rem; + line-height: 19px; + display: flex; +} + +.button:not(:disabled):not(.disabled) { + cursor: pointer; + background: #3b82f6; + border: 1px solid #3b82f6; + color: #fff; + font-size: 0.875rem; +} + +.groupHeader { + height: 40%; + flex-direction: column; + justify-content: space-between; + align-items: center; + margin-top: 2px; + display: flex; +} + +.groupName { + align-self: start; + text-align: start; + font-size: 0.875rem; + word-break: break-word; +} + +.groupMembers { + align-self: start; + color: #525f7f; +} + +.groupContainer { + text-align: center; + color: #808da0; + width: -webkit-fill-available; + background-color: #fff; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 11px; + line-height: 16px; + display: flex; + margin-left: -12px; + margin-right: -12px; + margin-bottom: -12px; +} + +.initials { + display: inline-flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} diff --git a/packages/extension/src/pages/group-chat/chat-section/chats-view-section.tsx b/packages/extension/src/pages/group-chat/chat-section/chats-view-section.tsx new file mode 100644 index 0000000000..328394114d --- /dev/null +++ b/packages/extension/src/pages/group-chat/chat-section/chats-view-section.tsx @@ -0,0 +1,340 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { + createRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSelector } from "react-redux"; +import { useHistory } from "react-router"; +import ReactTextareaAutosize from "react-textarea-autosize"; +import { InputGroup } from "reactstrap"; +import { ExtensionKVStore } from "@keplr-wallet/common"; +import { AddressBookConfigMap } from "@keplr-wallet/hooks"; +import { Chats, Group, Groups, GroupAddress, NameAddress } from "@chatTypes"; +import { userChatGroups, userMessages } from "@chatStore/messages-slice"; +import { userDetails } from "@chatStore/user-slice"; +import { CHAT_PAGE_COUNT } from "../../../config.ui.var"; +import { deliverGroupMessages } from "@graphQL/messages-api"; +import { recieveGroups, recieveMessages } from "@graphQL/recieve-messages"; +import { useOnScreen } from "@hooks/use-on-screen"; +import paperAirplaneIcon from "@assets/icon/paper-airplane.png"; +import { useStore } from "../../../stores"; +import style from "./style.module.scss"; +import { GroupMessageType } from "@utils/encrypt-group"; +import { GroupChatMessage } from "@components/group-chat-message"; + +export const GroupChatsViewSection = ({ + isMemberRemoved, +}: { + isMemberRemoved: boolean; +}) => { + const history = useHistory(); + const groupId = history.location.pathname.split("/")[3]; + + let enterKeyCount = 0; + const user = useSelector(userDetails); + const userGroups: Groups = useSelector(userChatGroups); + const userChats: Chats = useSelector(userMessages); + + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + + const [addresses, setAddresses] = useState<NameAddress>({}); + useEffect(() => { + const configMap = new AddressBookConfigMap( + new ExtensionKVStore("address-book"), + chainStore + ); + + const addressBookConfig = configMap.getAddressBookConfig(current.chainId); + addressBookConfig.setSelectHandler({ + setRecipient: (): void => { + // noop + }, + setMemo: (): void => { + // noop + }, + }); + addressBookConfig.waitLoaded().then(() => { + const addressList: NameAddress = {}; + addressBookConfig.addressBookDatas.map((data) => { + addressList[data.address] = data.name; + }); + setAddresses(addressList); + }); + }, [current.chainId]); + + const preLoadedChats = useMemo(() => { + return ( + userChats[groupId] || { + messages: {}, + pagination: { lastPage: 0, page: -1, pageCount: CHAT_PAGE_COUNT }, + } + ); + }, [Object.values(userChats[groupId]?.messages || []).length]); + const [messages, setMessages] = useState<any[]>( + Object.values(preLoadedChats?.messages) || [] + ); + + const [pagination, setPagination] = useState(preLoadedChats?.pagination); + const [group, setGroup] = useState<Group | undefined>( + Object.values(userGroups).find((group) => group.id.includes(groupId)) + ); + const [userGroupAddress, setUserGroupAddress] = useState< + GroupAddress | undefined + >(); + const [loadingMessages, setLoadingMessages] = useState(false); + + const [newMessage, setNewMessage] = useState(""); + + //Scrolling Logic + // const messagesEndRef: any = useRef(); + const messagesStartRef: any = createRef(); + const messagesScrollRef: any = useRef(null); + const isOnScreen = useOnScreen(messagesStartRef); + + // const scrollToBottom = () => { + // if (messagesEndRef.current) messagesEndRef.current.scrollIntoView(true); + // }; + + useEffect(() => { + const updatedMessages = Object.values(preLoadedChats?.messages).sort( + (a, b) => { + return parseInt(a.commitTimestamp) - parseInt(b.commitTimestamp); + } + ); + + setMessages(updatedMessages); + setPagination(preLoadedChats.pagination); + + // const lastMessage = + // updatedMessages && updatedMessages.length > 0 + // ? updatedMessages[updatedMessages.length - 1] + // : null; + + // if ( + // group?.id && + // lastMessage && + // lastMessage.sender !== accountInfo.bech32Address + // ) { + // setTimeout(() => { + // updateGroupTimestamp( + // group?.id, + // user.accessToken, + // current.chainId, + // accountInfo.bech32Address, + // groupId, + // new Date(lastMessage.commitTimestamp), + // new Date(lastMessage.commitTimestamp) + // ); + // }, 500); + // } + }, [preLoadedChats]); + + useEffect(() => { + const groupData = Object.values(userGroups).find((group) => + group.id.includes(groupId) + ); + setGroup(groupData); + + const currentUser = groupData?.addresses.find( + (element) => element.address === accountInfo.bech32Address + ); + setUserGroupAddress(currentUser); + + if (currentUser?.removedAt) { + /// receive last updated message as message subscription not called + recieveMessages(groupId, null, 0, false, groupId); + } + }, [userGroups]); + + const messagesEndRef: any = useCallback( + (node: any) => { + if (node) node.scrollIntoView({ block: "end" }); + }, + [messages] + ); + + useEffect(() => { + if (isMemberRemoved && newMessage.length > 0) { + setNewMessage(""); + } + }, [isMemberRemoved]); + + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView(true); + } + }, [messagesEndRef.current]); + + useEffect(() => { + const getChats = async () => { + await loadUserList(); + // if (pagination.page < 0) scrollToBottom(); + // else messagesScrollRef.current.scrollIntoView(true); + if (pagination.page >= 0) messagesScrollRef.current.scrollIntoView(true); + }; + if (isOnScreen) getChats(); + }, [isOnScreen]); + + const loadUserList = async () => { + if (loadingMessages) return; + if (group) { + const page = pagination?.page + 1 || 0; + setLoadingMessages(true); + await recieveMessages(groupId, null, page, group.isDm, groupId); + setLoadingMessages(false); + } else { + const newPagination = pagination; + newPagination.page = pagination.lastPage; + setPagination(newPagination); + } + }; + + const getDateValue = (d: any) => { + const date = new Date(d); + return date.getDate(); + }; + + let prevDate = 0; + const showDateFunction = (d: any) => { + const date = getDateValue(d); + + if (prevDate !== date) { + prevDate = date; + return true; + } + return false; + }; + + const handleSendMessage = async (e: any) => { + e.preventDefault(); + if (newMessage.trim().length && userGroupAddress) + try { + // get encryptedsymmetrickey as well as parameter + const { encryptedSymmetricKey } = userGroupAddress; + const message = await deliverGroupMessages( + user.accessToken, + current.chainId, + newMessage, + encryptedSymmetricKey || "", + GroupMessageType.message, + accountInfo.bech32Address, + groupId + ); + + if (message) { + const updatedMessagesList = [...messages, message]; + setMessages(updatedMessagesList); + setNewMessage(""); + } + // scrollToBottom(); + recieveGroups(0, accountInfo.bech32Address); + } catch (error) { + console.log("failed to send : ", error); + } finally { + enterKeyCount = 0; + } + }; + + const handleKeydown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + //it triggers by pressing the enter key + const { key } = e as React.KeyboardEvent<HTMLTextAreaElement>; + if (key === "Enter" && !e.shiftKey && enterKeyCount == 0) { + enterKeyCount = 1; + handleSendMessage(e); + } + }; + + return ( + <div className={style.chatArea}> + <div className={style.messages}> + {pagination?.lastPage > pagination?.page && + (pagination?.page === -1 || + messages.length === 30 || + messages.length == 0) && ( + <div ref={messagesStartRef} className={style.loader}> + Fetching older Chats <i className="fas fa-spinner fa-spin ml-2" /> + </div> + )} + {pagination?.lastPage <= pagination?.page && ( + <p> + {` Messages are end to end encrypted. Nobody else can read them except + you and the recipient${ + group && group?.addresses.length > 2 ? "s" : "" + }.`} + </p> + )} + {messages?.map((message: any, index) => { + const isShowDate = showDateFunction(message?.commitTimestamp); + if (!group) return; + const groupAddresses = group.addresses; + const userGroupAddress = groupAddresses.find( + (address) => address.address == accountInfo.bech32Address + ); + const encryptedSymmetricKey = + userGroupAddress?.encryptedSymmetricKey || ""; + + return ( + <div key={message.id}> + <GroupChatMessage + chainId={current.chainId} + encryptedSymmetricKey={encryptedSymmetricKey} + addresses={addresses} + senderAddress={message?.sender} + showDate={isShowDate} + message={message?.contents} + isSender={message?.sender === accountInfo.bech32Address} // if I am the sender of this message + timestamp={message?.commitTimestamp || 1549312452} + groupLastSeenTimestamp={0} + /> + {index === CHAT_PAGE_COUNT && <div ref={messagesScrollRef} />} + {/* {message?.commitTimestamp && + receiver?.lastSeenTimestamp && + Number(message?.commitTimestamp) > + Number(receiver?.lastSeenTimestamp) && + message?.sender === targetAddress && ( + <div ref={messagesEndRef} className={messagesEndRef} /> + )} */} + </div> + ); + })} + <div ref={messagesEndRef} className={"AAAAA"} /> + </div> + + <InputGroup className={style.inputText}> + { + <ReactTextareaAutosize + maxRows={3} + className={`${style.inputArea} ${style["send-message-inputArea"]}`} + placeholder={ + isMemberRemoved + ? "You can't send messages to this group because you're no longer a participant" + : "Type a new message..." + } + value={newMessage} + onChange={(event) => { + setNewMessage(event.target.value.substring(0, 499)); + }} + onKeyDown={handleKeydown} + disabled={isMemberRemoved} + /> + } + {newMessage?.length && newMessage.trim() !== "" ? ( + <div + className={style["send-message-icon"]} + onClick={handleSendMessage} + > + <img draggable={false} src={paperAirplaneIcon} alt="" /> + </div> + ) : ( + "" + )} + </InputGroup> + </div> + ); +}; diff --git a/packages/extension/src/pages/group-chat/chat-section/index.tsx b/packages/extension/src/pages/group-chat/chat-section/index.tsx new file mode 100644 index 0000000000..08cca0a339 --- /dev/null +++ b/packages/extension/src/pages/group-chat/chat-section/index.tsx @@ -0,0 +1,190 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { FunctionComponent, useEffect, useState } from "react"; +import { useHistory } from "react-router"; +import { useStore } from "../../../stores"; +import { ChatErrorPopup } from "@components/chat-error-popup"; +import { SwitchUser } from "@components/switch-user"; +import { HeaderLayout } from "@layouts/index"; +import { Menu } from "../../main/menu"; +import { UserNameSection } from "./username-section"; +import { GroupChatsViewSection } from "./chats-view-section"; +import { ChatActionsPopup } from "@components/chat-actions-popup"; +import { useSelector } from "react-redux"; +import { + updateChatList, + userChatGroups, + userMessages, +} from "@chatStore/messages-slice"; +import { Chats, GroupChatOptions, GroupMembers, Groups } from "@chatTypes"; +import { GroupChatActionsDropdown } from "@components/group-chat-actions-dropdown"; +import { store } from "@chatStore/index"; +import { setIsGroupEdit, setNewGroupInfo } from "@chatStore/new-group-slice"; +import { leaveGroup } from "@graphQL/groups-api"; +import { deliverGroupMessages } from "@graphQL/messages-api"; +import { GroupMessageType } from "@utils/encrypt-group"; +import { userDetails } from "@chatStore/user-slice"; +import { recieveGroups } from "@graphQL/recieve-messages"; +import { leaveGroupEvent } from "@utils/group-events"; +import amplitude from "amplitude-js"; + +export const GroupChatSection: FunctionComponent = () => { + const history = useHistory(); + const groupId = history.location.pathname.split("/")[3]; + const groups: Groups = useSelector(userChatGroups); + const userChats: Chats = useSelector(userMessages); + + const group = groups[groupId]; + const user = useSelector(userDetails); + + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + const walletAddress = accountInfo.bech32Address; + + const isAdmin = + group.addresses.find((element) => element.address === walletAddress) + ?.isAdmin ?? false; + + const [showDropdown, setShowDropdown] = useState(false); + const [confirmAction, setConfirmAction] = useState(false); + const [isMemberRemoved, setMemberRemoved] = useState(false); + const [action, setAction] = useState(""); + + /// Find the current user and check user exists in the group or not + const currentUser = group.addresses.find( + (element) => element.address === accountInfo.bech32Address + ); + + useEffect(() => { + if (group?.removedAt || currentUser?.removedAt) { + /// User is removed by admin + setMemberRemoved(true); + } else if (!currentUser && !isMemberRemoved) { + /// User removed from group address array + setMemberRemoved(true); + } else if (currentUser && isMemberRemoved) { + setMemberRemoved(false); + } + }, [groups]); + + const handleDropDown = () => { + setShowDropdown(!showDropdown); + }; + + function navigateToPage(page: string) { + const members: GroupMembers[] = group.addresses + .filter((element) => !element.removedAt) + .map((element) => { + return { + address: element.address, + pubKey: element.pubKey, + encryptedSymmetricKey: element.encryptedSymmetricKey, + isAdmin: element.isAdmin, + }; + }); + store.dispatch( + setNewGroupInfo({ + description: group.description === null ? "" : group.description, + groupId: group.id, + members: members, + name: group.name, + }) + ); + store.dispatch(setIsGroupEdit(true)); + history.push(page); + } + + const handleClick = (option: GroupChatOptions) => { + setShowDropdown(false); + switch (option) { + case GroupChatOptions.groupInfo: + amplitude.getInstance().logEvent("Group info click", {}); + navigateToPage("/chat/group-chat/review-details"); + break; + + case GroupChatOptions.chatSettings: + amplitude.getInstance().logEvent("Group Chat setting click", {}); + navigateToPage("/chat/group-chat/create"); + break; + + case GroupChatOptions.deleteGroup: + setAction("deleteGroup"); + setConfirmAction(true); + break; + + case GroupChatOptions.leaveGroup: + default: + setAction(GroupChatOptions[option]); + setConfirmAction(true); + break; + } + }; + + const handleAction = async () => { + if (currentUser) { + const { encryptedSymmetricKey } = currentUser; + + const message = await deliverGroupMessages( + user.accessToken, + current.chainId, + leaveGroupEvent(accountInfo.bech32Address), + encryptedSymmetricKey || "", + GroupMessageType.event, + accountInfo.bech32Address, + groupId + ); + + if (message) { + await leaveGroup(groupId); + recieveGroups(0, accountInfo.bech32Address); + const messagesObj: any = { [message.id]: message }; + const messages = { ...userChats[groupId].messages, ...messagesObj }; + store.dispatch(updateChatList({ userAddress: groupId, messages })); + amplitude.getInstance().logEvent("Leave group click", {}); + } + } + setConfirmAction(false); + }; + + return ( + <div + onClick={() => { + if (showDropdown) { + handleDropDown(); + } + }} + > + <HeaderLayout + showChainName={true} + canChangeChainInfo={true} + menuRenderer={<Menu />} + rightRenderer={<SwitchUser />} + > + <ChatErrorPopup /> + <div> + <UserNameSection + handleDropDown={handleDropDown} + groupName={group.name} + /> + + <GroupChatActionsDropdown + isMemberRemoved={isMemberRemoved} + showDropdown={showDropdown} + isAdmin={isAdmin} + handleClick={handleClick} + /> + + <GroupChatsViewSection isMemberRemoved={isMemberRemoved} /> + + {confirmAction && ( + <ChatActionsPopup + action={action} + setConfirmAction={setConfirmAction} + handleAction={handleAction} + /> + )} + </div> + </HeaderLayout> + </div> + ); +}; diff --git a/packages/extension/src/pages/group-chat/chat-section/style.module.scss b/packages/extension/src/pages/group-chat/chat-section/style.module.scss new file mode 100644 index 0000000000..ccbe38d645 --- /dev/null +++ b/packages/extension/src/pages/group-chat/chat-section/style.module.scss @@ -0,0 +1,354 @@ +.username { + margin-left: -12px; + margin-right: -12px; + padding: 10px; + align-items: center; + display: flex; + justify-content: space-between; + background-color: #ffffff; + border-radius: 6px; + flex-direction: row; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 1; + height: 531px; + width: 100%; + background: black; + opacity: 0.5; +} + +.rightBox { + display: flex; + align-items: center; + justify-content: space-between; + .more { + float: right; + transform: rotate(90deg); + } +} +.leftBox { + display: flex; + align-items: center; + width: 100%; + .user { + word-wrap: break-word; + max-width: 300px; + text-align: left; + padding: 2px 10px; + } + .userText { + display: inline-block; + } + .copyIcon { + color: #525f7f; + cursor: pointer; + margin-left: 10px; + } +} +.messageRef { + min-height: 10px; +} +.backBtn { + flex-basis: 25px; + width: 24px; + height: 24px; +} + +.send-message-inputArea { + // height: 32px; + outline: none !important; +} + +.recieverName { + text-align: start; + font-size: 16px; + color: #525f7f; + line-height: 16px; + font-weight: 400; + position: relative; + margin-left: 5px; + word-break: break-word; +} + +.send-message-icon { + margin-right: 5px; + cursor: pointer; +} + +.chatArea { + display: flex; + flex-direction: column; + height: 70vh; + .messages { + height: 63vh; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: clip; + margin-right: -12px; + margin-bottom: 10px; + scroll-behavior: smooth; + + p { + width: 320px; + text-align: center; + font-size: 14px; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + color: #525f7f; + } + } +} +.loader { + text-align: center; + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; +} +.inputText { + // margin-left: -12px; + border: #525f7f 1px solid; + background: white; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + + .inputArea { + width: 330px; + padding: 4.5px 10px; + border-radius: 5px; + height: 33px; + resize: none; + border: 0px; + } + + .inputArea:focus { + outline: solid #11b4ff 0.5px; + } +} + +.enterMessageIcon { + // border: 1px solid #cad1d7; + // border-radius: 0.375rem; + padding-left: 10px; + // padding-top: 4px; + // width: 20px; + // height: 20px; + // transform: scale(1.25); +} + +.newUserText { + margin-bottom: 5px; + // position: absolute; + // background-color: #F2F3F6; + width: inherit; + + p { + font-size: 14px; + color: #808da0; + text-align: center; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: center; + + button { + padding: 15px 20px; + margin-right: 5px; + border: solid #11b4ff 1px; + border-radius: 5px; + color: #11b4ff; + font-weight: bold; + cursor: pointer; + } + } +} + +.dropdown { + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + border-radius: 5px; + width: 180px; + padding: 5px; + margin-left: 160px; + top: 105px; + z-index: 2; + cursor: pointer; + + button { + border: none; + padding: 5px; + color: #343a40; + font-size: 14px; + background-color: #ffffff; + cursor: pointer; + } + + div { + padding: 5px; + } + + div:hover { + background-color: #c9d4e4; + } +} + +.popup { + width: 310px; + position: absolute; + border: solid #dee2e6 1px; + background-color: #ffffff; + margin: 20px 15px; + border-radius: 15px; + padding-bottom: 10px; + cursor: pointer; + top: 30%; + z-index: 2; + // 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; + } + + .textContainer { + text-align: left; + padding: 0 15px; + font-size: 14px; + color: #808da0; + width: 280px; + + 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: 10px; + border-top: solid #dee2e6 2px; + + button { + margin-left: 8px; + padding: 5px 10px; + border: solid #3b82f6 1px; + border-radius: 5px; + color: #3b82f6; + background-color: #ffffff; + cursor: pointer; + } + + .btn { + color: #ffffff; + background-color: #3b82f6; + } +} +.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: 1px solid #3b82f6; + padding: 4px 15px; + background-color: #fff; + border-radius: 4px; + color: #3b82f6; + cursor: pointer; + font-weight: 600; + } +} + +// ----------------------------- + +.memberContainer { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + .initials { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #d0def5; + color: #525f7f; + margin-right: 8px; + font-weight: 400; + font-size: 15px; + line-height: 20px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + .unread { + position: absolute; + top: -4px; + left: -4px; + width: 12px; + height: 12px; + background: #d43bf6; + border-radius: 50%; + } + } + .memberInner { + width: 80%; + .name { + font-weight: 400; + font-size: 15px; + color: #525f7f; + height: 20px; + } + .memberText { + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + white-space: nowrap; + font-weight: 400; + font-size: 15px; + color: #808da0; + height: 20px; + } + } +} diff --git a/packages/extension/src/pages/group-chat/chat-section/username-section.tsx b/packages/extension/src/pages/group-chat/chat-section/username-section.tsx new file mode 100644 index 0000000000..c98d8ab24a --- /dev/null +++ b/packages/extension/src/pages/group-chat/chat-section/username-section.tsx @@ -0,0 +1,80 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React from "react"; +import { useIntl } from "react-intl"; +import { useHistory } from "react-router"; +import { useNotification } from "@components/notification"; +import { ToolTip } from "@components/tooltip"; +import chevronLeft from "@assets/icon/chevron-left.png"; +import moreIcon from "@assets/icon/more-grey.png"; +import style from "./style.module.scss"; +import { formatGroupName } from "@utils/format"; + +export const UserNameSection = ({ + handleDropDown, + groupName, +}: { + handleDropDown: any; + groupName: string; +}) => { + const history = useHistory(); + const notification = useNotification(); + const intl = useIntl(); + + const copyAddress = async (address: string) => { + await navigator.clipboard.writeText(address); + notification.push({ + placement: "top-center", + type: "success", + duration: 2, + content: intl.formatMessage({ + id: "main.name.copied", + }), + canDelete: true, + transition: { + duration: 0.25, + }, + }); + }; + + return ( + <div className={style.username}> + <div className={style.leftBox}> + <img + alt="" + className={style.backBtn} + src={chevronLeft} + onClick={() => { + history.goBack(); + }} + /> + + <ToolTip + tooltip={groupName} + theme="dark" + trigger="hover" + options={{ + placement: "top", + }} + > + <span className={style.recieverName}> + {formatGroupName(groupName)} + </span> + </ToolTip> + + <span className={style.copyIcon} onClick={() => copyAddress(groupName)}> + <i className="fas fa-copy" /> + </span> + </div> + <div className={style.rightBox}> + <img + alt="" + style={{ cursor: "pointer" }} + className={style.more} + src={moreIcon} + onClick={handleDropDown} + onBlur={handleDropDown} + /> + </div> + </div> + ); +}; diff --git a/packages/extension/src/pages/group-chat/create-group-chat/index.tsx b/packages/extension/src/pages/group-chat/create-group-chat/index.tsx new file mode 100644 index 0000000000..14f4763c14 --- /dev/null +++ b/packages/extension/src/pages/group-chat/create-group-chat/index.tsx @@ -0,0 +1,232 @@ +import React, { FunctionComponent, useState } from "react"; +import { useHistory } from "react-router"; +import { HeaderLayout } from "@layouts/index"; +import style from "./style.module.scss"; +import { store } from "@chatStore/index"; +import { + newGroupDetails, + setIsGroupEdit, + setNewGroupInfo, +} from "@chatStore/new-group-slice"; +import { useSelector } from "react-redux"; +import { CommonPopupOptions, GroupDetails, NewGroupDetails } from "@chatTypes"; +import { useNotification } from "@components/notification"; +import { Button } from "reactstrap"; +import { encryptGroupMessage, GroupMessageType } from "@utils/encrypt-group"; +import { useStore } from "../../../stores"; +import { userDetails } from "@chatStore/user-slice"; +import { AlertPopup } from "@components/chat-actions-popup/alert-popup"; +import { createGroup } from "@graphQL/groups-api"; +import { setGroups } from "@chatStore/messages-slice"; +import { recieveMessages } from "@graphQL/recieve-messages"; +import { createGroupEvent, updateInfoEvent } from "@utils/group-events"; +import amplitude from "amplitude-js"; + +export const CreateGroupChat: FunctionComponent = () => { + const history = useHistory(); + const notification = useNotification(); + const user = useSelector(userDetails); + + const { chainStore, accountStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + const newGroupState: NewGroupDetails = useSelector(newGroupDetails); + + const [isLoading, setIsLoading] = useState<boolean>(false); + const [name, setName] = useState(newGroupState.group.name); + const [description, setDescription] = useState( + newGroupState.group.description + ); + + const [confirmAction, setConfirmAction] = useState<boolean>(false); + + async function updateGroupInfo() { + setIsLoading(true); + const groupAddresses = newGroupState.group.members; + const userGroupAddress = groupAddresses.find( + (address) => address.address == accountInfo.bech32Address + ); + const encryptedSymmetricKey = userGroupAddress?.encryptedSymmetricKey || ""; + const contents = await encryptGroupMessage( + current.chainId, + updateInfoEvent(accountInfo.bech32Address), + GroupMessageType.event, + encryptedSymmetricKey, + accountInfo.bech32Address, + newGroupState.group.groupId, + user.accessToken + ); + const updatedGroupInfo: GroupDetails = { + description: description, + groupId: newGroupState.group.groupId, + contents: contents, + members: newGroupState.group.members, + name: name, + onlyAdminMessages: false, + }; + const group = await createGroup(updatedGroupInfo); + + if (group) { + /// updating the group(chat history) object + const groups: any = { [group.id]: group }; + store.dispatch(setGroups({ groups })); + /// fetching the group messages again + await recieveMessages(group.id, null, 0, group.isDm, group.id); + amplitude.getInstance().logEvent("Group info updated", { + from: "Group Info", + }); + history.goBack(); + } + setIsLoading(false); + } + + async function validateAndContinue(): Promise<void> { + if (newGroupState.isEditGroup) { + updateGroupInfo(); + return; + } + + if (!name) { + notification.push({ + type: "warning", + placement: "top-center", + duration: 5, + content: `Please enter the group name`, + canDelete: true, + transition: { + duration: 0.25, + }, + }); + return; + } + + const contents = { + text: createGroupEvent(accountInfo.bech32Address), + type: GroupMessageType[GroupMessageType.event], + }; + + store.dispatch( + setNewGroupInfo({ + name: name.trim(), + description: description.trim(), + contents, + }) + ); + store.dispatch(setIsGroupEdit(false)); + history.push({ + pathname: "/chat/group-chat/add-member", + }); + } + + async function handlePopupAction(action: CommonPopupOptions) { + setConfirmAction(false); + + if (action === CommonPopupOptions.ok) { + history.goBack(); + } + } + + function handleBackButton() { + if (newGroupState.isEditGroup) { + if ( + newGroupState.group.name != name || + name.trim().length == 0 || + newGroupState.group.description != description + ) { + setConfirmAction(true); + return; + } + } + + history.goBack(); + } + + function isBtnDisable(): boolean | undefined { + if (newGroupState.isEditGroup) { + return ( + (newGroupState.group.name == name && + newGroupState.group.description == description) || + name.trim().length == 0 + ); + } + + return name.trim().length == 0; + } + + return ( + <HeaderLayout + showChainName={false} + canChangeChainInfo={false} + alternativeTitle={"New Group Chat"} + onBackButton={() => handleBackButton()} + > + <div> + {confirmAction && ( + <AlertPopup + setConfirmAction={setConfirmAction} + heading="Discard changes" + description="Leaving this page without saving changes will not save changes made" + firstButtonTitle="Cancel" + secondButtonTitle="Leave without saving" + onClick={(action) => { + handlePopupAction(action); + }} + /> + )} + </div> + <div className={style.tokens}> + <span className={style.groupImageText} hidden={true}> + Group Image (Optional) + </span> + <img + className={style.groupImage} + draggable="false" + src={require("@assets/group710.svg")} + /> + <span className={style.recommendedSize}> + Recommended size: 120 x 120 + </span> + <div className={style.input}> + <span className={style.text}>Group Name</span> + <input + className={style.inputText} + placeholder="Type your group chat name" + type="text" + value={name} + onChange={(event) => { + setName(event.target.value.substring(0, 30)); + }} + /> + </div> + <div className={style.input}> + <span className={style.text}>Description (Optional)</span> + <textarea + className={style.inputText} + placeholder="Tell us more about your group" + value={description} + onChange={(event) => { + setDescription(event.target.value.substring(0, 256)); + }} + /> + </div> + <div className={style.adminToggle}> + <img + draggable={false} + className={style.toggle} + src={require("@assets/toggle.svg")} + /> + <span className={style.adminText}>Only admins can send messages</span> + </div> + <Button + className={style.button} + color="primary" + data-loading={isLoading} + disabled={isBtnDisable()} + onClick={() => validateAndContinue()} + > + {newGroupState.isEditGroup ? "Save changes" : "Add Members"} + </Button> + </div> + </HeaderLayout> + ); +}; diff --git a/packages/extension/src/pages/group-chat/create-group-chat/style.module.scss b/packages/extension/src/pages/group-chat/create-group-chat/style.module.scss new file mode 100644 index 0000000000..36bd7a6a30 --- /dev/null +++ b/packages/extension/src/pages/group-chat/create-group-chat/style.module.scss @@ -0,0 +1,126 @@ +.tokens { + color: #525f7f; + width: -webkit-fill-available; + height: 100%; + background-color: #fff; + flex-direction: column; + padding: 10px; + font-weight: 400; + line-height: 16px; + display: flex; + margin-left: -12px; + margin-right: -12px; +} + +.groupImageText { + text-align: center; + align-self: flex-start; + margin-bottom: 6px; + margin-left: 1px; + font-weight: 700; + line-height: 19px; +} + +.groupImage { + text-align: center; + align-self: flex-start; + width: 120px; + height: 120px; + object-fit: cover; + margin: 13px auto; +} + +.recommendedSize { + text-align: center; + margin-bottom: 19px; + visibility: hidden; +} + +.adminToggle { + color: #808da0; + width: 85%; + flex-direction: row; + justify-content: space-between; + align-self: flex-start; + align-items: center; + margin-bottom: 20px; + font-size: 1rem; + display: flex; + visibility: hidden; +} + +.toggle { + width: 42px; + height: 24px; + object-fit: cover; +} + +.adminText { + margin-top: 2px; + margin-right: -51px; +} + +.inputText { + color: #525f7f; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + padding: 4px; + line-height: 16px; + display: flex; +} + +textarea.inputText { + resize: none; + min-height: 75px; + max-height: 75px; +} + +.input { + color: #525f7f; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + padding-top: 4px; + font-size: 0.875rem; + font-weight: 400; + line-height: 19px; + margin: 8px 0px; +} + +.text { + text-align: center; + margin-left: 1px; + font-weight: 700; +} + +.inputText { + color: #808da0; + width: 100%; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 4px; + flex-direction: row; + flex-basis: 62%; + justify-content: flex-start; + align-items: stretch; + font-size: 1rem; + line-height: 21px; + display: flex; +} + +.button { + width: 100%; + font-weight: 700; + color: #fff; + font-size: 14px; + height: 35px; + border: none; + outline: none; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + padding: 10px; +} diff --git a/packages/extension/src/pages/group-chat/edit-member/index.tsx b/packages/extension/src/pages/group-chat/edit-member/index.tsx new file mode 100644 index 0000000000..15b973635e --- /dev/null +++ b/packages/extension/src/pages/group-chat/edit-member/index.tsx @@ -0,0 +1,525 @@ +import { store } from "@chatStore/index"; +import { setGroups, userChatGroups } from "@chatStore/messages-slice"; +import { newGroupDetails, setNewGroupInfo } from "@chatStore/new-group-slice"; +import { userDetails } from "@chatStore/user-slice"; +import { + CommonPopupOptions, + Group, + GroupChatMemberOptions, + GroupDetails, + GroupMembers, + Groups, + NewGroupDetails, +} from "@chatTypes"; +import { AlertPopup } from "@components/chat-actions-popup/alert-popup"; +import { ChatLoader } from "@components/chat-loader"; +import { ChatMember } from "@components/chat-member"; +import { GroupChatPopup } from "@components/group-chat-popup"; +import { useLoadingIndicator } from "@components/loading-indicator"; +import { createGroup } from "@graphQL/groups-api"; +import { ExtensionKVStore } from "@keplr-wallet/common"; +import { + useAddressBookConfig, + useIBCTransferConfig, +} from "@keplr-wallet/hooks"; +import { HeaderLayout } from "@layouts/index"; +import amplitude from "amplitude-js"; +import { observer } from "mobx-react-lite"; +import React, { FunctionComponent, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { useHistory } from "react-router"; +import { EthereumEndpoint } from "../../../config.ui"; +import { useStore } from "../../../stores"; +import { encryptGroupMessage, GroupMessageType } from "@utils/encrypt-group"; +import { fetchPublicKey } from "@utils/fetch-public-key"; +import { formatAddress } from "@utils/format"; +import { + decryptEncryptedSymmetricKey, + encryptSymmetricKey, +} from "@utils/symmetric-key"; +import style from "./style.module.scss"; +import { recieveMessages } from "@graphQL/recieve-messages"; +import { + addAdminEvent, + removedAdminEvent, + removeMemberEvent, +} from "@utils/group-events"; + +export const EditMember: FunctionComponent = observer(() => { + const history = useHistory(); + const loadingIndicator = useLoadingIndicator(); + + const user = useSelector(userDetails); + + /// For updating the current group + const newGroupState: NewGroupDetails = useSelector(newGroupDetails); + const [selectedMembers, setSelectedMembers] = useState<GroupMembers[]>( + newGroupState.group.members || [] + ); + + /// Group Info + const groups: Groups = useSelector(userChatGroups); + const group: Group = groups[newGroupState.group.groupId]; + + /// Displaying list of addresses along with name + const [addresses, setAddresses] = useState<any[]>([]); + + /// Selected member info for displaying the dynamic popup + const [selectedAddress, setSelectedAddresse] = useState<any>(); + const [confirmAction, setConfirmAction] = useState(false); + + /// Show alert popup for remove member + const [removeMemberPopup, setRemoveMemberPopup] = useState(false); + + const { chainStore, accountStore, queriesStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + const walletAddress = accountInfo.bech32Address; + const userGroupAddress = group.addresses.find( + (address) => address.address == walletAddress + ); + // address book values + const queries = queriesStore.get(chainStore.current.chainId); + const ibcTransferConfigs = useIBCTransferConfig( + chainStore, + chainStore.current.chainId, + accountInfo.msgOpts.ibcTransfer, + accountInfo.bech32Address, + queries.queryBalances, + EthereumEndpoint + ); + + const [selectedChainId] = useState( + ibcTransferConfigs.channelConfig?.channel + ? ibcTransferConfigs.channelConfig.channel.counterpartyChainId + : current.chainId + ); + const addressBookConfig = useAddressBookConfig( + new ExtensionKVStore("address-book"), + chainStore, + selectedChainId, + { + setRecipient: (): void => { + // noop + }, + setMemo: (): void => { + // noop + }, + } + ); + + const updateUserAddresses = (members: GroupMembers[]) => { + const userAddresses: any[] = members + .reduce((acc: any[], element: GroupMembers) => { + const addressData = addressBookConfig.addressBookDatas.find( + (data) => data.address === element.address + ); + if (addressData && addressData.address !== walletAddress) { + return [ + ...acc, + { + name: addressData.name, + address: addressData.address, + existsInAddressBook: true, + }, + ]; + } else { + return element.address === walletAddress + ? [ + { + name: "You", + address: walletAddress, + existsInAddressBook: false, + }, + ...acc, + ] + : [ + ...acc, + { + name: element.address, + address: element.address, + existsInAddressBook: false, + }, + ]; + } + }, []) + .sort(function (a, b) { + return b.address === walletAddress ? 0 : a.name.localeCompare(b.name); + }); + setAddresses(userAddresses); + }; + + useEffect(() => { + updateUserAddresses(selectedMembers); + + if (!newGroupState.isEditGroup) { + /// Adding login user into the group list + handleAddRemoveMember( + walletAddress, + group.addresses.find((element) => element.address === walletAddress) + ?.isAdmin ?? false + ); + } + }, [addressBookConfig.addressBookDatas, selectedMembers]); + + /// Listening the live group updates + useEffect(() => { + const groupData = Object.values(groups).find((group) => + group.id.includes(newGroupState.group.groupId) + ); + if (groupData) { + const updatedMembers: GroupMembers[] = groupData.addresses + .filter((element) => !element.removedAt) + .map((element) => { + return { + address: element.address, + pubKey: element.pubKey, + encryptedSymmetricKey: element.encryptedSymmetricKey, + isAdmin: element.isAdmin, + }; + }); + setSelectedMembers(updatedMembers); + store.dispatch(setNewGroupInfo({ members: updatedMembers })); + } + }, [groups, newGroupState.group.groupId]); + + const isMemberExist = (contactAddress: string) => + !!selectedMembers.find((element) => element.address === contactAddress); + + const handleAddRemoveMember = async ( + contactAddress: string, + isAdmin?: boolean + ) => { + if (!isMemberExist(contactAddress)) { + const pubAddr = await fetchPublicKey( + user.accessToken, + current.chainId, + contactAddress + ); + + if (pubAddr && pubAddr.publicKey) { + //get symmetricKey of group using + const symmetricKey = await decryptEncryptedSymmetricKey( + current.chainId, + userGroupAddress?.encryptedSymmetricKey || "" + ); + const encryptedSymmetricKey = await encryptSymmetricKey( + current.chainId, + user.accessToken, + symmetricKey, + contactAddress + ); + const tempMember: GroupMembers = { + address: contactAddress, + pubKey: pubAddr.publicKey, + encryptedSymmetricKey, + isAdmin: isAdmin || false, + }; + + const tempMembers = [...selectedMembers, tempMember]; + + store.dispatch(setNewGroupInfo({ members: tempMembers })); + setSelectedMembers(tempMembers); + } + } else { + const tempMembers = selectedMembers.filter( + (item) => item.address !== contactAddress + ); + store.dispatch(setNewGroupInfo({ members: tempMembers })); + setSelectedMembers(tempMembers); + } + }; + + function showRemoveMemberPopup(action: CommonPopupOptions) { + setRemoveMemberPopup(false); + if (!selectedAddress) { + return; + } + + if (action === CommonPopupOptions.ok) { + removeMember(selectedAddress.address); + } + } + + const removeMember = async (contactAddress: string) => { + loadingIndicator.setIsLoading("group-action", true); + + const tempMembers = selectedMembers.filter( + (item) => item.address !== contactAddress + ); + + try { + const contents = await encryptGroupMessage( + current.chainId, + removeMemberEvent(contactAddress), + GroupMessageType.event, + userGroupAddress?.encryptedSymmetricKey || "", + accountInfo.bech32Address, + newGroupState.group.groupId, + user.accessToken + ); + const updatedGroupInfo: GroupDetails = { + description: group.description ?? "", + groupId: group.id, + contents: contents, + members: tempMembers, + name: group.name, + onlyAdminMessages: false, + }; + const tempGroup = await createGroup(updatedGroupInfo); + + if (tempGroup) { + /// Updating the UI + updateUserAddresses(tempMembers); + /// updating the new updated group + store.dispatch(setNewGroupInfo({ contents, members: tempMembers })); + /// update state of selected member + setSelectedMembers(tempMembers); + + /// updating the group(chat history) object + const groups: any = { [tempGroup.id]: tempGroup }; + store.dispatch(setGroups({ groups })); + + /// fetching the group messages again + await recieveMessages(group.id, null, 0, group.isDm, group.id); + } + } catch (e) { + // Show error toaster + console.error("error", e); + } finally { + loadingIndicator.setIsLoading("group-action", false); + } + }; + + const updateAdminStatus = async ( + contactAddress: string, + isAdmin: boolean + ) => { + const tempMember: GroupMembers | undefined = selectedMembers.find( + (element) => element.address === contactAddress + ); + + if (tempMember) { + loadingIndicator.setIsLoading("group-action", true); + + const updatedMember: GroupMembers = { + address: tempMember.address, + pubKey: tempMember.pubKey, + encryptedSymmetricKey: tempMember.encryptedSymmetricKey, + isAdmin: isAdmin || false, + }; + + const newMembers = selectedMembers.filter( + (item) => item.address !== contactAddress + ); + const tempMembers = [...newMembers, updatedMember]; + const statement = isAdmin + ? addAdminEvent(contactAddress) + : removedAdminEvent(contactAddress); + const contents = await encryptGroupMessage( + current.chainId, + statement, + GroupMessageType.event, + userGroupAddress?.encryptedSymmetricKey || "", + accountInfo.bech32Address, + newGroupState.group.groupId, + user.accessToken + ); + const updatedGroupInfo: GroupDetails = { + description: group.description ?? "", + groupId: group.id, + contents: contents, + members: tempMembers, + name: group.name, + onlyAdminMessages: false, + }; + + try { + const tempGroup = await createGroup(updatedGroupInfo); + + if (tempGroup) { + /// updating the new updated group + store.dispatch(setNewGroupInfo({ contents, members: tempMembers })); + setSelectedMembers(tempMembers); + + /// updating the group(chat history) object + const groups: any = { [tempGroup.id]: tempGroup }; + store.dispatch(setGroups({ groups })); + + /// fetching the group messages again + await recieveMessages(group.id, null, 0, group.isDm, group.id); + } + } catch (e) { + // Show error toaster + console.error("error", e); + } finally { + loadingIndicator.setIsLoading("group-action", false); + } + } + }; + + const AddContactOption = (address: string) => { + amplitude.getInstance().logEvent("Add to address click", { + from: "Group Info", + }); + history.push({ + pathname: "/setting/address-book", + state: { + openModal: true, + addressInputValue: address, + }, + }); + }; + + function showGroupPopup(address: any): void { + if (address.address !== walletAddress) { + setSelectedAddresse(address); + setConfirmAction(true); + } + } + + function handlePopupAction(action: GroupChatMemberOptions) { + setConfirmAction(false); + + if (!selectedAddress) { + return; + } + + switch (action) { + case GroupChatMemberOptions.messageMember: + amplitude.getInstance().logEvent("Open DM click", { + from: "Group Info", + }); + history.push(`/chat/${selectedAddress.address}`); + break; + + case GroupChatMemberOptions.addToAddressBook: + AddContactOption(selectedAddress.address); + break; + + case GroupChatMemberOptions.removeMember: + setRemoveMemberPopup(true); + amplitude.getInstance().logEvent("Remove Member from Group", { + from: "Group Info", + }); + break; + + case GroupChatMemberOptions.makeAdminStatus: + updateAdminStatus(selectedAddress.address, true); + amplitude.getInstance().logEvent("Make member an Admin", { + from: "Group Info", + }); + break; + + case GroupChatMemberOptions.removeAdminStatus: + updateAdminStatus(selectedAddress.address, false); + amplitude.getInstance().logEvent("Remove member as Admin", { + from: "Group Info", + }); + break; + + case GroupChatMemberOptions.viewInAddressBook: + amplitude.getInstance().logEvent("Address book viewed", { + from: "Group Info", + }); + history.push("/setting/address-book"); + break; + } + } + + return ( + <HeaderLayout + showChainName={false} + canChangeChainInfo={false} + alternativeTitle={"New Group Chat"} + onBackButton={() => { + history.goBack(); + }} + > + <div className={style.group}> + <div className={style.groupContainer}> + <div className={style.groupHeader}> + <span className={style.groupName}>{group.name}</span> + <span className={style.groupMembers}> + {`${addresses.length} member${addresses.length > 1 ? "s" : ""}`} + <i + className={"fa fa-user-plus"} + style={{ + width: "24px", + height: "24px", + padding: "2px 0 0 12px", + cursor: "pointer", + alignItems: "end", + alignSelf: "end", + }} + aria-hidden="true" + onClick={() => { + history.push({ + pathname: "/chat/group-chat/add-member", + }); + }} + /> + </span> + </div> + </div> + <span className={style.groupDescription}>{group.description}</span> + {!addressBookConfig.isLoaded ? ( + <ChatLoader message="Loading contacts, please wait..." /> + ) : ( + <div className={style.newMemberContainer}> + <div className={style.membersContainer}> + {addresses.map((address: any) => { + return ( + <ChatMember + address={address} + key={address.address} + isShowAdmin={ + selectedMembers.find( + (element) => element.address === address.address + )?.isAdmin ?? false + } + showPointer + showSelectedIcon={false} + onClick={() => showGroupPopup(address)} + /> + ); + })} + </div> + </div> + )} + {removeMemberPopup && ( + <AlertPopup + setConfirmAction={setConfirmAction} + heading={`Remove ${formatAddress(selectedAddress?.name ?? "")}`} + description={`${formatAddress( + selectedAddress?.name ?? "" + )} will no longer receive messages from this group. \nThe group will be notified that they have been removed.`} + firstButtonTitle="Cancel" + secondButtonTitle="Remove" + onClick={(action) => { + showRemoveMemberPopup(action); + }} + /> + )} + {confirmAction && ( + <GroupChatPopup + isAdded={selectedAddress.existsInAddressBook} + isFromReview={false} + name={selectedAddress?.name ?? ""} + selectedMember={selectedMembers.find( + (element) => element.address === selectedAddress?.address + )} + isLoginUserAdmin={ + group.addresses.find( + (element) => element.address === walletAddress + )?.isAdmin ?? false + } + onClick={(action) => { + handlePopupAction(action); + }} + /> + )} + </div> + </HeaderLayout> + ); +}); diff --git a/packages/extension/src/pages/group-chat/edit-member/style.module.scss b/packages/extension/src/pages/group-chat/edit-member/style.module.scss new file mode 100644 index 0000000000..bfdda80523 --- /dev/null +++ b/packages/extension/src/pages/group-chat/edit-member/style.module.scss @@ -0,0 +1,183 @@ +.searchContainer { + padding: 10px; + background-color: #ffffff; + width: 100%; + display: flex; + align-items: center; + .searchBox { + display: flex; + padding: 5px; + flex: 1; + background-color: #f2f3f6; + border-radius: 4px; + align-items: center; + gap: 10px; + img { + width: 16px; + height: 16px; + } + input { + border: none; + outline: none; + background: transparent; + flex-grow: 1; + &::placeholder { + color: #525f7f; + } + } + } +} +.membersContainer { + padding: 10px; + background-color: #ffffff; + padding-top: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.newMemberContainer { + margin-left: -12px; + margin-right: -12px; + + height: 70vh; + overflow-y: auto; +} + +.resultText { + color: #808da0; + font-weight: 400; + font-size: 15px; + margin-top: 10px; + margin-bottom: 16px; + text-align: center; +} +.searchHelp { + font-size: 13px; + color: #808da0; + margin-top: 5px; + text-align: center; +} + +.contacts { + display: flex; +} + +.button { + padding: 8px; + color: #fff; + text-align: center; + background-color: #3b82f6; + border: 1px solid #3b82f6; + border-radius: 4px; + flex-direction: row; + justify-content: center; + align-items: center; + font-size: 0.875rem; + line-height: 19px; + display: flex; +} + +.button:not(:disabled):not(.disabled) { + cursor: pointer; + background: #3b82f6; + border: 1px solid #3b82f6; + color: #fff; + font-size: 0.875rem; +} + +.group { + flex-direction: column; +} + +.groupHeader { + height: 40%; + flex-direction: row; + flex: 1; + justify-content: space-between; + align-items: end; + margin-top: 2px; + display: flex; +} + +.groupName { + width: 60%; + align-self: start; + font-size: 0.875rem; + word-break: break-word; + text-align: start; +} + +.groupDescription { + text-align: center; + margin-bottom: 19px; + color: #808da0; + width: -webkit-fill-available; + background-color: #fff; + align-items: center; + padding: 11px; + line-height: 16px; + display: flex; + margin-left: -12px; + margin-right: -12px; + margin-bottom: -12px; + word-break: break-word; +} + +.groupMembers { + align-self: start; + color: #525f7f; +} + +.groupContainer { + text-align: center; + color: #808da0; + width: -webkit-fill-available; + background-color: #fff; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 11px; + line-height: 16px; + display: flex; + margin-left: -12px; + margin-right: -12px; + margin-bottom: -12px; +} + +.initials { + display: inline-flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.memberContainer { + display: flex; + align-items: center; + + .initials1 { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #d0def5; + color: #525f7f; + margin-right: 8px; + font-weight: 400; + font-size: 15px; + line-height: 20px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + .memberInner { + width: 80%; + .name { + font-weight: 400; + font-size: 15px; + color: #525f7f; + height: 20px; + } + } +} diff --git a/packages/extension/src/pages/group-chat/review-details/index.tsx b/packages/extension/src/pages/group-chat/review-details/index.tsx new file mode 100644 index 0000000000..7eebe052b7 --- /dev/null +++ b/packages/extension/src/pages/group-chat/review-details/index.tsx @@ -0,0 +1,373 @@ +import { ExtensionKVStore } from "@keplr-wallet/common"; +import { + useAddressBookConfig, + useIBCTransferConfig, +} from "@keplr-wallet/hooks"; +import React, { FunctionComponent, useEffect, useState } from "react"; +import { useHistory } from "react-router"; +import { EthereumEndpoint } from "../../../config.ui"; +import { HeaderLayout } from "@layouts/index"; +import { useStore } from "../../../stores"; +import style from "./style.module.scss"; +import { observer } from "mobx-react-lite"; +import { ChatMember } from "@components/chat-member"; +import { + Group, + GroupChatMemberOptions, + GroupMembers, + Groups, + NewGroupDetails, +} from "@chatTypes"; +import { useSelector } from "react-redux"; +import { + newGroupDetails, + resetNewGroup, + setNewGroupInfo, +} from "@chatStore/new-group-slice"; +import { store } from "@chatStore/index"; +import { createGroup } from "@graphQL/groups-api"; +import { Button } from "reactstrap"; +import { setGroups, userChatGroups } from "@chatStore/messages-slice"; +import { createEncryptedSymmetricKeyForAddresses } from "@utils/symmetric-key"; +import { userDetails } from "@chatStore/user-slice"; +import { encryptGroupMessage, GroupMessageType } from "@utils/encrypt-group"; +import amplitude from "amplitude-js"; +import { GroupChatPopup } from "@components/group-chat-popup"; +import { useNotification } from "@components/notification"; +import { createGroupEvent } from "@utils/group-events"; + +export const ReviewGroupChat: FunctionComponent = observer(() => { + const history = useHistory(); + const notification = useNotification(); + + const newGroupState: NewGroupDetails = useSelector(newGroupDetails); + const [selectedMembers, setSelectedMembers] = useState<GroupMembers[]>( + newGroupState.group.members || [] + ); + const user = useSelector(userDetails); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [addresses, setAddresses] = useState<any[]>([]); + + const groups: Groups = useSelector(userChatGroups); + const group: Group = groups[newGroupState.group.groupId]; + + const [selectedAddress, setSelectedAddresse] = useState<any>(); + const [confirmAction, setConfirmAction] = useState(false); + + const { chainStore, accountStore, queriesStore } = useStore(); + const current = chainStore.current; + const accountInfo = accountStore.getAccount(current.chainId); + const walletAddress = accountInfo.bech32Address; + + // address book values + const queries = queriesStore.get(chainStore.current.chainId); + const ibcTransferConfigs = useIBCTransferConfig( + chainStore, + chainStore.current.chainId, + accountInfo.msgOpts.ibcTransfer, + accountInfo.bech32Address, + queries.queryBalances, + EthereumEndpoint + ); + + const [selectedChainId] = useState( + ibcTransferConfigs.channelConfig?.channel + ? ibcTransferConfigs.channelConfig.channel.counterpartyChainId + : current.chainId + ); + const addressBookConfig = useAddressBookConfig( + new ExtensionKVStore("address-book"), + chainStore, + selectedChainId, + { + setRecipient: (): void => { + // noop + }, + setMemo: (): void => { + // noop + }, + } + ); + + useEffect(() => { + const userAddresses: any[] = selectedMembers + .reduce((acc: any[], element: GroupMembers) => { + const addressData = addressBookConfig.addressBookDatas.find( + (data) => data.address === element.address + ); + if (addressData && addressData.address !== walletAddress) { + return [ + ...acc, + { + name: addressData.name, + address: addressData.address, + existsInAddressBook: true, + }, + ]; + } else { + return element.address === walletAddress + ? [ + { + name: "You", + address: walletAddress, + existsInAddressBook: false, + }, + ...acc, + ] + : [ + ...acc, + { + name: element.address, + address: element.address, + existsInAddressBook: false, + }, + ]; + } + }, []) + .sort(function (a, b) { + return b.address === walletAddress ? 0 : a.name.localeCompare(b.name); + }); + setAddresses(userAddresses); + }, [addressBookConfig.addressBookDatas, selectedMembers]); + + useEffect(() => { + if (newGroupState.isEditGroup) { + const groupData = Object.values(groups).find((group) => + group.id.includes(newGroupState.group.groupId) + ); + if (groupData) { + const updatedMembers: GroupMembers[] = groupData.addresses + .filter((element) => !element.removedAt) + .map((element) => { + return { + address: element.address, + pubKey: element.pubKey, + encryptedSymmetricKey: element.encryptedSymmetricKey, + isAdmin: element.isAdmin, + }; + }); + setSelectedMembers(updatedMembers); + store.dispatch(setNewGroupInfo({ members: updatedMembers })); + } + } + }, [groups, newGroupState.group.groupId, newGroupState.isEditGroup]); + + const handleRemoveMember = async (contactAddress: string) => { + const tempAddresses = selectedMembers.filter( + (item) => item.address !== contactAddress + ); + store.dispatch(setNewGroupInfo({ members: tempAddresses })); + setSelectedMembers(tempAddresses); + setAddresses(addresses.filter((item) => item.address !== contactAddress)); + }; + + const isUserAdmin = (address: string): boolean => { + return ( + selectedMembers.find((element) => element.address === address)?.isAdmin ?? + false + ); + }; + + /// check login user is admin and part of group + const isLoginUserAdmin = (): boolean => { + const groupAddress = group?.addresses.find( + (element) => element.address === walletAddress + ); + if (groupAddress) { + return groupAddress.isAdmin && !groupAddress.removedAt; + } + + return false; + }; + + const AddContactOption = (address: string) => { + amplitude.getInstance().logEvent("Add to address click", { + from: "Group Info", + }); + history.push({ + pathname: "/setting/address-book", + state: { + openModal: true, + addressInputValue: address, + }, + }); + }; + + function showGroupPopup(address: any): void { + if (address.address !== walletAddress) { + setSelectedAddresse(address); + setConfirmAction(true); + } + } + + function handlePopupAction(action: GroupChatMemberOptions) { + setConfirmAction(false); + + if (!selectedAddress) { + return; + } + + switch (action) { + case GroupChatMemberOptions.messageMember: + amplitude.getInstance().logEvent("Open DM click", { + from: "Group Info", + }); + history.push(`/chat/${selectedAddress.address}`); + break; + + case GroupChatMemberOptions.addToAddressBook: + AddContactOption(selectedAddress.address); + break; + + case GroupChatMemberOptions.viewInAddressBook: + amplitude.getInstance().logEvent("Address book viewed", { + from: "Group Info", + }); + history.push("/setting/address-book"); + break; + } + } + + const createNewGroup = async () => { + setIsLoading(true); + const updatedGroupMembers = await createEncryptedSymmetricKeyForAddresses( + newGroupState.group.members, + current.chainId, + user.accessToken + ); + const userGroupAddress = updatedGroupMembers.find( + (address) => address.address == accountInfo.bech32Address + ); + const encryptedSymmetricKey = userGroupAddress?.encryptedSymmetricKey || ""; + const contents = await encryptGroupMessage( + current.chainId, + createGroupEvent(accountInfo.bech32Address), + GroupMessageType.event, + encryptedSymmetricKey, + accountInfo.bech32Address, + createGroupEvent(accountInfo.bech32Address), + user.accessToken + ); + + const newGroupData = { + ...newGroupState.group, + members: updatedGroupMembers, + contents, + }; + const groupData = await createGroup(newGroupData); + setIsLoading(false); + + if (groupData) { + store.dispatch(resetNewGroup()); + const groups: any = { [groupData.id]: groupData }; + store.dispatch(setGroups({ groups })); + /// Clearing stack till chat tab + history.go(-4); + setTimeout(() => { + amplitude.getInstance().logEvent("New group created", {}); + history.push(`/chat/group-chat-section/${groupData.id}`); + }, 100); + } + }; + + return ( + <HeaderLayout + showChainName={false} + canChangeChainInfo={false} + alternativeTitle={"New Group Chat"} + onBackButton={() => { + history.goBack(); + }} + > + <div className={style.tokens}> + <img + className={style.groupImage} + src={require("@assets/group710.svg")} + /> + <span className={style.groupDescription}> + {group?.name ?? newGroupState.group.name} + </span> + <span className={style.groupDescription}> + {group?.description ?? newGroupState.group.description} + </span> + {newGroupState.isEditGroup && isUserAdmin(walletAddress) && ( + <Button + className={style.button} + size="large" + onClick={async () => { + history.push("/chat/group-chat/edit-member"); + }} + > + Edit Chat Settings + </Button> + )} + </div> + <div className={style.membersContainer}> + { + <text className={style.memberText}> + {addresses.length} member + {addresses.length > 1 ? "s" : ""} + </text> + } + + {addresses.map((address: any) => { + return ( + <ChatMember + address={address} + key={address.address} + /// showSelectedIcon: isEditGroup true means remove the cross icon + showSelectedIcon={!newGroupState.isEditGroup} + isSelected={true} + isShowAdmin={isUserAdmin(address.address)} + showPointer + onClick={() => showGroupPopup(address)} + onIconClick={() => { + handleRemoveMember(address.address); + }} + /> + ); + })} + </div> + {!newGroupState.isEditGroup && ( + <Button + className={style.button} + size="large" + data-loading={isLoading} + onClick={() => { + if (selectedMembers.length > 1) { + createNewGroup(); + } else { + notification.push({ + type: "warning", + placement: "top-center", + duration: 5, + content: `At least 2 members must be selected`, + canDelete: true, + transition: { + duration: 0.25, + }, + }); + } + }} + > + Create Group Chat + </Button> + )} + {confirmAction && !isUserAdmin(walletAddress) && ( + /// Display popup for non admin member + <GroupChatPopup + isAdded={selectedAddress.existsInAddressBook} + isFromReview={true} + name={selectedAddress?.name ?? ""} + selectedMember={selectedMembers.find( + (element) => element.address === selectedAddress?.address + )} + isLoginUserAdmin={isLoginUserAdmin()} + onClick={(action) => { + handlePopupAction(action); + }} + /> + )} + </HeaderLayout> + ); +}); diff --git a/packages/extension/src/pages/group-chat/review-details/style.module.scss b/packages/extension/src/pages/group-chat/review-details/style.module.scss new file mode 100644 index 0000000000..7db0200fa7 --- /dev/null +++ b/packages/extension/src/pages/group-chat/review-details/style.module.scss @@ -0,0 +1,154 @@ +.tokens { + color: #525f7f; + width: -webkit-fill-available; + background-color: #fff; + flex-direction: column; + padding: 10px; + font-weight: 400; + line-height: 16px; + display: flex; + margin-left: -12px; + margin-right: -12px; +} + +.groupImageText { + text-align: center; + align-self: flex-start; + margin-bottom: 6px; + margin-left: 1px; + font-weight: 700; + line-height: 19px; +} + +.groupImage { + text-align: center; + align-self: flex-start; + width: 120px; + height: 120px; + object-fit: cover; + margin: 13px auto; +} + +.groupDescription { + text-align: center; + margin-bottom: 19px; + word-break: break-word; +} + +.adminToggle { + color: #808da0; + width: 85%; + flex-direction: row; + justify-content: space-between; + align-self: flex-start; + align-items: center; + margin-bottom: 20px; + font-size: 1rem; + display: flex; +} + +.toggle { + width: 42px; + height: 24px; + object-fit: cover; +} + +.adminText { + margin-top: 2px; + margin-right: -51px; +} + +.inputText { + color: #525f7f; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + padding: 4px; + line-height: 16px; + display: flex; +} + +.input { + color: #525f7f; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + padding-top: 4px; + font-size: 0.875rem; + font-weight: 400; + line-height: 19px; + margin: 8px 0px; +} + +.text { + text-align: center; + margin-left: 1px; + font-weight: 700; +} + +.inputText { + color: #808da0; + width: 100%; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 4px; + flex-direction: row; + flex-basis: 62%; + justify-content: flex-start; + align-items: stretch; + font-size: 1rem; + line-height: 21px; + display: flex; +} + +.button { + width: 100%; + background-color: #3b82f6; + font-weight: 700; + color: #fff; + font-size: 14px; + height: 35px; + border: none; + outline: none; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 10px; +} + +.button:not(:disabled):not(.disabled) { + cursor: pointer; + background: #3b82f6; + font-weight: 700; + color: #fff; + font-size: 14px; + height: 35px; +} + +.membersContainer { + margin-left: -12px; + margin-right: -12px; + margin-top: 10px; + padding: 10px 20px; + background-color: #ffffff; + padding-top: 20px; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 42%; + height: auto; + + .memberText { + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + white-space: nowrap; + font-weight: 400; + font-size: 15px; + color: #525f7f; + height: 20px; + } +} diff --git a/packages/extension/src/pages/ibc-transfer/index.tsx b/packages/extension/src/pages/ibc-transfer/index.tsx index 7443307e29..4ff9b90e25 100644 --- a/packages/extension/src/pages/ibc-transfer/index.tsx +++ b/packages/extension/src/pages/ibc-transfer/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useState } from "react"; import { observer } from "mobx-react-lite"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import style from "./style.module.scss"; @@ -11,7 +11,7 @@ import { FeeButtons, MemoInput, DestinationChainSelector, -} from "../../components/form"; +} from "@components/form"; import { IAmountConfig, IFeeConfig, @@ -23,7 +23,7 @@ import { } from "@keplr-wallet/hooks"; import { useStore } from "../../stores"; import { EthereumEndpoint } from "../../config.ui"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { FormattedMessage, useIntl } from "react-intl"; export const IBCTransferPage: FunctionComponent = observer(() => { diff --git a/packages/extension/src/pages/ledger/grant.tsx b/packages/extension/src/pages/ledger/grant.tsx index 6b99fac263..d16025f0d5 100644 --- a/packages/extension/src/pages/ledger/grant.tsx +++ b/packages/extension/src/pages/ledger/grant.tsx @@ -15,11 +15,11 @@ import { } from "@keplr-wallet/background"; import style from "./style.module.scss"; -import { EmptyLayout } from "../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import classnames from "classnames"; import { FormattedMessage, useIntl } from "react-intl"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import delay from "delay"; import { useInteractionInfo } from "@keplr-wallet/hooks"; import { observer } from "mobx-react-lite"; @@ -122,7 +122,7 @@ export const LedgerGrantPage: FunctionComponent = observer(() => { <Instruction icon={ <img - src={require("../../public/assets/img/icons8-usb-2.svg")} + src={require("@assets/img/icons8-usb-2.svg")} style={{ height: "50px" }} alt="usb" /> @@ -134,7 +134,7 @@ export const LedgerGrantPage: FunctionComponent = observer(() => { <Instruction icon={ <img - src={require("../../public/assets/img/atom-o.svg")} + src={require("@assets/img/atom-o.svg")} style={{ height: "34px" }} alt="atom" /> @@ -232,10 +232,7 @@ const ConfirmLedgerDialog: FunctionComponent = () => { justifyContent: "flex-end", }} > - <img - src={require("../../public/assets/img/icons8-pen.svg")} - alt="pen" - /> + <img src={require("@assets/img/icons8-pen.svg")} alt="pen" /> </div> <p> <FormattedMessage id="ledger.confirm.waiting.paragraph" /> @@ -270,15 +267,9 @@ const SignCompleteDialog: FunctionComponent<{ }} > {!rejected ? ( - <img - src={require("../../public/assets/img/icons8-checked.svg")} - alt="success" - /> + <img src={require("@assets/img/icons8-checked.svg")} alt="success" /> ) : ( - <img - src={require("../../public/assets/img/icons8-cancel.svg")} - alt="rejected" - /> + <img src={require("@assets/img/icons8-cancel.svg")} alt="rejected" /> )} </div> <p> diff --git a/packages/extension/src/pages/lock/index.tsx b/packages/extension/src/pages/lock/index.tsx index f1daa7b4cb..6f5fc5f85b 100644 --- a/packages/extension/src/pages/lock/index.tsx +++ b/packages/extension/src/pages/lock/index.tsx @@ -1,15 +1,15 @@ import React, { FunctionComponent, useEffect, useRef, useState } from "react"; -import { PasswordInput } from "../../components/form"; +import { PasswordInput } from "@components/form"; import { Button, Form } from "reactstrap"; import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; -import { Banner } from "../../components/banner"; +import { Banner } from "@components/banner"; import useForm from "react-hook-form"; -import { EmptyLayout } from "../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import style from "./style.module.scss"; @@ -86,8 +86,8 @@ export const LockPage: FunctionComponent = observer(() => { })} > <Banner - icon={require("../../public/assets/temp-icon.svg")} - logo={require("../../public/assets/logo-temp.png")} + icon={require("@assets/temp-icon.svg")} + logo={require("@assets/logo-temp.png")} /> <PasswordInput label={intl.formatMessage({ diff --git a/packages/extension/src/pages/main/account.module.scss b/packages/extension/src/pages/main/account.module.scss index 00c1157e3d..29e31ed33b 100644 --- a/packages/extension/src/pages/main/account.module.scss +++ b/packages/extension/src/pages/main/account.module.scss @@ -7,6 +7,16 @@ font-size: 20px; color: #32325d; font-weight: bold; + overflow: hidden; + text-align: center; + word-break: break-all; + margin-right: 5px; + text-overflow: ellipsis; + display: -webkit-box; + line-height: 21px; + max-height: 48px; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } .container-account { diff --git a/packages/extension/src/pages/main/account.tsx b/packages/extension/src/pages/main/account.tsx index 6ad4efc470..e3de66c96a 100644 --- a/packages/extension/src/pages/main/account.tsx +++ b/packages/extension/src/pages/main/account.tsx @@ -1,13 +1,13 @@ import React, { FunctionComponent, useCallback } from "react"; -import { Address } from "../../components/address"; +import { Address } from "@components/address"; import styleAccount from "./account.module.scss"; import { WalletStatus } from "@keplr-wallet/stores"; import { observer } from "mobx-react-lite"; import { useIntl } from "react-intl"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { useStore } from "../../stores"; export const AccountView: FunctionComponent = observer(() => { diff --git a/packages/extension/src/pages/main/asset.tsx b/packages/extension/src/pages/main/asset.tsx index 49efb5830b..6634a6f774 100644 --- a/packages/extension/src/pages/main/asset.tsx +++ b/packages/extension/src/pages/main/asset.tsx @@ -6,20 +6,20 @@ import React, { useCallback, } from "react"; import { FormattedMessage } from "react-intl"; -import { ToolTip } from "../../components/tooltip"; +import { ToolTip } from "@components/tooltip"; import { useLanguage } from "../../languages"; import { useStore } from "../../stores"; import styleAsset from "./asset.module.scss"; import { TxButtonView } from "./tx-button"; -import walletIcon from "../../public/assets/icon/wallet.png"; -import buyIcon from "../../public/assets/icon/buy.png"; +import walletIcon from "@assets/icon/wallet.png"; +import buyIcon from "@assets/icon/buy.png"; import { DepositView } from "./deposit"; import { DepositModal } from "./qr-code"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { useIntl } from "react-intl"; import { WalletStatus } from "@keplr-wallet/stores"; -import { store } from "../../chatStore"; -import { setIsChatActive } from "../../chatStore/user-slice"; +import { store } from "@chatStore/index"; +import { setIsChatActive } from "@chatStore/user-slice"; export const ProgressBar = ({ width, diff --git a/packages/extension/src/pages/main/bip44-select-modal.tsx b/packages/extension/src/pages/main/bip44-select-modal.tsx index b16889ad7f..805e871f84 100644 --- a/packages/extension/src/pages/main/bip44-select-modal.tsx +++ b/packages/extension/src/pages/main/bip44-select-modal.tsx @@ -7,7 +7,7 @@ import { useStore } from "../../stores"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; import { BIP44 } from "@keplr-wallet/types"; -import { useLoadingIndicator } from "../../components/loading-indicator"; +import { useLoadingIndicator } from "@components/loading-indicator"; import { Dec } from "@keplr-wallet/unit"; const BIP44Selectable: FunctionComponent<{ diff --git a/packages/extension/src/pages/main/deposit.tsx b/packages/extension/src/pages/main/deposit.tsx index 58ea1381f4..e9bae0dac3 100644 --- a/packages/extension/src/pages/main/deposit.tsx +++ b/packages/extension/src/pages/main/deposit.tsx @@ -5,7 +5,7 @@ import styleDeposit from "./deposit.module.scss"; import classnames from "classnames"; import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { useIntl } from "react-intl"; import { WalletStatus } from "@keplr-wallet/stores"; import { FormattedMessage } from "react-intl"; diff --git a/packages/extension/src/pages/main/index.tsx b/packages/extension/src/pages/main/index.tsx index 2aaae79157..a34c298aed 100644 --- a/packages/extension/src/pages/main/index.tsx +++ b/packages/extension/src/pages/main/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useEffect, useRef } from "react"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { Card, CardBody } from "reactstrap"; @@ -8,8 +8,8 @@ import { ChainUpdaterService } from "@keplr-wallet/background"; import classnames from "classnames"; import { observer } from "mobx-react-lite"; import { useIntl } from "react-intl"; -import { useConfirm } from "../../components/confirm"; -import { SwitchUser } from "../../components/switch-user"; +import { useConfirm } from "@components/confirm"; +import { SwitchUser } from "@components/switch-user"; import { useStore } from "../../stores"; import { AccountView } from "./account"; import { AssetView } from "./asset"; diff --git a/packages/extension/src/pages/main/menu.module.scss b/packages/extension/src/pages/main/menu.module.scss index d6bf39b78f..a9df13be22 100644 --- a/packages/extension/src/pages/main/menu.module.scss +++ b/packages/extension/src/pages/main/menu.module.scss @@ -7,10 +7,13 @@ .item { cursor: pointer; - + color: #32325d; padding: 8px 20px; font-size: 24px; font-weight: bold; +} + +a.item:hover { color: #32325d; } diff --git a/packages/extension/src/pages/main/stake.tsx b/packages/extension/src/pages/main/stake.tsx index 70128db634..261548178d 100644 --- a/packages/extension/src/pages/main/stake.tsx +++ b/packages/extension/src/pages/main/stake.tsx @@ -10,7 +10,7 @@ import styleStake from "./stake.module.scss"; import classnames from "classnames"; import { Dec } from "@keplr-wallet/unit"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { useHistory } from "react-router"; diff --git a/packages/extension/src/pages/main/token.tsx b/packages/extension/src/pages/main/token.tsx index eb666af053..bd9a1bb8e2 100644 --- a/packages/extension/src/pages/main/token.tsx +++ b/packages/extension/src/pages/main/token.tsx @@ -8,9 +8,9 @@ import { Hash } from "@keplr-wallet/crypto"; import { ObservableQueryBalanceInner } from "@keplr-wallet/stores/build/query/balances"; import { UncontrolledTooltip } from "reactstrap"; import { WrongViewingKeyError } from "@keplr-wallet/stores"; -import { useNotification } from "../../components/notification"; -import { useLoadingIndicator } from "../../components/loading-indicator"; -import sendIcon from "../../public/assets/icon/send.png"; +import { useNotification } from "@components/notification"; +import { useLoadingIndicator } from "@components/loading-indicator"; +import sendIcon from "@assets/icon/send.png"; import { Dec } from "@keplr-wallet/unit"; const TokenView: FunctionComponent<{ diff --git a/packages/extension/src/pages/main/tx-button.tsx b/packages/extension/src/pages/main/tx-button.tsx index b12c4f4f74..a78d9be3cb 100644 --- a/packages/extension/src/pages/main/tx-button.tsx +++ b/packages/extension/src/pages/main/tx-button.tsx @@ -8,20 +8,20 @@ import { observer } from "mobx-react-lite"; import { useStore } from "../../stores"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { FormattedMessage } from "react-intl"; import { useHistory } from "react-router"; import { Dec } from "@keplr-wallet/unit"; -import reward from "../../public/assets/icon/reward.png"; -import send from "../../public/assets/icon/send.png"; -import stake from "../../public/assets/icon/stake.png"; +import reward from "@assets/icon/reward.png"; +import send from "@assets/icon/send.png"; +import stake from "@assets/icon/stake.png"; -import activeReward from "../../public/assets/icon/activeReward.png"; -import activeSend from "../../public/assets/icon/activeSend.png"; -import activeStake from "../../public/assets/icon/activeStake.png"; +import activeReward from "@assets/icon/activeReward.png"; +import activeSend from "@assets/icon/activeSend.png"; +import activeStake from "@assets/icon/activeStake.png"; export const TxButtonView: FunctionComponent = observer(() => { const { accountStore, chainStore, queriesStore, analyticsStore } = useStore(); diff --git a/packages/extension/src/pages/more/index.tsx b/packages/extension/src/pages/more/index.tsx index 4e9d940f35..5e654b8e5c 100644 --- a/packages/extension/src/pages/more/index.tsx +++ b/packages/extension/src/pages/more/index.tsx @@ -1,8 +1,8 @@ import classnames from "classnames"; import React, { FunctionComponent } from "react"; import { Card, CardBody } from "reactstrap"; -import { SwitchUser } from "../../components/switch-user"; -import { HeaderLayout } from "../../layouts"; +import { SwitchUser } from "@components/switch-user"; +import { HeaderLayout } from "@layouts/index"; import { IBCTransferView } from "../main/ibc-transfer"; import { Menu } from "../main/menu"; import style from "./style.module.scss"; diff --git a/packages/extension/src/pages/newchat/new-chat.tsx b/packages/extension/src/pages/newchat/new-chat.tsx index 97c5892635..cf1634bbbc 100644 --- a/packages/extension/src/pages/newchat/new-chat.tsx +++ b/packages/extension/src/pages/newchat/new-chat.tsx @@ -1,32 +1,35 @@ +import { fromBech32 } from "@cosmjs/encoding"; +import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; import { ExtensionKVStore } from "@keplr-wallet/common"; +import { Bech32Address } from "@keplr-wallet/cosmos"; import { useAddressBookConfig, useIBCTransferConfig, } from "@keplr-wallet/hooks"; +import jazzicon from "@metamask/jazzicon"; +import amplitude from "amplitude-js"; +import { observer } from "mobx-react-lite"; import React, { FunctionComponent, useEffect, useState } from "react"; +import ReactHtmlParser from "react-html-parser"; +import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { HeaderLayout } from "../../layouts"; -import rightArrowIcon from "../../public/assets/icon/right-arrow.png"; -import searchIcon from "../../public/assets/icon/search.png"; -import { useStore } from "../../stores"; -import style from "./style.module.scss"; -import chevronLeft from "../../public/assets/icon/chevron-left.png"; -import { Bech32Address } from "@keplr-wallet/cosmos"; -import { observer } from "mobx-react-lite"; -import { SwitchUser } from "../../components/switch-user"; +import { NameAddress } from "@chatTypes"; +import { userDetails } from "@chatStore/user-slice"; +import { ChatLoader } from "@components/chat-loader"; +import { SwitchUser } from "@components/switch-user"; import { EthereumEndpoint } from "../../config.ui"; -import { NameAddress } from "../chat/users"; -import { formatAddress } from "../../utils/format"; -import { fetchPublicKey } from "../../utils/fetch-public-key"; -import { useSelector } from "react-redux"; -import { userDetails } from "../../chatStore/user-slice"; +import { HeaderLayout } from "@layouts/index"; +import chevronLeft from "@assets/icon/chevron-left.png"; +import rightArrowIcon from "@assets/icon/right-arrow.png"; +import searchIcon from "@assets/icon/search.png"; +import { useStore } from "../../stores"; +import { fetchPublicKey } from "@utils/fetch-public-key"; +import { formatAddress } from "@utils/format"; import { Menu } from "../main/menu"; -import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; -import jazzicon from "@metamask/jazzicon"; -import ReactHtmlParser from "react-html-parser"; -import { fromBech32 } from "@cosmjs/encoding"; -import { ChatLoader } from "../../components/chat-loader"; -import amplitude from "amplitude-js"; +import style from "./style.module.scss"; +import { store } from "@chatStore/index"; +import { resetNewGroup } from "@chatStore/new-group-slice"; +import { DeactivatedChat } from "@components/chat/deactivated-chat"; const NewUser = (props: { address: NameAddress }) => { const history = useHistory(); @@ -89,7 +92,12 @@ const NewUser = (props: { address: NameAddress }) => { {isLoading ? ( <i className="fas fa-spinner fa-spin ml-1" /> ) : ( - <img src={rightArrowIcon} style={{ width: "80%" }} alt="message" /> + <img + draggable={false} + src={rightArrowIcon} + style={{ width: "80%" }} + alt="message" + /> )} </div> </div> @@ -136,11 +144,13 @@ export const NewChat: FunctionComponent = observer(() => { } ); - const useraddresses: NameAddress[] = addressBookConfig.addressBookDatas.map( - (data) => { + const useraddresses: NameAddress[] = addressBookConfig.addressBookDatas + .map((data) => { return { name: data.name, address: data.address }; - } - ); + }) + .sort(function (a, b) { + return a.name.localeCompare(b.name); + }); useEffect(() => { setAddresses(useraddresses.filter((a) => a.address !== walletAddress)); @@ -184,6 +194,14 @@ export const NewChat: FunctionComponent = observer(() => { setAddresses(addresses); } }; + + if ( + user.messagingPubKey.privacySetting && + user.messagingPubKey.privacySetting === PrivacySetting.Nobody + ) { + return <DeactivatedChat />; + } + return ( <HeaderLayout showChainName={true} @@ -194,8 +212,8 @@ export const NewChat: FunctionComponent = observer(() => { {!addressBookConfig.isLoaded ? ( <ChatLoader message="Loading contacts, please wait..." /> ) : ( - <div> - <div className={style.newChatContainer}> + <div className={style.newChatContainer}> + <div className={style.newChatHeader}> <div className={style.leftBox}> <img alt="" @@ -210,7 +228,7 @@ export const NewChat: FunctionComponent = observer(() => { </div> <div className={style.searchContainer}> <div className={style.searchBox}> - <img src={searchIcon} alt="search" /> + <img draggable={false} src={searchIcon} alt="search" /> <input placeholder="Search by name or address" value={inputVal} @@ -221,7 +239,20 @@ export const NewChat: FunctionComponent = observer(() => { <div className={style.searchHelp}> You can search your contacts or paste any valid {current.chainName}{" "} address to start a conversation. + <br /> or <br /> + <button + className={style.button} + onClick={() => { + store.dispatch(resetNewGroup()); + history.push({ + pathname: "/chat/group-chat/create", + }); + }} + > + Create new group chat + </button> </div> + <div className={style.messagesContainer}> {randomAddress && ( <NewUser address={randomAddress} key={randomAddress.address} /> diff --git a/packages/extension/src/pages/newchat/style.module.scss b/packages/extension/src/pages/newchat/style.module.scss index eec80789ab..bfd688dc45 100644 --- a/packages/extension/src/pages/newchat/style.module.scss +++ b/packages/extension/src/pages/newchat/style.module.scss @@ -10,7 +10,6 @@ border-radius: 4px; align-items: center; gap: 10px; - margin-right: 10px; img { width: 16px; height: 16px; @@ -39,7 +38,6 @@ display: flex; justify-content: space-between; align-items: center; - padding: 0 12px; cursor: pointer; .initials { width: 24px; @@ -86,14 +84,18 @@ } } } + .newChatContainer { margin-left: -12px; margin-right: -12px; padding: 10px; + background-color: #ffffff; +} + +.newChatHeader { align-items: center; display: flex; justify-content: space-between; - background-color: #ffffff; border-radius: 6px; flex-direction: row; .backBtn { @@ -125,8 +127,23 @@ font-size: 13px; color: #808da0; margin-top: 5px; + text-align: center; } .contacts { display: flex; } + +.button { + display: flex; + flex-direction: row; + justify-content: center; + padding: 10px; + border: solid #3b82f6 1px; + border-radius: 6px; + color: #3b82f6; + font-weight: bold; + cursor: pointer; + width: 100%; + background: #ffffff; +} diff --git a/packages/extension/src/pages/register/advanced-bip44.tsx b/packages/extension/src/pages/register/advanced-bip44.tsx index c9e64ec6b8..7f96c5aaf6 100644 --- a/packages/extension/src/pages/register/advanced-bip44.tsx +++ b/packages/extension/src/pages/register/advanced-bip44.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useState } from "react"; import { Button, FormGroup, Input, Label } from "reactstrap"; -import { useConfirm } from "../../components/confirm"; +import { useConfirm } from "@components/confirm"; import { FormattedMessage, useIntl } from "react-intl"; import { action, computed, makeObservable, observable } from "mobx"; import { observer } from "mobx-react-lite"; diff --git a/packages/extension/src/pages/register/index.tsx b/packages/extension/src/pages/register/index.tsx index 5db211b42b..a0fe2df57a 100644 --- a/packages/extension/src/pages/register/index.tsx +++ b/packages/extension/src/pages/register/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useEffect } from "react"; -import { EmptyLayout } from "../../layouts/empty-layout"; +import { EmptyLayout } from "@layouts/empty-layout"; import { observer } from "mobx-react-lite"; @@ -93,13 +93,13 @@ export const RegisterPage: FunctionComponent = observer(() => { <div className={style.logoContainer}> <img className={style.icon} - src={require("../../public/assets/temp-icon.svg")} + src={require("@assets/temp-icon.svg")} alt="logo" /> <div className={style.logoInnerContainer}> <img className={style.logo} - src={require("../../public/assets/logo-temp.png")} + src={require("@assets/logo-temp.png")} alt="logo" /> </div> diff --git a/packages/extension/src/pages/register/ledger/index.tsx b/packages/extension/src/pages/register/ledger/index.tsx index a83ec4fc87..b336df6d26 100644 --- a/packages/extension/src/pages/register/ledger/index.tsx +++ b/packages/extension/src/pages/register/ledger/index.tsx @@ -4,7 +4,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Button, Form } from "reactstrap"; import useForm from "react-hook-form"; import style from "../style.module.scss"; -import { Input, PasswordInput } from "../../../components/form"; +import { Input, PasswordInput } from "@components/form"; import { AdvancedBIP44Option, useBIP44Option } from "../advanced-bip44"; import { BackButton } from "../index"; import { observer } from "mobx-react-lite"; diff --git a/packages/extension/src/pages/register/migration/metamask-privatekey.tsx b/packages/extension/src/pages/register/migration/metamask-privatekey.tsx index c5af197f7e..e4ee603533 100644 --- a/packages/extension/src/pages/register/migration/metamask-privatekey.tsx +++ b/packages/extension/src/pages/register/migration/metamask-privatekey.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent } from "react"; import { BackButton } from "../index"; import { FormattedMessage, useIntl } from "react-intl"; -import { Input, TextArea } from "../../../components/form"; +import { Input, TextArea } from "@components/form"; import style from "../style.module.scss"; import { Button, Form } from "reactstrap"; import useForm from "react-hook-form"; diff --git a/packages/extension/src/pages/register/mnemonic/new-mnemonic.tsx b/packages/extension/src/pages/register/mnemonic/new-mnemonic.tsx index 3077aa063a..2356006b19 100644 --- a/packages/extension/src/pages/register/mnemonic/new-mnemonic.tsx +++ b/packages/extension/src/pages/register/mnemonic/new-mnemonic.tsx @@ -10,7 +10,7 @@ import { } from "../advanced-bip44"; import style from "../style.module.scss"; import { Alert, Button, ButtonGroup, Form } from "reactstrap"; -import { Input, PasswordInput, TextArea } from "../../../components/form"; +import { Input, PasswordInput, TextArea } from "@components/form"; import { BackButton } from "../index"; import { NewMnemonicConfig, useNewMnemonicConfig, NumWords } from "./hook"; import { useStore } from "../../../stores"; diff --git a/packages/extension/src/pages/register/mnemonic/recover-mnemonic.tsx b/packages/extension/src/pages/register/mnemonic/recover-mnemonic.tsx index 43decec93d..7c5fbb3eb0 100644 --- a/packages/extension/src/pages/register/mnemonic/recover-mnemonic.tsx +++ b/packages/extension/src/pages/register/mnemonic/recover-mnemonic.tsx @@ -5,7 +5,7 @@ import { Button, Form } from "reactstrap"; import { FormattedMessage, useIntl } from "react-intl"; import style from "../style.module.scss"; import { BackButton } from "../index"; -import { Input, PasswordInput, TextArea } from "../../../components/form"; +import { Input, PasswordInput, TextArea } from "@components/form"; import useForm from "react-hook-form"; import { observer } from "mobx-react-lite"; import { RegisterConfig } from "@keplr-wallet/hooks"; diff --git a/packages/extension/src/pages/send/index.tsx b/packages/extension/src/pages/send/index.tsx index 9023e51db2..59dd870fef 100644 --- a/packages/extension/src/pages/send/index.tsx +++ b/packages/extension/src/pages/send/index.tsx @@ -4,15 +4,15 @@ import { FeeButtons, CoinInput, MemoInput, -} from "../../components/form"; +} from "@components/form"; import { useStore } from "../../stores"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { observer } from "mobx-react-lite"; import style from "./style.module.scss"; -import { useNotification } from "../../components/notification"; +import { useNotification } from "@components/notification"; import { useIntl } from "react-intl"; import { Button } from "reactstrap"; diff --git a/packages/extension/src/pages/setting/address-book/add-address-modal.tsx b/packages/extension/src/pages/setting/address-book/add-address-modal.tsx index 077d171088..b31af346c0 100644 --- a/packages/extension/src/pages/setting/address-book/add-address-modal.tsx +++ b/packages/extension/src/pages/setting/address-book/add-address-modal.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useEffect, useState } from "react"; -import { HeaderLayout } from "../../../layouts"; -import { AddressInput, Input, MemoInput } from "../../../components/form"; +import { HeaderLayout } from "@layouts/index"; +import { AddressInput, Input, MemoInput } from "@components/form"; import { Button } from "reactstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { observer } from "mobx-react-lite"; diff --git a/packages/extension/src/pages/setting/address-book/index.tsx b/packages/extension/src/pages/setting/address-book/index.tsx index 043fe71d28..6a520a4794 100644 --- a/packages/extension/src/pages/setting/address-book/index.tsx +++ b/packages/extension/src/pages/setting/address-book/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, useLocation } from "react-router"; import style from "../style.module.scss"; @@ -19,7 +19,7 @@ import { PageButton } from "../page-button"; import { AddAddressModal } from "./add-address-modal"; import { ExtensionKVStore } from "@keplr-wallet/common"; import { Bech32Address } from "@keplr-wallet/cosmos"; -import { useConfirm } from "../../../components/confirm"; +import { useConfirm } from "@components/confirm"; import { AddressBookSelectHandler, IIBCChannelConfig, @@ -136,7 +136,7 @@ export const AddressBookPage: FunctionComponent<{ img: ( <img alt="" - src={require("../../../public/assets/img/trash.svg")} + src={require("@assets/img/trash.svg")} style={{ height: "80px" }} /> ), diff --git a/packages/extension/src/pages/setting/chat/block/index.tsx b/packages/extension/src/pages/setting/chat/block/index.tsx index ead76012dd..0081a3adb1 100644 --- a/packages/extension/src/pages/setting/chat/block/index.tsx +++ b/packages/extension/src/pages/setting/chat/block/index.tsx @@ -5,13 +5,13 @@ import React, { FunctionComponent, useState } from "react"; import { useIntl } from "react-intl"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { userBlockedAddresses } from "../../../../chatStore/messages-slice"; -import { HeaderLayout } from "../../../../layouts"; +import { NameAddress } from "@chatTypes"; +import { userBlockedAddresses } from "@chatStore/messages-slice"; +import { UnblockUserPopup } from "@components/chat-actions-popup/unblock-user-popup"; +import { HeaderLayout } from "@layouts/index"; import { useStore } from "../../../../stores"; -import { formatAddress } from "../../../../utils/format"; -import { NameAddress } from "../../../chat/users"; +import { formatAddress } from "@utils/format"; import style from "./style.module.scss"; -import { UnblockUserPopup } from "./unblock-user-popup"; export const BlockList: FunctionComponent = observer(() => { // const language = useLanguage(); @@ -93,7 +93,7 @@ const BlockAddresses: React.FC<{ </div> <div> <img - src={require("../../../../public/assets/svg/x-icon.svg")} + src={require("@assets/svg/x-icon.svg")} style={{ width: "80%" }} alt="message" onClick={() => { diff --git a/packages/extension/src/pages/setting/chat/index.tsx b/packages/extension/src/pages/setting/chat/index.tsx index f3f02d79e1..ed75f996bf 100644 --- a/packages/extension/src/pages/setting/chat/index.tsx +++ b/packages/extension/src/pages/setting/chat/index.tsx @@ -1,23 +1,23 @@ import { observer } from "mobx-react-lite"; import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; import { useIntl } from "react-intl"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; // import { useLanguage } from "../../../languages"; import { PrivacySetting } from "@keplr-wallet/background/build/messaging/types"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { store } from "../../../chatStore"; -import { userBlockedAddresses } from "../../../chatStore/messages-slice"; +import { store } from "@chatStore/index"; +import { userBlockedAddresses } from "@chatStore/messages-slice"; import { setAccessToken, setMessagingPubKey, userDetails, -} from "../../../chatStore/user-slice"; +} from "@chatStore/user-slice"; import { AUTH_SERVER } from "../../../config.ui.var"; -import { fetchBlockList } from "../../../graphQL/messages-api"; +import { fetchBlockList } from "@graphQL/messages-api"; import { useStore } from "../../../stores"; -import { getJWT } from "../../../utils/auth"; -import { fetchPublicKey } from "../../../utils/fetch-public-key"; +import { getJWT } from "@utils/auth"; +import { fetchPublicKey } from "@utils/fetch-public-key"; import { PageButton } from "../page-button"; import style from "./style.module.scss"; diff --git a/packages/extension/src/pages/setting/chat/privacy/index.tsx b/packages/extension/src/pages/setting/chat/privacy/index.tsx index 553fe6d5be..ad0114e07d 100644 --- a/packages/extension/src/pages/setting/chat/privacy/index.tsx +++ b/packages/extension/src/pages/setting/chat/privacy/index.tsx @@ -8,13 +8,10 @@ import React, { FunctionComponent, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { useSelector } from "react-redux"; import { useHistory } from "react-router"; -import { store } from "../../../../chatStore"; -import { - setMessagingPubKey, - userDetails, -} from "../../../../chatStore/user-slice"; -import { useLoadingIndicator } from "../../../../components/loading-indicator"; -import { HeaderLayout } from "../../../../layouts"; +import { store } from "@chatStore/index"; +import { setMessagingPubKey, userDetails } from "@chatStore/user-slice"; +import { useLoadingIndicator } from "@components/loading-indicator"; +import { HeaderLayout } from "@layouts/index"; import { useStore } from "../../../../stores"; import { PageButton } from "../../page-button"; import style from "./style.module.scss"; @@ -93,7 +90,7 @@ export const Privacy: FunctionComponent = observer(() => { ? [ <img key={0} - src={require("../../../../public/assets/svg/tick-icon.svg")} + src={require("@assets/svg/tick-icon.svg")} style={{ width: "100%" }} alt="message" />, @@ -117,7 +114,7 @@ export const Privacy: FunctionComponent = observer(() => { ? [ <img key={0} - src={require("../../../../public/assets/svg/tick-icon.svg")} + src={require("@assets/svg/tick-icon.svg")} style={{ width: "100%" }} alt="message" />, @@ -141,7 +138,7 @@ export const Privacy: FunctionComponent = observer(() => { ? [ <img key={0} - src={require("../../../../public/assets/svg/tick-icon.svg")} + src={require("@assets/svg/tick-icon.svg")} style={{ width: "100%" }} alt="message" />, diff --git a/packages/extension/src/pages/setting/chat/readRecipt/index.tsx b/packages/extension/src/pages/setting/chat/readRecipt/index.tsx index 52341a9711..8fbd83f9ab 100644 --- a/packages/extension/src/pages/setting/chat/readRecipt/index.tsx +++ b/packages/extension/src/pages/setting/chat/readRecipt/index.tsx @@ -6,14 +6,11 @@ import { observer } from "mobx-react-lite"; import React, { FunctionComponent, useMemo, useState } from "react"; import { useSelector } from "react-redux"; import { useIntl } from "react-intl"; -import { store } from "../../../../chatStore"; +import { store } from "@chatStore/index"; import { useHistory } from "react-router"; -import { - setMessagingPubKey, - userDetails, -} from "../../../../chatStore/user-slice"; -import { useLoadingIndicator } from "../../../../components/loading-indicator"; -import { HeaderLayout } from "../../../../layouts"; +import { setMessagingPubKey, userDetails } from "@chatStore/user-slice"; +import { useLoadingIndicator } from "@components/loading-indicator"; +import { HeaderLayout } from "@layouts/index"; import { PageButton } from "../../page-button"; import { useStore } from "../../../../stores"; import style from "./style.module.scss"; @@ -99,7 +96,7 @@ export const ReadRecipt: FunctionComponent = observer(() => { ? [ <img key={0} - src={require("../../../../public/assets/svg/tick-icon.svg")} + src={require("@assets/svg/tick-icon.svg")} style={{ width: "100%" }} alt="message" />, @@ -130,7 +127,7 @@ export const ReadRecipt: FunctionComponent = observer(() => { ? [ <img key={0} - src={require("../../../../public/assets/svg/tick-icon.svg")} + src={require("@assets/svg/tick-icon.svg")} style={{ width: "100%" }} alt="message" />, diff --git a/packages/extension/src/pages/setting/clear/index.tsx b/packages/extension/src/pages/setting/clear/index.tsx index df79de3fdc..e0c61eb1eb 100644 --- a/packages/extension/src/pages/setting/clear/index.tsx +++ b/packages/extension/src/pages/setting/clear/index.tsx @@ -5,11 +5,11 @@ import React, { useEffect, useMemo, } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory, useRouteMatch } from "react-router"; import { FormattedMessage, useIntl } from "react-intl"; -import { PasswordInput } from "../../../components/form"; +import { PasswordInput } from "@components/form"; import { Button, Form } from "reactstrap"; import useForm from "react-hook-form"; import { useStore } from "../../../stores"; diff --git a/packages/extension/src/pages/setting/clear/warning-view.tsx b/packages/extension/src/pages/setting/clear/warning-view.tsx index 5d0f7ef148..f607f4e687 100644 --- a/packages/extension/src/pages/setting/clear/warning-view.tsx +++ b/packages/extension/src/pages/setting/clear/warning-view.tsx @@ -42,7 +42,7 @@ export const WarningView: FunctionComponent<{ ) : null} <div className={styleWarningView.trashContainer}> <img - src={require("../../../public/assets/img/icons8-trash-can.svg")} + src={require("@assets/img/icons8-trash-can.svg")} alt="trash-can" /> <div> diff --git a/packages/extension/src/pages/setting/connections/basic-access.tsx b/packages/extension/src/pages/setting/connections/basic-access.tsx index 1de9eb2df7..812e178ec7 100644 --- a/packages/extension/src/pages/setting/connections/basic-access.tsx +++ b/packages/extension/src/pages/setting/connections/basic-access.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useMemo, useState } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import style from "../style.module.scss"; import { useHistory } from "react-router"; @@ -15,7 +15,7 @@ import { import styleConnections from "./style.module.scss"; import { useIntl } from "react-intl"; -import { useConfirm } from "../../../components/confirm"; +import { useConfirm } from "@components/confirm"; export const SettingConnectionsPage: FunctionComponent = observer(() => { const history = useHistory(); @@ -87,7 +87,7 @@ export const SettingConnectionsPage: FunctionComponent = observer(() => { img: ( <img alt="unlink" - src={require("../../../public/assets/img/broken-link.svg")} + src={require("@assets/img/broken-link.svg")} style={{ height: "80px" }} /> ), diff --git a/packages/extension/src/pages/setting/connections/viewing-key.tsx b/packages/extension/src/pages/setting/connections/viewing-key.tsx index d24313a02c..07aa2639fe 100644 --- a/packages/extension/src/pages/setting/connections/viewing-key.tsx +++ b/packages/extension/src/pages/setting/connections/viewing-key.tsx @@ -4,9 +4,9 @@ import { observer } from "mobx-react-lite"; import { useStore } from "../../../stores"; import style from "../style.module.scss"; import { PageButton } from "../page-button"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useIntl } from "react-intl"; -import { useConfirm } from "../../../components/confirm"; +import { useConfirm } from "@components/confirm"; export const SettingSecret20ViewingKeyConnectionsPage: FunctionComponent = observer( () => { @@ -68,7 +68,7 @@ export const SettingSecret20ViewingKeyConnectionsPage: FunctionComponent = obser img: ( <img alt="unlink" - src={require("../../../public/assets/img/broken-link.svg")} + src={require("@assets/img/broken-link.svg")} style={{ height: "80px" }} /> ), diff --git a/packages/extension/src/pages/setting/credit/index.tsx b/packages/extension/src/pages/setting/credit/index.tsx index ccc47b7732..bbeed7d9b4 100644 --- a/packages/extension/src/pages/setting/credit/index.tsx +++ b/packages/extension/src/pages/setting/credit/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import { useIntl } from "react-intl"; import { PageButton } from "../page-button"; diff --git a/packages/extension/src/pages/setting/export-to-mobile/index.tsx b/packages/extension/src/pages/setting/export-to-mobile/index.tsx index f01dd4fd29..8b7661791f 100644 --- a/packages/extension/src/pages/setting/export-to-mobile/index.tsx +++ b/packages/extension/src/pages/setting/export-to-mobile/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useEffect, useState } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import { FormattedMessage, useIntl } from "react-intl"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -8,12 +8,12 @@ import QRCode from "qrcode.react"; import style from "./style.module.scss"; import WalletConnect from "@walletconnect/client"; import { Buffer } from "buffer/"; -import { useLoadingIndicator } from "../../../components/loading-indicator"; +import { useLoadingIndicator } from "@components/loading-indicator"; import { Button, Form } from "reactstrap"; import { observer } from "mobx-react-lite"; import { useStore } from "../../../stores"; import useForm from "react-hook-form"; -import { PasswordInput } from "../../../components/form"; +import { PasswordInput } from "@components/form"; import { ExportKeyRingData } from "@keplr-wallet/background"; import AES, { Counter } from "aes-js"; import { AddressBookConfigMap, AddressBookData } from "@keplr-wallet/hooks"; @@ -107,7 +107,7 @@ export const EnterPasswordToExportKeyRingView: FunctionComponent<{ height: "32px", marginRight: "12px", }} - src={require("../../../public/assets/svg/info-mark.svg")} + src={require("@assets/svg/info-mark.svg")} alt="info" /> <div @@ -145,7 +145,7 @@ export const EnterPasswordToExportKeyRingView: FunctionComponent<{ marginLeft: "80px", marginRight: "80px", }} - src={require("../../../public/assets/svg/export-to-mobile.svg")} + src={require("@assets/svg/export-to-mobile.svg")} alt="export-to-mobile" /> <div diff --git a/packages/extension/src/pages/setting/export/index.tsx b/packages/extension/src/pages/setting/export/index.tsx index 8b98303912..b3508cd3d1 100644 --- a/packages/extension/src/pages/setting/export/index.tsx +++ b/packages/extension/src/pages/setting/export/index.tsx @@ -4,11 +4,11 @@ import React, { useEffect, useState, } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory, useLocation, useRouteMatch } from "react-router"; import { FormattedMessage, useIntl } from "react-intl"; -import { PasswordInput } from "../../../components/form"; +import { PasswordInput } from "@components/form"; import { Button, Form } from "reactstrap"; import useForm from "react-hook-form"; import { WarningView } from "./warning-view"; diff --git a/packages/extension/src/pages/setting/export/warning-view.tsx b/packages/extension/src/pages/setting/export/warning-view.tsx index 4dd299a810..b5f5bb7d66 100644 --- a/packages/extension/src/pages/setting/export/warning-view.tsx +++ b/packages/extension/src/pages/setting/export/warning-view.tsx @@ -8,7 +8,7 @@ export const WarningView: FunctionComponent = () => { <div className={styleWarningView.innerContainer}> <img className={styleWarningView.imgLock} - src={require("../../../public/assets/img/icons8-lock.svg")} + src={require("@assets/img/icons8-lock.svg")} alt="lock" /> <p> diff --git a/packages/extension/src/pages/setting/fiat/index.tsx b/packages/extension/src/pages/setting/fiat/index.tsx index a596e7fa90..52feb54670 100644 --- a/packages/extension/src/pages/setting/fiat/index.tsx +++ b/packages/extension/src/pages/setting/fiat/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useMemo } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { PageButton } from "../page-button"; import style from "../style.module.scss"; diff --git a/packages/extension/src/pages/setting/index.tsx b/packages/extension/src/pages/setting/index.tsx index b143195b37..250fa60e92 100644 --- a/packages/extension/src/pages/setting/index.tsx +++ b/packages/extension/src/pages/setting/index.tsx @@ -1,12 +1,12 @@ import React, { FunctionComponent, useMemo } from "react"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import { PageButton } from "./page-button"; import style from "./style.module.scss"; import { useLanguage } from "../../languages"; import { useIntl } from "react-intl"; import { observer } from "mobx-react-lite"; -import { userChatActive } from "../../chatStore/user-slice"; +import { userChatActive } from "@chatStore/user-slice"; import { useSelector } from "react-redux"; // import { useStore } from "../../stores"; diff --git a/packages/extension/src/pages/setting/keyring/change/name.tsx b/packages/extension/src/pages/setting/keyring/change/name.tsx index b65096f341..8e75ce0e86 100644 --- a/packages/extension/src/pages/setting/keyring/change/name.tsx +++ b/packages/extension/src/pages/setting/keyring/change/name.tsx @@ -1,9 +1,9 @@ import React, { FunctionComponent, useState, useEffect, useMemo } from "react"; -import { HeaderLayout } from "../../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory, useRouteMatch } from "react-router"; import { FormattedMessage, useIntl } from "react-intl"; -import { Input } from "../../../../components/form"; +import { Input } from "@components/form"; import { Button, Form } from "reactstrap"; import useForm from "react-hook-form"; import { useStore } from "../../../../stores"; diff --git a/packages/extension/src/pages/setting/keyring/index.tsx b/packages/extension/src/pages/setting/keyring/index.tsx index c4b4be3636..a34e659e31 100644 --- a/packages/extension/src/pages/setting/keyring/index.tsx +++ b/packages/extension/src/pages/setting/keyring/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useState } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { observer } from "mobx-react-lite"; import { useStore } from "../../../stores"; @@ -9,17 +9,17 @@ import { useHistory } from "react-router"; import { Button, Popover, PopoverBody } from "reactstrap"; import style from "./style.module.scss"; -import { useLoadingIndicator } from "../../../components/loading-indicator"; +import { useLoadingIndicator } from "@components/loading-indicator"; import { PageButton } from "../page-button"; import { MultiKeyStoreInfoWithSelectedElem } from "@keplr-wallet/background"; import { FormattedMessage, useIntl } from "react-intl"; -import { store } from "../../../chatStore"; -import { resetUser } from "../../../chatStore/user-slice"; +import { store } from "@chatStore/index"; +import { resetUser } from "@chatStore/user-slice"; import { resetChatList, setIsChatSubscriptionActive, -} from "../../../chatStore/messages-slice"; -import { messageAndGroupListenerUnsubscribe } from "../../../graphQL/messages-api"; +} from "@chatStore/messages-slice"; +import { messageAndGroupListenerUnsubscribe } from "@graphQL/messages-api"; export const SetKeyRingPage: FunctionComponent = observer(() => { const intl = useIntl(); diff --git a/packages/extension/src/pages/setting/language/index.tsx b/packages/extension/src/pages/setting/language/index.tsx index 4653dd6d8f..4b38b4f997 100644 --- a/packages/extension/src/pages/setting/language/index.tsx +++ b/packages/extension/src/pages/setting/language/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useCallback, useMemo } from "react"; -import { HeaderLayout } from "../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { PageButton } from "../page-button"; import style from "../style.module.scss"; diff --git a/packages/extension/src/pages/setting/page-button.tsx b/packages/extension/src/pages/setting/page-button.tsx index a463406251..7f2af4f5a4 100644 --- a/packages/extension/src/pages/setting/page-button.tsx +++ b/packages/extension/src/pages/setting/page-button.tsx @@ -3,7 +3,7 @@ import React, { FunctionComponent } from "react"; import classnames from "classnames"; import stylePageButton from "./page-button.module.scss"; -import { ToolTip } from "../../components/tooltip"; +import { ToolTip } from "@components/tooltip"; export const PageButton: FunctionComponent< { diff --git a/packages/extension/src/pages/setting/token/add/index.tsx b/packages/extension/src/pages/setting/token/add/index.tsx index 2799451381..9ff02fe0e8 100644 --- a/packages/extension/src/pages/setting/token/add/index.tsx +++ b/packages/extension/src/pages/setting/token/add/index.tsx @@ -1,19 +1,19 @@ import React, { FunctionComponent, useEffect, useState } from "react"; -import { HeaderLayout } from "../../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import { useIntl, FormattedMessage } from "react-intl"; import style from "./style.module.scss"; import { Button, Form } from "reactstrap"; -import { Input } from "../../../../components/form"; +import { Input } from "@components/form"; import { observer } from "mobx-react-lite"; import { useStore } from "../../../../stores"; import useForm from "react-hook-form"; import { Bech32Address } from "@keplr-wallet/cosmos"; import { CW20Currency, Secret20Currency } from "@keplr-wallet/types"; import { useInteractionInfo } from "@keplr-wallet/hooks"; -import { useLoadingIndicator } from "../../../../components/loading-indicator"; -import { useNotification } from "../../../../components/notification"; +import { useLoadingIndicator } from "@components/loading-indicator"; +import { useNotification } from "@components/notification"; interface FormData { contractAddress: string; diff --git a/packages/extension/src/pages/setting/token/manage/index.tsx b/packages/extension/src/pages/setting/token/manage/index.tsx index 420e57d860..a4ec6e00af 100644 --- a/packages/extension/src/pages/setting/token/manage/index.tsx +++ b/packages/extension/src/pages/setting/token/manage/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent } from "react"; -import { HeaderLayout } from "../../../../layouts"; +import { HeaderLayout } from "@layouts/index"; import { useHistory } from "react-router"; import { PageButton } from "../../page-button"; @@ -7,8 +7,8 @@ import style from "./style.module.scss"; import { observer } from "mobx-react-lite"; import { useStore } from "../../../../stores"; import { Bech32Address } from "@keplr-wallet/cosmos"; -import { useNotification } from "../../../../components/notification"; -import { useConfirm } from "../../../../components/confirm"; +import { useNotification } from "@components/notification"; +import { useConfirm } from "@components/confirm"; import { CW20Currency, Secret20Currency } from "@keplr-wallet/types"; import { useIntl } from "react-intl"; diff --git a/packages/extension/src/pages/sign/details-tab.tsx b/packages/extension/src/pages/sign/details-tab.tsx index a4cca82d69..b5a51ee44c 100644 --- a/packages/extension/src/pages/sign/details-tab.tsx +++ b/packages/extension/src/pages/sign/details-tab.tsx @@ -8,7 +8,7 @@ import styleDetailsTab from "./details-tab.module.scss"; import { renderAminoMessage } from "./amino"; import { Msg } from "@cosmjs/launchpad"; import { FormattedMessage, useIntl } from "react-intl"; -import { FeeButtons, MemoInput } from "../../components/form"; +import { FeeButtons, MemoInput } from "@components/form"; import { IFeeConfig, IGasConfig, diff --git a/packages/extension/src/pages/sign/index.tsx b/packages/extension/src/pages/sign/index.tsx index e33c03a04d..141e9c3386 100644 --- a/packages/extension/src/pages/sign/index.tsx +++ b/packages/extension/src/pages/sign/index.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; import { Button } from "reactstrap"; -import { HeaderLayout } from "../../layouts"; +import { HeaderLayout } from "@layouts/index"; import style from "./style.module.scss"; diff --git a/packages/extension/src/public/assets/group710.svg b/packages/extension/src/public/assets/group710.svg new file mode 100644 index 0000000000..07ff8c284d --- /dev/null +++ b/packages/extension/src/public/assets/group710.svg @@ -0,0 +1,4 @@ +<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="60" cy="60" r="60" fill="#D0DEF5"/> +<path d="M79.7917 91.1668H99.5833V83.2502C99.5833 76.6918 94.2667 71.3752 87.7083 71.3752C83.9257 71.3752 80.5562 73.1437 78.3815 75.8991M79.7917 91.1668H40.2083M79.7917 91.1668V83.2502C79.7917 80.6525 79.2912 78.1718 78.3815 75.8991M40.2083 91.1668H20.4167V83.2502C20.4167 76.6918 25.7333 71.3752 32.2917 71.3752C36.0743 71.3752 39.4438 73.1437 41.6185 75.8991M40.2083 91.1668V83.2502C40.2083 80.6525 40.7088 78.1718 41.6185 75.8991M41.6185 75.8991C44.5368 68.6084 51.667 63.4585 60 63.4585C68.333 63.4585 75.4632 68.6084 78.3815 75.8991M71.875 39.7085C71.875 46.2669 66.5584 51.5835 60 51.5835C53.4416 51.5835 48.125 46.2669 48.125 39.7085C48.125 33.1501 53.4416 27.8335 60 27.8335C66.5584 27.8335 71.875 33.1501 71.875 39.7085ZM95.625 51.5835C95.625 55.9558 92.0806 59.5002 87.7083 59.5002C83.3361 59.5002 79.7917 55.9558 79.7917 51.5835C79.7917 47.2112 83.3361 43.6668 87.7083 43.6668C92.0806 43.6668 95.625 47.2112 95.625 51.5835ZM40.2083 51.5835C40.2083 55.9558 36.6639 59.5002 32.2917 59.5002C27.9194 59.5002 24.375 55.9558 24.375 51.5835C24.375 47.2112 27.9194 43.6668 32.2917 43.6668C36.6639 43.6668 40.2083 47.2112 40.2083 51.5835Z" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/packages/extension/src/public/assets/toggle.svg b/packages/extension/src/public/assets/toggle.svg new file mode 100644 index 0000000000..065237c14d --- /dev/null +++ b/packages/extension/src/public/assets/toggle.svg @@ -0,0 +1,4 @@ +<svg width="42" height="24" viewBox="0 0 42 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="1" width="40" height="22" rx="11" fill="white" stroke="#3B82F6" stroke-width="2"/> +<circle cx="12" cy="12" r="9" fill="#3B82F6"/> +</svg> diff --git a/packages/extension/src/chatStore/index.ts b/packages/extension/src/stores/chats/index.ts similarity index 78% rename from packages/extension/src/chatStore/index.ts rename to packages/extension/src/stores/chats/index.ts index 1bc4280275..0cc00c5f48 100644 --- a/packages/extension/src/chatStore/index.ts +++ b/packages/extension/src/stores/chats/index.ts @@ -3,6 +3,7 @@ import { persistReducer } from "redux-persist"; // import { composeWithDevTools } from 'redux-devtools-extension'; import localStorage from "redux-persist/lib/storage"; import { messageStore } from "./messages-slice"; +import { newGroupStore } from "./new-group-slice"; import { userStore } from "./user-slice"; const messagesConfig = { @@ -10,6 +11,11 @@ const messagesConfig = { storage: localStorage, }; +const newGroupConfig = { + key: "newGroup", + storage: localStorage, +}; + const userConfig = { key: "user", storage: localStorage, @@ -21,11 +27,13 @@ const customizedMiddleware = (getDefaultMiddleware: any) => }); const persistedMessages = persistReducer(messagesConfig, messageStore); const persistedUserDetails = persistReducer(userConfig, userStore); +const persistedNewGroupDetails = persistReducer(newGroupConfig, newGroupStore); export const store = configureStore({ reducer: { messages: persistedMessages, user: persistedUserDetails, + newGroup: persistedNewGroupDetails, }, middleware: customizedMiddleware, }); diff --git a/packages/extension/src/chatStore/messages-slice.ts b/packages/extension/src/stores/chats/messages-slice.ts similarity index 72% rename from packages/extension/src/chatStore/messages-slice.ts rename to packages/extension/src/stores/chats/messages-slice.ts index 6463eb84d6..37b23e52eb 100644 --- a/packages/extension/src/chatStore/messages-slice.ts +++ b/packages/extension/src/stores/chats/messages-slice.ts @@ -1,63 +1,17 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { Message } from "../graphQL/messages-queries"; -import { CHAT_PAGE_COUNT, GROUP_PAGE_COUNT } from "../config.ui.var"; - -export interface MessageMap { - [key: string]: Message; -} - -interface ContactState { - contactAddress: string; - messages: MessageMap; - pubKey?: string; - pagination: Pagination; -} - -export interface Pagination { - page: number; - pageCount: number; - total: number; - lastPage: number; -} - -//key is group ID -export interface MessagesState { - [key: string]: ContactState; -} - -interface BlockedAddressState { - [key: string]: boolean; -} - -export interface GroupAddress { - address: string; - pubKey: string; - lastSeenTimestamp: string; - groupLastSeenTimestamp: string; - encryptedSymmetricKey: string; - isAdmin: boolean; -} - -export interface Group { - id: string; // groupID - name: string; // contactAddress - isDm: boolean; - addresses: GroupAddress[]; - lastMessageContents: string; - lastMessageSender: string; - lastMessageTimestamp: string; - lastSeenTimestamp: string; - createdAt: string; -} - -export interface Groups { - [contactAddress: string]: Group; -} +import { + BlockedAddressState, + Chats, + Groups, + Message, + Pagination, +} from "@chatTypes"; +import { CHAT_PAGE_COUNT, GROUP_PAGE_COUNT } from "../../config.ui.var"; interface State { groups: Groups; groupsPagination: Pagination; - chats: MessagesState; + chats: Chats; blockedAddress: BlockedAddressState; errorMessage?: { type: string; message: string; level: number }; isChatGroupPopulated: boolean; @@ -94,7 +48,7 @@ export const messagesSlice = createSlice({ contactAddress: userAddress, messages: {}, pagination: { - page: 0, + page: -1, pageCount: CHAT_PAGE_COUNT, lastPage: 0, total: CHAT_PAGE_COUNT, @@ -106,10 +60,12 @@ export const messagesSlice = createSlice({ }, resetChatList: (_state, _action) => initialState, updateMessages: (state: any, action: PayloadAction<Message>) => { - const { sender, id } = action.payload; - if (!state.chats[sender]) { - state.chats[sender] = { - contactAddress: sender, + const { sender, id, groupId } = action.payload; + /// Distinguish between Group and Single chat + const tempId = groupId.split("-").length == 2 ? sender : groupId; + if (!state.chats[tempId]) { + state.chats[tempId] = { + contactAddress: tempId, messages: {}, pagination: { page: -1, @@ -119,21 +75,33 @@ export const messagesSlice = createSlice({ }, }; } - state.chats[sender].messages[id] = action.payload; + state.chats[tempId].messages[id] = action.payload; }, updateGroupsData: (state: any, action: PayloadAction<any>) => { const group = action.payload; - const key = group?.userAddress; + let key; + if (group.isDm) { + key = group?.userAddress; + } else { + key = group.id; + } + const updatedGroup = { [key]: group, }; state.groups = { ...state.groups, ...updatedGroup }; }, + removeGroup: (state: any, action: PayloadAction<any>) => { + const groupId = action.payload; + delete state.groups[groupId]; + }, updateLatestSentMessage: (state: any, action: PayloadAction<Message>) => { - const { target, id } = action.payload; - if (!state.chats[target]) { - state.chats[target] = { - contactAddress: target, + const { target, id, groupId } = action.payload; + /// Distinguish between Group and Single chat + const tempId = groupId.split("-").length == 2 ? target : groupId; + if (!state.chats[tempId]) { + state.chats[tempId] = { + contactAddress: tempId, messages: {}, pagination: { page: 0, @@ -143,7 +111,7 @@ export const messagesSlice = createSlice({ }, }; } - state.chats[target].messages[id] = action.payload; + state.chats[tempId].messages[id] = action.payload; }, setBlockedList: (state, action) => { const blockedList = action.payload; @@ -180,6 +148,7 @@ export const { updateChatList, updateMessages, updateGroupsData, + removeGroup, updateLatestSentMessage, setBlockedList, setBlockedUser, diff --git a/packages/extension/src/stores/chats/new-group-slice.ts b/packages/extension/src/stores/chats/new-group-slice.ts new file mode 100644 index 0000000000..805272e02f --- /dev/null +++ b/packages/extension/src/stores/chats/new-group-slice.ts @@ -0,0 +1,39 @@ +import { GroupDetails, GroupMembers, NewGroupDetails } from "@chatTypes"; +import { createSlice } from "@reduxjs/toolkit"; + +const initialState: NewGroupDetails = { + isEditGroup: false, + group: { + contents: "", + description: "", + groupId: "", + members: [] as GroupMembers[], + name: "", + onlyAdminMessages: false, + } as GroupDetails, +}; + +export const newGroupSlice = createSlice({ + name: "newGroup", + initialState: initialState, + reducers: { + resetNewGroup: () => initialState, + setNewGroupInfo: (state, action) => { + state.group = { ...state.group, ...action.payload }; + }, + setIsGroupEdit: (state, action) => { + state.isEditGroup = action.payload; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { + resetNewGroup, + setNewGroupInfo, + setIsGroupEdit, +} = newGroupSlice.actions; + +export const newGroupDetails = (state: { newGroup: any }) => state.newGroup; + +export const newGroupStore = newGroupSlice.reducer; diff --git a/packages/extension/src/chatStore/user-slice.ts b/packages/extension/src/stores/chats/user-slice.ts similarity index 100% rename from packages/extension/src/chatStore/user-slice.ts rename to packages/extension/src/stores/chats/user-slice.ts diff --git a/packages/extension/src/utils/decrypt-group.ts b/packages/extension/src/utils/decrypt-group.ts index bed3d1310f..1dea026e68 100644 --- a/packages/extension/src/utils/decrypt-group.ts +++ b/packages/extension/src/utils/decrypt-group.ts @@ -1,5 +1,11 @@ +import { GroupMessagePayload } from "@chatTypes"; import { decryptMessageContent } from "./decrypt-message"; +import { GroupMessageType } from "./encrypt-group"; +import { + decryptEncryptedSymmetricKey, + decryptGroupData, +} from "./symmetric-key"; /** * Attempt to decrypt the payload of a group timestamp envelope for the currently * selected wallet address @@ -33,3 +39,35 @@ export const decryptGroupTimestamp = async ( return content; } }; + +export const decryptGroupMessage = async ( + content: string, + chainId: string, + encryptedSymmetricKey: string +): Promise<GroupMessagePayload> => { + try { + const data = Buffer.from(content, "base64").toString("ascii"); + const dataEnvelopeDecoded = JSON.parse(data); + const decodedData = JSON.parse( + Buffer.from(dataEnvelopeDecoded.data, "base64").toString("ascii") + ); + const symmetricKey = await decryptEncryptedSymmetricKey( + chainId, + encryptedSymmetricKey + ); + const decryptedContent = decryptGroupData(symmetricKey, decodedData); + const parsedData = JSON.parse( + Buffer.from(decryptedContent, "base64").toString("ascii") + ); + return { + message: parsedData.content.text, + type: parsedData.content.type, + }; + } catch (e) { + console.log("error", e.message); + return { + message: content, + type: GroupMessageType[GroupMessageType.message], + }; + } +}; diff --git a/packages/extension/src/utils/encrypt-group.ts b/packages/extension/src/utils/encrypt-group.ts index 19cdffa9c4..856f9857ba 100644 --- a/packages/extension/src/utils/encrypt-group.ts +++ b/packages/extension/src/utils/encrypt-group.ts @@ -1,12 +1,16 @@ import { toBase64, toUtf8 } from "@cosmjs/encoding"; -import { InExtensionMessageRequester } from "@keplr-wallet/router-extension"; -import { BACKGROUND_PORT } from "@keplr-wallet/router"; import { EncryptMessagingMessage, GetMessagingPublicKey, SignMessagingPayload, } from "@keplr-wallet/background/build/messaging"; import { MESSAGE_CHANNEL_ID } from "@keplr-wallet/background/build/messaging/constants"; +import { BACKGROUND_PORT } from "@keplr-wallet/router"; +import { InExtensionMessageRequester } from "@keplr-wallet/router-extension"; +import { + decryptEncryptedSymmetricKey, + encryptGroupData, +} from "./symmetric-key"; export interface GroupTimestampUpdateEnvelope { data: string; // base64 encoded @@ -21,6 +25,19 @@ export interface GroupTimestampUpdatePrimitive { content: Date; } +export enum GroupMessageType { + message, + event, +} + +export interface GroupMessageEnvelope { + data: string; // base64 encoded + senderPublicKey: string; // base64 encoded + targetGroupId: string; // base64 encoded + signature: string; // base64 encoded signature + channelId: string; +} + export const encryptGroupTimestamp = async ( accessToken: string, chainId: string, @@ -121,3 +138,78 @@ export async function encryptGroupTimestampToEnvelope( channelId: MESSAGE_CHANNEL_ID, }; } + +export const encryptGroupMessage = async ( + chainId: string, + messageStr: string, + messageType: GroupMessageType, + encryptedSymmetricKey: string, + senderAddress: string, + targetGroupId: string, + accessToken: string +): Promise<string> => { + const dataEnvelope = await encryptGroupMessageToEnvelope( + chainId, + messageStr, + messageType, + encryptedSymmetricKey, + senderAddress, + targetGroupId, + accessToken + ); + return toBase64(Buffer.from(JSON.stringify(dataEnvelope))); +}; + +export async function encryptGroupMessageToEnvelope( + chainId: string, + messageStr: string, + messageType: GroupMessageType, + encryptedSymmetricKey: string, + senderAddress: string, + targetGroupId: string, + accessToken: string +): Promise<GroupMessageEnvelope> { + // TODO: ideally this is cached + const requester = new InExtensionMessageRequester(); + + // lookup both our (sender) and target public keys + const senderPublicKey = await requester.sendMessage( + BACKGROUND_PORT, + new GetMessagingPublicKey(chainId, accessToken, senderAddress) + ); + + if (!senderPublicKey.publicKey) { + throw new Error("Sender Public key not available"); + } + + const symmetricKey = await decryptEncryptedSymmetricKey( + chainId, + encryptedSymmetricKey + ); + const message = { + senderPublicKey, + targetGroupId, + content: { + text: messageStr, + type: GroupMessageType[messageType], + }, + }; + const encodedData = toBase64(Buffer.from(JSON.stringify(message))); + + const encryptedContent = encryptGroupData(symmetricKey, encodedData); + const encodedContent = toBase64( + Buffer.from(JSON.stringify(encryptedContent)) + ); + // get the signature for the payload + const signature = await requester.sendMessage( + BACKGROUND_PORT, + new SignMessagingPayload(chainId, encodedContent) + ); + return { + data: encodedContent, + senderPublicKey: senderPublicKey.publicKey, + targetGroupId, + signature, + channelId: MESSAGE_CHANNEL_ID, + }; +} diff --git a/packages/extension/src/utils/format.ts b/packages/extension/src/utils/format.ts index 4d0346d394..7375f5327e 100644 --- a/packages/extension/src/utils/format.ts +++ b/packages/extension/src/utils/format.ts @@ -7,3 +7,13 @@ export const formatAddress = (address: string) => { ); else return address; }; + +export const formatGroupName = (address: string) => { + if (address?.length > 15) + return ( + address.substring(0, 8) + + "..." + + address.substring(address.length - 6, address.length) + ); + else return address; +}; diff --git a/packages/extension/src/utils/group-events.ts b/packages/extension/src/utils/group-events.ts new file mode 100644 index 0000000000..4bc64c120c --- /dev/null +++ b/packages/extension/src/utils/group-events.ts @@ -0,0 +1,75 @@ +export interface GroupEvent { + action: string; + createdBy?: string; + performedOn?: string; + message: string; + createdAt?: Date; +} + +export const createEvent = (eventContent: GroupEvent) => { + return JSON.stringify(eventContent); +}; + +export const leaveGroupEvent = (memberAddress: string) => { + return createEvent({ + action: "LEAVE", + createdBy: memberAddress, + message: "[createdBy] left the group chat", + createdAt: new Date(), + }); +}; + +export const addMemberEvent = (adminAddress: string, memberAddress: string) => { + return createEvent({ + action: "ADD", + createdBy: adminAddress, + performedOn: memberAddress, + message: "[createdBy] added [performedOn]", + createdAt: new Date(), + }); +}; + +export const updateInfoEvent = (adminAddress: string) => { + return createEvent({ + action: "UPDATE", + createdBy: adminAddress, + message: "Group info updated by [createdBy]", + createdAt: new Date(), + }); +}; + +export const createGroupEvent = (adminAddress: string) => { + return createEvent({ + action: "CREATE", + createdBy: adminAddress, + message: "Group created by [createdBy]", + createdAt: new Date(), + }); +}; + +export const removeMemberEvent = (memberAddress: string) => { + return createEvent({ + action: "REMOVE", + performedOn: memberAddress, + message: "[performedOn] has been removed.", + createdAt: new Date(), + }); +}; + +export const addAdminEvent = (memberAddress: string) => { + return createEvent({ + action: "ADDADMIN", + performedOn: memberAddress, + message: "[performedOn] is now an admin.", + createdAt: new Date(), + }); +}; + +export const removedAdminEvent = (memberAddress: string) => { + return createEvent({ + action: "REMOVEADMIN", + performedOn: memberAddress, + message: "[performedOn] removed as admin.", + createdAt: new Date(), + }); +}; diff --git a/packages/extension/src/utils/index.ts b/packages/extension/src/utils/index.ts index 2c36a9a90c..cce35bd98d 100644 --- a/packages/extension/src/utils/index.ts +++ b/packages/extension/src/utils/index.ts @@ -1,5 +1,8 @@ +import { NameAddress } from "@chatTypes"; import { toHex } from "@cosmjs/encoding"; import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { formatAddress } from "./format"; +import { GroupEvent } from "./group-events"; export const getWalletKeys = async (mnemonic: string) => { const wallet: any = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic); @@ -8,3 +11,57 @@ export const getWalletKeys = async (mnemonic: string) => { publicKey: toHex(wallet?.pubkey), }; }; + +export function removeByIndex(str: string, index: number) { + return str.slice(0, index) + str.slice(index + 1); +} + +// translate the contact address into the address book name if it exists +export function getUserName( + walletAddress: string, + addressBook: NameAddress, + address: string +): string { + if (walletAddress === address) { + return "You"; + } + + const contactAddressBookName = addressBook[address]; + return contactAddressBookName + ? formatAddress(contactAddressBookName) + : formatAddress(address); +} + +export function getEventMessage( + walletAddress: string, + addressBook: NameAddress, + message: string +): string { + let data: GroupEvent = { action: "NA", message: "Event cant be translated" }; + try { + data = JSON.parse(message); + } catch (e) { + console.log("Older group evnet cant be translated"); + } + + let eventMessage = data.message; + if (data.createdBy) { + eventMessage = eventMessage.replace( + "[createdBy]", + getUserName(walletAddress, addressBook, data.createdBy) + ); + } + if (data.performedOn) { + let address = data.performedOn; + if (address.includes(",")) { + const addresses = address.split(","); + const updatedAddresses = addresses.map((address) => + getUserName(walletAddress, addressBook, address) + ); + address = updatedAddresses.join(","); + } else address = getUserName(walletAddress, addressBook, address); + eventMessage = eventMessage.replace("[performedOn]", address); + } + + return eventMessage; +} diff --git a/packages/extension/src/utils/symmetric-key.ts b/packages/extension/src/utils/symmetric-key.ts new file mode 100644 index 0000000000..cab3ad5225 --- /dev/null +++ b/packages/extension/src/utils/symmetric-key.ts @@ -0,0 +1,91 @@ +import { toBase64, toUtf8 } from "@cosmjs/encoding"; +import { EncryptMessagingMessage } from "@keplr-wallet/background/build/messaging"; +import { BACKGROUND_PORT } from "@keplr-wallet/router"; +import { InExtensionMessageRequester } from "@keplr-wallet/router-extension"; +import crypto from "crypto"; +import { GroupMembers } from "@chatTypes"; +import { decryptMessageContent } from "./decrypt-message"; + +function generateSymmetricKey() { + const secret = "fetchwallet"; + const key = crypto + .createHash("sha256") + .update(String(secret)) + .digest("base64"); + return key; +} + +export async function decryptEncryptedSymmetricKey( + chainId: string, + encryptedSymmetricKey: string +) { + const symmetricKey = await decryptMessageContent( + chainId, + encryptedSymmetricKey + ); + return symmetricKey.substring(1, symmetricKey.length - 1); +} + +export async function encryptSymmetricKey( + chainId: string, + accessToken: string, + symmetricKey: string, + address: string +) { + const requester = new InExtensionMessageRequester(); + const encryptMsg = new EncryptMessagingMessage( + chainId, + address, + toBase64(toUtf8(JSON.stringify(symmetricKey))), + accessToken + ); + const encryptSymmetricKey = await requester.sendMessage( + BACKGROUND_PORT, + encryptMsg + ); + return encryptSymmetricKey; +} + +export const createEncryptedSymmetricKeyForAddresses = async ( + addresses: GroupMembers[], + chainId: string, + accessToken: string +) => { + const newAddresses = []; + const newSymmetricKey = generateSymmetricKey(); + for (let i = 0; i < addresses.length; i++) { + const groupAddress = addresses[i]; + const encryptedSymmetricKey = await encryptSymmetricKey( + chainId, + accessToken, + newSymmetricKey, + groupAddress.address + ); + newAddresses[i] = { ...groupAddress, encryptedSymmetricKey }; + } + return newAddresses; +}; + +export function encryptGroupData(key: string, data: string) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + "aes-256-cbc", + Buffer.from(key, "base64"), + iv + ); + let encrypted = cipher.update(data, "utf8", "base64"); + encrypted += cipher.final("base64"); + return `${iv.toString("base64")}:${encrypted}`; +} + +export function decryptGroupData(key: string, data: string) { + const [iv, encrypted] = data.split(":"); + const decipher = crypto.createDecipheriv( + "aes-256-cbc", + Buffer.from(key, "base64"), + Buffer.from(iv, "base64") + ); + let decrypted = decipher.update(encrypted, "base64", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +} diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 6b74e79e02..0406b3ddd7 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -5,6 +5,17 @@ "target": "ES2016" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, "module": "ESNEXT" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, + "baseUrl": "./", + "paths": { + "@components/*": ["src/components/*"], + "@layouts/*": ["src/layouts/*"], + "@graphQL/*": ["src/graphQL/*"], + "@chatTypes": ["src/@types/chat"], + "@hooks/*": ["src/hooks/*"], + "@chatStore/*": ["src/stores/chats/*"], + "@assets/*": ["src/public/assets/*"], + "@utils/*": ["src/utils/*"] + }, "typeRoots": [ "./node_modules/@types", "../../node_modules/@types", diff --git a/packages/extension/webpack.config.js b/packages/extension/webpack.config.js index 9eaae2ea4e..2a37f2445a 100644 --- a/packages/extension/webpack.config.js +++ b/packages/extension/webpack.config.js @@ -11,10 +11,17 @@ const BundleAnalyzerPlugin = require("webpack-bundle-analyzer") const isEnvDevelopment = process.env.NODE_ENV !== "production"; const isEnvAnalyzer = process.env.ANALYZER === "true"; -const commonResolve = (dir) => ({ - extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss"], +const commonResolve = () => ({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".svg"], alias: { - assets: path.resolve(__dirname, dir), + "@components": path.resolve(__dirname, "src/components"), + "@layouts": path.resolve(__dirname, "src/layouts"), + "@chatStore": path.resolve(__dirname, "src/stores/chats"), + "@graphQL": path.resolve(__dirname, "src/graphQL"), + "@chatTypes": path.resolve(__dirname, "src/@types/chat"), + "@hooks": path.resolve(__dirname, "src/hooks"), + "@assets": path.resolve(__dirname, "src/public/assets"), + "@utils": path.resolve(__dirname, "src/utils"), }, }); const sassRule = { @@ -89,7 +96,7 @@ const extensionConfig = () => { path: path.resolve(__dirname, isEnvDevelopment ? "dist" : "prod"), filename: "[name].bundle.js", }, - resolve: commonResolve("src/public/assets"), + resolve: commonResolve(), module: { rules: [sassRule, tsRule, fileRule], },