diff --git a/.github/workflows/pull_request_checks.yml b/.github/workflows/pull_request_checks.yml index 7fe68372..75fdacc5 100644 --- a/.github/workflows/pull_request_checks.yml +++ b/.github/workflows/pull_request_checks.yml @@ -17,13 +17,41 @@ jobs: - name: "šŸ”§ Setup" uses: ./.github/actions/use-dependencies + ## + # validation + ## + + validate_pr_title: + name: "Validate PR Title" + needs: install + runs-on: ubuntu-latest + steps: + - name: "šŸ›Ž Checkout" + uses: actions/checkout@v4 + - name: "šŸ”§ Setup" + uses: ./.github/actions/use-dependencies + - name: "šŸ“„ Get PR Title" + id: get_pr_title + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + return data.title; + - name: "āœ… Validate" + run: echo "${{ steps.get_pr_title.outputs.result }}" | yarn commitlint + ## # lint, type-check, build and test ## lint: name: "Lint" - needs: install + needs: [install, validate_pr_title] runs-on: ubuntu-latest steps: - name: "šŸ›Ž Checkout" @@ -35,7 +63,7 @@ jobs: type_check: name: "Type Check" - needs: install + needs: [install, validate_pr_title] runs-on: ubuntu-latest steps: - name: "šŸ›Ž Checkout" @@ -43,7 +71,7 @@ jobs: - name: "šŸ”§ Setup" uses: ./.github/actions/use-dependencies - name: "šŸ” Type Check" - run: yarn types:check + run: yarn check:types test: name: "Test" @@ -63,7 +91,7 @@ jobs: build_chrome: name: "Build Chrome" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: @@ -80,7 +108,7 @@ jobs: build_edge: name: "Build Edge" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: @@ -97,7 +125,7 @@ jobs: build_firefox: name: "Build Firefox" - needs: [install, type_check] + needs: [install, validate_pr_title, type_check] runs-on: ubuntu-latest environment: development steps: diff --git a/package.json b/package.json index e3da44dc..49e07d83 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "author": { "name": "Kieran O'Neill", - "email": "kieran@agoralabs.sh", + "email": "kieran@kibis.is", "url": "https://github.com/kieranroneill" }, "license": "AGPL-3.0-or-later", @@ -36,6 +36,7 @@ "build:dapp-example": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name dapp-example --env environment=production", "build:edge": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name extension-scripts --config-name extension-apps --env environment=production --env target=edge", "build:firefox": "cross-env TS_NODE_PROJECT=\"webpack/tsconfig.webpack.json\" webpack --config webpack/webpack.config.ts --config-name extension-scripts --config-name extension-apps --env environment=production --env target=firefox", + "check:types": "tsc --noEmit", "install:chrome": "./scripts/install_chrome.sh", "install:firefox": "./scripts/install_firefox.sh", "lint": "eslint . --ext .ts --ext .tsx --ext .js", @@ -51,7 +52,6 @@ "start:firefox": "concurrently --names \"DAPP,EXTENSION\" -c \"blue.bold,magenta.bold\" \"yarn start:dapp-example\" \"yarn start:extension --env target=firefox\"", "test": "jest", "test:coverage": "jest --coverage", - "types:check": "tsc --noEmit", "validate:firefox": "addons-linter .firefox_build/" }, "devDependencies": { @@ -135,6 +135,7 @@ "@reduxjs/toolkit": "^1.9.3", "@stablelib/base64": "^1.0.1", "@stablelib/hex": "^1.0.1", + "@stablelib/random": "^1.0.2", "@stablelib/utf8": "^1.0.1", "algosdk": "^2.7.0", "bignumber.js": "^9.1.1", @@ -174,5 +175,6 @@ "webpack-dev-middleware": "^5.3.4", "word-wrap": "^1.2.4", "yaml": "^2.2.2" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/extension/apps/main/App.tsx b/src/extension/apps/main/App.tsx index 4ad7d66c..1562f727 100644 --- a/src/extension/apps/main/App.tsx +++ b/src/extension/apps/main/App.tsx @@ -15,7 +15,9 @@ import { reducer as arc200AssetsReducer } from '@extension/features/arc0200-asse import { reducer as credentialLockReducer } from '@extension/features/credential-lock'; import { reducer as eventsReducer } from '@extension/features/events'; import { reducer as layoutReducer } from '@extension/features/layout'; +import { reducer as manageGroupsModalReducer } from '@extension/features/manage-groups-modal'; import { reducer as messagesReducer } from '@extension/features/messages'; +import { reducer as moveGroupModalReducer } from '@extension/features/move-group-modal'; import { reducer as networksReducer } from '@extension/features/networks'; import { reducer as notificationsReducer } from '@extension/features/notifications'; import { reducer as passkeysReducer } from '@extension/features/passkeys'; @@ -51,7 +53,9 @@ const App: FC = ({ credentialLock: credentialLockReducer, events: eventsReducer, layout: layoutReducer, + manageGroupsModal: manageGroupsModalReducer, messages: messagesReducer, + moveGroupModal: moveGroupModalReducer, networks: networksReducer, notifications: notificationsReducer, passkeys: passkeysReducer, diff --git a/src/extension/apps/main/Root.tsx b/src/extension/apps/main/Root.tsx index 22a9159b..daa57d0d 100644 --- a/src/extension/apps/main/Root.tsx +++ b/src/extension/apps/main/Root.tsx @@ -12,10 +12,12 @@ import { startPollingForAccountsThunk } from '@extension/features/accounts'; import { fetchARC0072AssetsFromStorageThunk } from '@extension/features/arc0072-assets'; import { fetchARC0200AssetsFromStorageThunk } from '@extension/features/arc0200-assets'; import { - setConfirmModal, + openConfirmModal, setScanQRCodeModal, setWhatsNewModal, } from '@extension/features/layout'; +import { closeModal as closeManageGroupsModal } from '@extension/features/manage-groups-modal'; +import { closeModal as closeMoveGroupModal } from '@extension/features/move-group-modal'; import { startPollingForTransactionsParamsThunk } from '@extension/features/networks'; import { setShowingConfetti } from '@extension/features/notifications'; import { reset as resetReKeyAccount } from '@extension/features/re-key-account'; @@ -41,6 +43,8 @@ import ARC0300KeyRegistrationTransactionSendEventModal from '@extension/modals/A import ConfirmModal from '@extension/modals/ConfirmModal'; import CredentialLockModal from '@extension/modals/CredentialLockModal'; import EnableModal from '@extension/modals/EnableModal'; +import ManageGroupsModal from '@extension/modals/ManageGroupsModal'; +import MoveGroupModal from '@extension/modals/MoveGroupModal'; import ReKeyAccountModal from '@extension/modals/ReKeyAccountModal'; import RemoveAssetsModal from '@extension/modals/RemoveAssetsModal'; import ScanQRCodeModal from '@extension/modals/ScanQRCodeModal'; @@ -69,8 +73,10 @@ const Root: FC = ({ i18n }) => { const whatsNewInfo = useSelectSystemWhatsNewInfo(); // handlers const handleAddAssetsModalClose = () => dispatch(resetAddAsset()); - const handleConfirmClose = () => dispatch(setConfirmModal(null)); + const handleConfirmClose = () => dispatch(openConfirmModal(null)); const handleConfettiComplete = () => dispatch(setShowingConfetti(false)); + const handleManageGroupsModalClose = () => dispatch(closeManageGroupsModal()); + const handleMoveGroupModalClose = () => dispatch(closeMoveGroupModal()); const handleReKeyAccountModalClose = () => dispatch(resetReKeyAccount()); const handleRemoveAssetsModalClose = () => dispatch(resetRemoveAssets()); const handleScanQRCodeModalClose = () => dispatch(setScanQRCodeModal(null)); @@ -126,6 +132,8 @@ const Root: FC = ({ i18n }) => { {/*action modals*/} + + diff --git a/src/extension/components/AccountAvatar/AccountAvatar.tsx b/src/extension/components/AccountAvatar/AccountAvatar.tsx index 865961b3..734841cc 100644 --- a/src/extension/components/AccountAvatar/AccountAvatar.tsx +++ b/src/extension/components/AccountAvatar/AccountAvatar.tsx @@ -1,4 +1,4 @@ -import { Avatar, Icon } from '@chakra-ui/react'; +import { Avatar } from '@chakra-ui/react'; import React, { type FC } from 'react'; // hooks diff --git a/src/extension/components/ActionItem/ActionItem.tsx b/src/extension/components/ActionItem/ActionItem.tsx new file mode 100644 index 00000000..d3ec73dd --- /dev/null +++ b/src/extension/components/ActionItem/ActionItem.tsx @@ -0,0 +1,78 @@ +import { Button as ChakraButton, HStack, Icon, Text } from '@chakra-ui/react'; +import React, { type FC } from 'react'; +import { IoChevronForward } from 'react-icons/io5'; + +// constants +import { DEFAULT_GAP, TAB_ITEM_HEIGHT } from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useColorModeValue from '@extension/hooks/useColorModeValue'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { IProps } from './types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const ActionItem: FC = ({ + icon, + isSelected = false, + label, + onClick, +}) => { + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const primaryButtonTextColor: string = useColorModeValue( + theme.colors.primaryLight['600'], + theme.colors.primaryDark['600'] + ); + const subTextColor = useSubTextColor(); + // misc + const iconSize = calculateIconSize('md'); + const textColor = isSelected ? primaryButtonTextColor : subTextColor; + + return ( + + } + variant="ghost" + w="full" + > + + {/*icon*/} + + + {/*content*/} + + {label} + + + + ); +}; + +export default ActionItem; diff --git a/src/extension/components/ActionItem/index.ts b/src/extension/components/ActionItem/index.ts new file mode 100644 index 00000000..b5a2dc78 --- /dev/null +++ b/src/extension/components/ActionItem/index.ts @@ -0,0 +1 @@ +export { default } from './ActionItem'; diff --git a/src/extension/components/ActionItem/types/IProps.ts b/src/extension/components/ActionItem/types/IProps.ts new file mode 100644 index 00000000..20d57ff5 --- /dev/null +++ b/src/extension/components/ActionItem/types/IProps.ts @@ -0,0 +1,10 @@ +import type { IconType } from 'react-icons'; + +interface IProps { + icon: IconType; + isSelected?: boolean; + label: string; + onClick?: () => void; +} + +export default IProps; diff --git a/src/extension/components/ActionItem/types/index.ts b/src/extension/components/ActionItem/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/ActionItem/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/EditableText/EditableText.tsx b/src/extension/components/EditableText/EditableText.tsx new file mode 100644 index 00000000..3384ed09 --- /dev/null +++ b/src/extension/components/EditableText/EditableText.tsx @@ -0,0 +1,226 @@ +import { + Box, + HStack, + Input, + Skeleton, + Text, + type TextProps, + VStack, +} from '@chakra-ui/react'; +import { faker } from '@faker-js/faker'; +import React, { + ChangeEvent, + FC, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoCheckmarkOutline, IoCloseOutline } from 'react-icons/io5'; + +// components +import IconButton from '@extension/components/IconButton'; + +// constants +import { BODY_BACKGROUND_COLOR, DEFAULT_GAP } from '@extension/constants'; + +// hooks +import useButtonHoverBackgroundColor from '@extension/hooks/useButtonHoverBackgroundColor'; +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import usePrimaryColor from '@extension/hooks/usePrimaryColor'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; +import useTextBackgroundColor from '@extension/hooks/useTextBackgroundColor'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { IProps } from './types'; + +const EditableText: FC> = ({ + characterLimit, + isEditing = false, + isLoading = false, + onCancel, + onSubmit, + placeholder, + value, + ...textProps +}) => { + const { t } = useTranslation(); + const inputRef = useRef(null); + // hooks + const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); + const defaultTextColor = useDefaultTextColor(); + const primaryColor = usePrimaryColor(); + const subTextColor = useSubTextColor(); + const textBackgroundColor = useTextBackgroundColor(); + // memos + const loadingText = useMemo( + () => faker.random.alphaNumeric(12).toUpperCase(), + [] + ); + const charactersRemaining = useMemo( + () => + characterLimit + ? characterLimit - new TextEncoder().encode(value).byteLength + : null, + [value] + ); + // state + const [_charactersRemaining, setCharactersRemaining] = useState< + number | null + >(charactersRemaining); + const [_value, setValue] = useState(value); + // handlers + const handleCancelClick = () => handleClose(); + const handleClose = () => { + setCharactersRemaining(charactersRemaining); + setValue(value); + onCancel(); + }; + const handleOnChange = (event: ChangeEvent) => { + let byteLength: number; + + // update the characters remaining + if (characterLimit) { + byteLength = new TextEncoder().encode(event.target.value).byteLength; + + setCharactersRemaining(characterLimit - byteLength); + } + + setValue(event.target.value); + }; + const handleOnKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + handleSubmitClick(); + } + }; + const handleSubmitClick = () => { + if ( + (typeof _charactersRemaining === 'number' && _charactersRemaining < 0) || + _value.length <= 0 + ) { + return; + } + + onSubmit(_value); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + useEffect(() => setValue(value), [value]); + + if (isLoading) { + return ( + + + {loadingText} + + + ); + } + + if (!isEditing) { + return {value}; + } + + return ( + + {/*/input*/} + + + {/*controls*/} + + {/*characters remaining*/} + {typeof _charactersRemaining === 'number' && ( + + = 0 ? subTextColor : 'red.300'} + fontSize="xs" + textAlign="center" + w="full" + > + {t('captions.charactersRemaining', { + amount: _charactersRemaining, + })} + + + )} + + + {/*submit*/} + + ('ariaLabels.checkIcon')} + bg={textBackgroundColor} + icon={IoCheckmarkOutline} + onClick={handleSubmitClick} + size="sm" + type="submit" + variant="ghost" + /> + + + {/*cancel*/} + + ('ariaLabels.crossIcon')} + bg={textBackgroundColor} + icon={IoCloseOutline} + onClick={handleCancelClick} + size="sm" + variant="ghost" + /> + + + + + ); +}; + +export default EditableText; diff --git a/src/extension/components/EditableText/index.ts b/src/extension/components/EditableText/index.ts new file mode 100644 index 00000000..62ce3fe1 --- /dev/null +++ b/src/extension/components/EditableText/index.ts @@ -0,0 +1 @@ +export { default } from './EditableText'; diff --git a/src/extension/components/EditableText/types/IProps.ts b/src/extension/components/EditableText/types/IProps.ts new file mode 100644 index 00000000..e8dc6495 --- /dev/null +++ b/src/extension/components/EditableText/types/IProps.ts @@ -0,0 +1,13 @@ +interface IProps { + characterLimit?: number; + color?: string; + fontSize?: string; + isEditing?: boolean; + isLoading?: boolean; + onCancel: () => void; + onSubmit: (value: string) => void; + placeholder?: string; + value: string; +} + +export default IProps; diff --git a/src/extension/components/EditableText/types/index.ts b/src/extension/components/EditableText/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/EditableText/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/GenericInput/GenericInput.tsx b/src/extension/components/GenericInput/GenericInput.tsx index 658edcbc..1a0cca26 100644 --- a/src/extension/components/GenericInput/GenericInput.tsx +++ b/src/extension/components/GenericInput/GenericInput.tsx @@ -1,17 +1,20 @@ import { + HStack, Input, InputGroup, InputRightElement, - Stack, Text, + Tooltip, VStack, } from '@chakra-ui/react'; import { encodeURLSafe as encodeBase64URLSafe } from '@stablelib/base64'; import React, { type FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { IoArrowForwardOutline } from 'react-icons/io5'; import { randomBytes } from 'tweetnacl'; // components +import IconButton from '@extension/components/IconButton'; import InformationIcon from '@extension/components/InformationIcon'; import Label from '@extension/components/Label'; @@ -30,9 +33,11 @@ const GenericInput: FC = ({ error, id, informationText, + isLoading = false, label, required = false, validate, + onSubmit, ...inputProps }) => { const { t } = useTranslation(); @@ -41,6 +46,45 @@ const GenericInput: FC = ({ const subTextColor = useSubTextColor(); // misc const _id = id || encodeBase64URLSafe(randomBytes(6)); + // handlers + const handleOnSubmit = () => onSubmit && onSubmit(); + // renders + const renderRightInputIcon = () => { + if (!informationText && !onSubmit) { + return null; + } + + return ( + + + {informationText && ( + ('ariaLabels.informationIcon')} + tooltipLabel={informationText} + /> + )} + {onSubmit && ( + ('buttons.submit')}> + ('ariaLabels.forwardArrow')} + borderRadius="full" + icon={IoArrowForwardOutline} + isLoading={isLoading} + onClick={handleOnSubmit} + size="sm" + variant="ghost" + /> + + )} + + + ); + }; return ( @@ -65,16 +109,7 @@ const GenericInput: FC = ({ w="full" /> - {informationText && ( - - - - - - )} + {renderRightInputIcon()} {/*character limit*/} diff --git a/src/extension/components/GenericInput/types/IProps.ts b/src/extension/components/GenericInput/types/IProps.ts index 86cdc6c9..ce232760 100644 --- a/src/extension/components/GenericInput/types/IProps.ts +++ b/src/extension/components/GenericInput/types/IProps.ts @@ -1,11 +1,13 @@ import type { InputProps } from '@chakra-ui/react'; -interface IProps extends InputProps { +interface IProps extends Omit { charactersRemaining?: number; error?: string | null; id?: string; informationText?: string; + isLoading?: boolean; label: string; + onSubmit?: () => void; required?: boolean; validate?: (value: string) => string | null; } diff --git a/src/extension/components/GroupBadge/GroupBadge.tsx b/src/extension/components/GroupBadge/GroupBadge.tsx new file mode 100644 index 00000000..989b7805 --- /dev/null +++ b/src/extension/components/GroupBadge/GroupBadge.tsx @@ -0,0 +1,17 @@ +import { Tag, TagLabel, TagLeftIcon, Tooltip } from '@chakra-ui/react'; +import React, { type FC } from 'react'; +import { IoFolderOutline } from 'react-icons/io5'; + +// types +import type { IProps } from './types'; + +const GroupBadge: FC = ({ group, size = 'sm' }) => ( + + + + {group.name} + + +); + +export default GroupBadge; diff --git a/src/extension/components/GroupBadge/index.ts b/src/extension/components/GroupBadge/index.ts new file mode 100644 index 00000000..9ee92123 --- /dev/null +++ b/src/extension/components/GroupBadge/index.ts @@ -0,0 +1 @@ +export { default } from './GroupBadge'; diff --git a/src/extension/components/GroupBadge/types/IProps.ts b/src/extension/components/GroupBadge/types/IProps.ts new file mode 100644 index 00000000..641acfec --- /dev/null +++ b/src/extension/components/GroupBadge/types/IProps.ts @@ -0,0 +1,11 @@ +import type { ResponsiveValue } from '@chakra-ui/react'; + +// types +import type { IAccountGroup } from '@extension/types'; + +interface IProps { + group: IAccountGroup; + size?: ResponsiveValue<'size'>; +} + +export default IProps; diff --git a/src/extension/components/GroupBadge/types/index.ts b/src/extension/components/GroupBadge/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/GroupBadge/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBar/SideBar.tsx b/src/extension/components/SideBar/SideBar.tsx index 2bbec1c5..1fb09b60 100644 --- a/src/extension/components/SideBar/SideBar.tsx +++ b/src/extension/components/SideBar/SideBar.tsx @@ -9,6 +9,7 @@ import { IoAddCircleOutline, IoChevronBack, IoChevronForward, + IoFolderOutline, IoScanOutline, IoSendOutline, IoSettingsOutline, @@ -24,6 +25,8 @@ import KibisisIcon from '@extension/components/KibisisIcon'; import ScrollableContainer from '@extension/components/ScrollableContainer'; import SideBarAccountList from '@extension/components/SideBarAccountList'; import SideBarActionItem from '@extension/components/SideBarActionItem'; +import SideBarGroupList from '@extension/components/SideBarGroupList'; +import SideBarSkeletonItem from '@extension/components/SideBarSkeletonItem'; // constants import { @@ -41,11 +44,18 @@ import { AccountTabEnum } from '@extension/enums'; // features import { + removeFromGroupThunk, + saveAccountGroupsThunk, saveAccountsThunk, saveActiveAccountDetails, updateAccountsThunk, } from '@extension/features/accounts'; -import { setScanQRCodeModal } from '@extension/features/layout'; +import { + openConfirmModal, + setScanQRCodeModal, +} from '@extension/features/layout'; +import { openModal as openManageGroupsModal } from '@extension/features/manage-groups-modal'; +import { openModal as openMoveGroupModal } from '@extension/features/move-group-modal'; import { initialize as initializeSendAssets } from '@extension/features/send-assets'; // hooks @@ -55,6 +65,7 @@ import usePrimaryColor from '@extension/hooks/usePrimaryColor'; // selectors import { + useSelectAccountGroups, useSelectAccounts, useSelectAccountsFetching, useSelectActiveAccount, @@ -66,6 +77,7 @@ import { // types import type { + IAccountGroup, IAccountWithExtendedProps, IAppThunkDispatch, IMainRootState, @@ -73,6 +85,8 @@ import type { // utils import calculateIconSize from '@extension/utils/calculateIconSize'; +import ellipseAddress from '@extension/utils/ellipseAddress'; +import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; const SideBar: FC = () => { const { t } = useTranslation(); @@ -84,6 +98,7 @@ const SideBar: FC = () => { const activeAccountDetails = useSelectActiveAccountDetails(); const availableAccounts = useSelectAvailableAccountsForSelectedNetwork(); const fetchingAccounts = useSelectAccountsFetching(); + const groups = useSelectAccountGroups(); const network = useSelectSettingsSelectedNetwork(); const systemInfo = useSelectSystemInfo(); // hooks @@ -127,15 +142,39 @@ const SideBar: FC = () => { onCloseSideBar(); }; - const handleOnAccountSort = (_accounts: IAccountWithExtendedProps[]) => + const handleOnAccountSort = (items: IAccountWithExtendedProps[]) => + dispatch(saveAccountsThunk(items)); + const handleOnAddToGroupClick = (accountID: string) => + dispatch(openMoveGroupModal(accountID)); + const handleOnGroupSort = (items: IAccountGroup[]) => + dispatch(saveAccountGroupsThunk(items)); + const handleOnManageGroupsClick = () => dispatch(openManageGroupsModal()); + const handleOnRemoveFromGroupClick = (accountID: string) => { + const account = accounts.find((value) => value.id === accountID) || null; + let group: IAccountGroup | null; + if (!account) { + return; + } + + group = groups.find((value) => value.id === account?.groupID) || null; + + if (!group) { + return; + } + dispatch( - saveAccountsThunk( - _accounts.map((value, index) => ({ - ...value, - index, - })) - ) + openConfirmModal({ + description: t('captions.removedFromGroupConfirm', { + account: + account.name || + ellipseAddress(convertPublicKeyToAVMAddress(account.publicKey)), + group: group.name, + }), + onConfirm: () => dispatch(removeFromGroupThunk(account.id)), + title: t('headings.removedFromGroupConfirm'), + }) ); + }; const handleScanQRCodeClick = () => dispatch( setScanQRCodeModal({ @@ -230,7 +269,7 @@ const SideBar: FC = () => { - {/*accounts*/} + {/*groups/accounts*/} { spacing={0} w="full" > - + {!network || fetchingAccounts ? ( + Array.from({ length: 3 }, (_, index) => ( + + )) + ) : ( + <> + {/*groups*/} + {groups.length > 0 && ( + <> + + + + )} + + {/*accounts*/} + + + )} @@ -281,6 +348,14 @@ const SideBar: FC = () => { onClick={handleAddAccountClick} /> + {/*manage groups*/} + ('labels.manageGroups')} + onClick={handleOnManageGroupsClick} + /> + {/*settings*/} = ({ +const SideBarAccountItem: FC = ({ account, accounts, active, isShortForm, network, + onAddToGroupClick, onClick, + onRemoveFromGroupClick, systemInfo, }) => { const { @@ -78,7 +81,13 @@ const Item: FC = ({ bg: BODY_BACKGROUND_COLOR, }; // handlers + const handleOnAddToGroupClick = () => + onAddToGroupClick && onAddToGroupClick(account.id); const handleOnClick = () => onClick(account.id); + const handleOnRemoveFromGroupClick = () => + account.groupID && + onRemoveFromGroupClick && + onRemoveFromGroupClick(account.id); return ( = ({ + {/*add/remove group button*/} + {account.groupID ? ( + + ) : ( + + )} + {/*re-order button*/} + + {/*re-order button*/} + + + + + {/*accounts*/} + + + + {_accounts.map((value) => ( + + ))} + + + + + ); +}; + +export default SideBarGroupItem; diff --git a/src/extension/components/SideBarGroupItem/index.ts b/src/extension/components/SideBarGroupItem/index.ts new file mode 100644 index 00000000..ea848351 --- /dev/null +++ b/src/extension/components/SideBarGroupItem/index.ts @@ -0,0 +1 @@ +export { default } from './SideBarGroupItem'; diff --git a/src/extension/components/SideBarGroupItem/types/IProps.ts b/src/extension/components/SideBarGroupItem/types/IProps.ts new file mode 100644 index 00000000..97f691e1 --- /dev/null +++ b/src/extension/components/SideBarGroupItem/types/IProps.ts @@ -0,0 +1,26 @@ +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + INetworkWithTransactionParams, + ISystemInfo, +} from '@extension/types'; + +/** + * @property {IAccountWithExtendedProps[]} accounts - All accounts. + * @property {IAccountGroup} group - The group. + * @property {boolean} isShortForm - Whether the full item is being shown or just the avatar. + */ +interface IProps { + accounts: IAccountWithExtendedProps[]; + activeAccountID: string | null; + group: IAccountGroup; + isShortForm: boolean; + network: INetworkWithTransactionParams; + onAccountClick: (id: string) => void; + onAccountSort: (items: IAccountWithExtendedProps[]) => void; + onRemoveAccountFromGroupClick: (accountID: string) => void; + systemInfo: ISystemInfo | null; +} + +export default IProps; diff --git a/src/extension/components/SideBarGroupItem/types/index.ts b/src/extension/components/SideBarGroupItem/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/SideBarGroupItem/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarGroupList/SideBarGroupList.tsx b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx new file mode 100644 index 00000000..3898d5c0 --- /dev/null +++ b/src/extension/components/SideBarGroupList/SideBarGroupList.tsx @@ -0,0 +1,108 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import React, { type FC, useEffect, useMemo, useState } from 'react'; + +// components +import SideBarGroupItem from '@extension/components/SideBarGroupItem'; + +// types +import type { IAccountGroup } from '@extension/types'; +import type { IProps } from './types'; + +// utils +import sortByIndex from '@extension/utils/sortByIndex'; + +const SideBarGroupList: FC = ({ + accounts, + activeAccountID, + groups, + isShortForm, + network, + onAccountClick, + onAccountSort, + onGroupSort, + onRemoveAccountFromGroupClick, + systemInfo, +}) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + // memos + const sortedGroups = useMemo(() => sortByIndex([...groups]), [groups]); + // states + const [_groups, setGroups] = useState(sortedGroups); // a local state fixes the delay between the ui and redux updates + // handlers + const handleOnAccountClick = async (id: string) => onAccountClick(id); + const handleOnGroupDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + let previousIndex: number; + let nextIndex: number; + let updatedItems: IAccountGroup[]; + + if (!over || active.id === over.id) { + return; + } + + previousIndex = _groups.findIndex(({ id }) => id === active.id); + nextIndex = _groups.findIndex(({ id }) => id === over.id); + + setGroups((prevState) => { + updatedItems = arrayMove(prevState, previousIndex, nextIndex).map( + (value, index) => ({ + ...value, + index, + }) + ); + + // update the external state + onGroupSort(updatedItems); + + return updatedItems; + }); + }; + + useEffect(() => setGroups(sortedGroups), [sortedGroups]); + + return ( + + + {_groups.map((group) => ( + + ))} + + + ); +}; + +export default SideBarGroupList; diff --git a/src/extension/components/SideBarGroupList/index.ts b/src/extension/components/SideBarGroupList/index.ts new file mode 100644 index 00000000..a9dc2534 --- /dev/null +++ b/src/extension/components/SideBarGroupList/index.ts @@ -0,0 +1,2 @@ +export { default } from './SideBarGroupList'; +export * from './types'; diff --git a/src/extension/components/SideBarGroupList/types/IProps.ts b/src/extension/components/SideBarGroupList/types/IProps.ts new file mode 100644 index 00000000..74a0fb27 --- /dev/null +++ b/src/extension/components/SideBarGroupList/types/IProps.ts @@ -0,0 +1,22 @@ +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + INetworkWithTransactionParams, + ISystemInfo, +} from '@extension/types'; + +interface IProps { + accounts: IAccountWithExtendedProps[]; + activeAccountID: string | null; + groups: IAccountGroup[]; + isShortForm: boolean; + network: INetworkWithTransactionParams; + onAccountClick: (id: string) => void; + onAccountSort: (items: IAccountWithExtendedProps[]) => void; + onGroupSort: (items: IAccountGroup[]) => void; + onRemoveAccountFromGroupClick: (accountID: string) => void; + systemInfo: ISystemInfo | null; +} + +export default IProps; diff --git a/src/extension/components/SideBarGroupList/types/index.ts b/src/extension/components/SideBarGroupList/types/index.ts new file mode 100644 index 00000000..f404deed --- /dev/null +++ b/src/extension/components/SideBarGroupList/types/index.ts @@ -0,0 +1 @@ +export type { default as IProps } from './IProps'; diff --git a/src/extension/components/SideBarAccountList/SkeletonItem.tsx b/src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx similarity index 94% rename from src/extension/components/SideBarAccountList/SkeletonItem.tsx rename to src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx index ea56216e..20761ced 100644 --- a/src/extension/components/SideBarAccountList/SkeletonItem.tsx +++ b/src/extension/components/SideBarSkeletonItem/SideBarSkeletonItem.tsx @@ -19,7 +19,7 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; // utils import ellipseAddress from '@extension/utils/ellipseAddress'; -const SkeletonItem: FC = () => { +const SideBarSkeletonItem: FC = () => { // hooks const buttonHoverBackgroundColor = useButtonHoverBackgroundColor(); const defaultTextColor = useDefaultTextColor(); @@ -55,4 +55,4 @@ const SkeletonItem: FC = () => { ); }; -export default SkeletonItem; +export default SideBarSkeletonItem; diff --git a/src/extension/components/SideBarSkeletonItem/index.ts b/src/extension/components/SideBarSkeletonItem/index.ts new file mode 100644 index 00000000..ec0bb613 --- /dev/null +++ b/src/extension/components/SideBarSkeletonItem/index.ts @@ -0,0 +1 @@ +export { default } from './SideBarSkeletonItem'; diff --git a/src/extension/constants/Dimensions.ts b/src/extension/constants/Dimensions.ts index 66e442b8..76beca9f 100644 --- a/src/extension/constants/Dimensions.ts +++ b/src/extension/constants/Dimensions.ts @@ -2,15 +2,15 @@ export const ACCOUNT_PAGE_HEADER_ITEM_HEIGHT = 10; // 2.5rem - 40px export const ACCOUNT_PAGE_TAB_CONTENT_HEIGHT = '60dvh'; export const ACCOUNT_SELECT_ITEM_MINIMUM_HEIGHT = 60; // px export const DEFAULT_GAP = 6; -export const DEFAULT_POPUP_HEIGHT = 750; +export const DEFAULT_POPUP_HEIGHT = 762; export const DEFAULT_POPUP_WIDTH = 465; export const INPUT_HEIGHT = 12; // 3rem - 48px export const MODAL_ITEM_HEIGHT = 10; // 10 = 2.5rem = 40px export const OPTION_HEIGHT = '57px'; export const PAGE_ITEM_HEIGHT = 10; // 10 = 2.5rem = 40px +export const SETTINGS_ITEM_HEIGHT = 16; export const SIDEBAR_BORDER_WIDTH = 1; export const SIDEBAR_ITEM_HEIGHT = 12; -export const SETTINGS_ITEM_HEIGHT = 16; export const SIDEBAR_MIN_WIDTH = 45; -export const SIDEBAR_MAX_WIDTH = 250; +export const SIDEBAR_MAX_WIDTH = 340; export const TAB_ITEM_HEIGHT = 16; diff --git a/src/extension/constants/Keys.ts b/src/extension/constants/Keys.ts index b89c2817..cbc1c404 100644 --- a/src/extension/constants/Keys.ts +++ b/src/extension/constants/Keys.ts @@ -1,4 +1,5 @@ export const ACCOUNTS_ITEM_KEY_PREFIX: string = 'accounts_'; +export const ACCOUNT_GROUPS_ITEM_KEY: string = 'account_groups'; export const ACTIVE_ACCOUNT_DETAILS_KEY: string = 'active_account_details'; export const APP_WINDOW_KEY_PREFIX: string = 'app_window_'; export const ARC0072_ASSETS_KEY_PREFIX: string = 'arc0072_assets_'; diff --git a/src/extension/constants/Limits.ts b/src/extension/constants/Limits.ts index 0e3610f2..a0ff318d 100644 --- a/src/extension/constants/Limits.ts +++ b/src/extension/constants/Limits.ts @@ -1,4 +1,5 @@ export const ACCOUNT_NAME_BYTE_LIMIT = 32; +export const ACCOUNT_GROUP_NAME_BYTE_LIMIT = 32; export const CUSTOM_NODE_BYTE_LIMIT = 16; export const DEFAULT_TRANSACTION_INDEXER_LIMIT = 20; export const EXPORT_ACCOUNT_PAGE_LIMIT = 5; diff --git a/src/extension/enums/DelimiterEnum.ts b/src/extension/enums/DelimiterEnum.ts new file mode 100644 index 00000000..10106f0a --- /dev/null +++ b/src/extension/enums/DelimiterEnum.ts @@ -0,0 +1,6 @@ +enum DelimiterEnum { + Account = 'account', + Group = 'group', +} + +export default DelimiterEnum; diff --git a/src/extension/enums/StoreNameEnum.ts b/src/extension/enums/StoreNameEnum.ts index cda2c2fc..9467ad3c 100644 --- a/src/extension/enums/StoreNameEnum.ts +++ b/src/extension/enums/StoreNameEnum.ts @@ -5,8 +5,10 @@ enum StoreNameEnum { ARC0200Assets = 'arc0200-assets', CredentialLock = 'credential-lock', Events = 'events', - Messages = 'messages', Layout = 'layout', + ManageGroupsModal = 'manage-groups-modal', + Messages = 'messages', + MoveGroupModal = 'move-group-modal', Networks = 'networks', Notifications = 'notifications', Passkeys = 'passkeys', diff --git a/src/extension/enums/index.ts b/src/extension/enums/index.ts index 5d8a826d..a53966aa 100644 --- a/src/extension/enums/index.ts +++ b/src/extension/enums/index.ts @@ -8,6 +8,7 @@ export { default as ARC0300AuthorityEnum } from './ARC0300AuthorityEnum'; export { default as ARC0300PathEnum } from './ARC0300PathEnum'; export { default as ARC0300QueryEnum } from './ARC0300QueryEnum'; export { default as AssetTypeEnum } from './AssetTypeEnum'; +export { default as DelimiterEnum } from './DelimiterEnum'; export { default as EncryptionMethodEnum } from './EncryptionMethodEnum'; export { default as EventTypeEnum } from './EventTypeEnum'; export { default as ErrorCodeEnum } from './ErrorCodeEnum'; diff --git a/src/extension/features/accounts/enums/ThunkEnum.ts b/src/extension/features/accounts/enums/ThunkEnum.ts index 1237e630..f40d8277 100644 --- a/src/extension/features/accounts/enums/ThunkEnum.ts +++ b/src/extension/features/accounts/enums/ThunkEnum.ts @@ -1,11 +1,15 @@ enum ThunkEnum { AddARC0200AssetHoldings = 'accounts/addARC0200AssetHoldings', AddStandardAssetHoldings = 'accounts/addStandardAssetHoldings', + AddToGroup = 'accounts/addToGroup', FetchAccountsFromStorage = 'accounts/fetchAccountsFromStorage', RemoveAccountById = 'accounts/removeAccountById', RemoveARC0200AssetHoldings = 'accounts/removeARC0200AssetHoldings', + RemoveGroupByID = 'accounts/removeGroupByID', + RemoveFromGroup = 'accounts/removeFromGroup', RemoveStandardAssetHoldings = 'accounts/removeStandardAssetHoldings', SaveAccountDetails = 'accounts/saveAccountDetails', + SaveAccountGroups = 'accounts/saveAccountGroups', SaveAccounts = 'accounts/saveAccounts', SaveActiveAccountDetails = 'accounts/saveActiveAccountDetails', SaveNewAccounts = 'accounts/saveNewAccounts', diff --git a/src/extension/features/accounts/slice.ts b/src/extension/features/accounts/slice.ts index d88b8703..e354645e 100644 --- a/src/extension/features/accounts/slice.ts +++ b/src/extension/features/accounts/slice.ts @@ -3,18 +3,19 @@ import { createSlice } from '@reduxjs/toolkit'; // enums import { StoreNameEnum } from '@extension/enums'; -// repositories -import AccountRepository from '@extension/repositories/AccountRepository'; - // thunks import { addARC0200AssetHoldingsThunk, addStandardAssetHoldingsThunk, + addToGroupThunk, fetchAccountsFromStorageThunk, removeAccountByIdThunk, removeARC0200AssetHoldingsThunk, + removeFromGroupThunk, + removeGroupByIDThunk, removeStandardAssetHoldingsThunk, saveAccountDetailsThunk, + saveAccountGroupsThunk, saveAccountsThunk, saveActiveAccountDetails, saveNewAccountsThunk, @@ -25,7 +26,10 @@ import { } from './thunks'; // types -import type { IAccountWithExtendedProps } from '@extension/types'; +import type { + IAccountGroup, + IAccountWithExtendedProps, +} from '@extension/types'; import type { IState } from './types'; // utils @@ -112,11 +116,28 @@ const slice = createSlice({ ); } ); + /** remove from group **/ + builder.addCase(addToGroupThunk.fulfilled, (state: IState, action) => { + if (action.payload) { + state.items = upsertItemsById(state.items, [ + action.payload, + ]); + } + + state.saving = false; + }); + builder.addCase(addToGroupThunk.pending, (state: IState) => { + state.saving = true; + }); + builder.addCase(addToGroupThunk.rejected, (state: IState) => { + state.saving = false; + }); /** fetch accounts from storage **/ builder.addCase( fetchAccountsFromStorageThunk.fulfilled, (state: IState, action) => { state.activeAccountDetails = action.payload.activeAccountDetails; + state.groups = action.payload.groups; state.items = action.payload.accounts; state.fetching = false; } @@ -182,6 +203,36 @@ const slice = createSlice({ ); } ); + /** remove from group **/ + builder.addCase(removeFromGroupThunk.fulfilled, (state: IState, action) => { + if (action.payload) { + state.items = upsertItemsById(state.items, [ + action.payload, + ]); + } + + state.saving = false; + }); + builder.addCase(removeFromGroupThunk.pending, (state: IState) => { + state.saving = true; + }); + builder.addCase(removeFromGroupThunk.rejected, (state: IState) => { + state.saving = false; + }); + /** remove group by id **/ + builder.addCase(removeGroupByIDThunk.fulfilled, (state: IState, action) => { + if (action.payload) { + state.groups = state.groups.filter(({ id }) => id !== action.payload); + state.items = state.items.map((value) => ({ + ...value, + ...(!!value.groupID && + value.groupID === action.payload && { + groupID: null, + groupIndex: null, + }), + })); + } + }); /** remove standard asset holdings **/ builder.addCase( removeStandardAssetHoldingsThunk.fulfilled, @@ -241,10 +292,21 @@ const slice = createSlice({ builder.addCase(saveAccountDetailsThunk.rejected, (state: IState) => { state.saving = false; }); + /** save account groups **/ + builder.addCase( + saveAccountGroupsThunk.fulfilled, + (state: IState, action) => { + state.groups = upsertItemsById( + state.groups, + action.payload + ); + } + ); /** save accounts **/ builder.addCase(saveAccountsThunk.fulfilled, (state: IState, action) => { - state.items = AccountRepository.sort( - upsertItemsById(state.items, action.payload) + state.items = upsertItemsById( + state.items, + action.payload ); state.saving = false; }); diff --git a/src/extension/features/accounts/thunks/addToGroupThunk.ts b/src/extension/features/accounts/thunks/addToGroupThunk.ts new file mode 100644 index 00000000..162e42cb --- /dev/null +++ b/src/extension/features/accounts/thunks/addToGroupThunk.ts @@ -0,0 +1,81 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; +import type { IAddToGroupPayload } from '../types'; + +// utils +import isWatchAccount from '@extension/utils/isWatchAccount/isWatchAccount'; +import serialize from '@extension/utils/serialize'; +import { findAccountWithoutExtendedProps } from '../utils'; + +const addToGroupThunk: AsyncThunk< + IAccountWithExtendedProps | null, // return + IAddToGroupPayload, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountWithExtendedProps | null, + IAddToGroupPayload, + IBaseAsyncThunkConfig +>(ThunkEnum.AddToGroup, async ({ accountID, groupID }, { getState }) => { + const logger = getState().system.logger; + const accounts = getState().accounts.items; + const groups = getState().accounts.groups; + let account = serialize(findAccountWithoutExtendedProps(accountID, accounts)); + let group: IAccountGroup | null; + let groupIndex: number; + + if (!account) { + logger.debug( + `${ThunkEnum.AddToGroup}: no account found for "${accountID}", ignoring` + ); + + return null; + } + + group = groups.find(({ id }) => id === groupID) || null; + + if (!group) { + logger.debug( + `${ThunkEnum.AddToGroup}: no group found for "${groupID}", ignoring` + ); + + return null; + } + + // get the group index based on the accounts already in the group + groupIndex = accounts.filter( + (value) => + !!value.groupID && value.groupID === group?.id && !!value.groupIndex + ).length; + + account = { + ...account, + groupID, + groupIndex, + }; + + await new AccountRepository().saveMany([account]); + + logger.debug( + `${ThunkEnum.AddToGroup}: added account "${accountID}" to group "${groupID}"` + ); + + return { + ...account, + watchAccount: await isWatchAccount(account), + }; +}); + +export default addToGroupThunk; diff --git a/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts b/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts index c2776555..12fe29ac 100644 --- a/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts +++ b/src/extension/features/accounts/thunks/fetchAccountsFromStorageThunk.ts @@ -10,7 +10,6 @@ import ActiveAccountRepositoryService from '@extension/repositories/ActiveAccoun // types import type { IAccount, - IActiveAccountDetails, IBackgroundRootState, IBaseAsyncThunkConfig, IMainRootState, @@ -19,6 +18,7 @@ import type { IFetchAccountsFromStorageResult } from '../types'; // utils import isWatchAccount from '@extension/utils/isWatchAccount'; +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; const fetchAccountsFromStorageThunk: AsyncThunk< IFetchAccountsFromStorageResult, // return @@ -31,14 +31,12 @@ const fetchAccountsFromStorageThunk: AsyncThunk< >(ThunkEnum.FetchAccountsFromStorage, async (_, { getState }) => { const logger = getState().system.logger; let accounts: IAccount[]; - let activeAccountDetails: IActiveAccountDetails | null; logger.debug( `${ThunkEnum.FetchAccountsFromStorage}: fetching accounts from storage` ); accounts = await new AccountRepository().fetchAll(); - activeAccountDetails = await new ActiveAccountRepositoryService().fetch(); return { accounts: await Promise.all( @@ -47,7 +45,8 @@ const fetchAccountsFromStorageThunk: AsyncThunk< watchAccount: await isWatchAccount(value), })) ), - activeAccountDetails, + activeAccountDetails: await new ActiveAccountRepositoryService().fetch(), + groups: await new AccountGroupRepository().fetchAll(), }; }); diff --git a/src/extension/features/accounts/thunks/index.ts b/src/extension/features/accounts/thunks/index.ts index 70c9ca1c..7496c414 100644 --- a/src/extension/features/accounts/thunks/index.ts +++ b/src/extension/features/accounts/thunks/index.ts @@ -1,10 +1,14 @@ export { default as addARC0200AssetHoldingsThunk } from './addARC0200AssetHoldingsThunk'; export { default as addStandardAssetHoldingsThunk } from './addStandardAssetHoldingsThunk'; +export { default as addToGroupThunk } from './addToGroupThunk'; export { default as fetchAccountsFromStorageThunk } from './fetchAccountsFromStorageThunk'; export { default as removeAccountByIdThunk } from './removeAccountByIdThunk'; export { default as removeARC0200AssetHoldingsThunk } from './removeARC0200AssetHoldingsThunk'; +export { default as removeFromGroupThunk } from './removeFromGroupThunk'; +export { default as removeGroupByIDThunk } from './removeGroupByIDThunk'; export { default as removeStandardAssetHoldingsThunk } from './removeStandardAssetHoldingsThunk'; export { default as saveAccountDetailsThunk } from './saveAccountDetailsThunk'; +export { default as saveAccountGroupsThunk } from './saveAccountGroupsThunk'; export { default as saveAccountsThunk } from './saveAccountsThunk'; export { default as saveActiveAccountDetails } from './saveActiveAccountDetails'; export { default as saveNewAccountsThunk } from './saveNewAccountsThunk'; diff --git a/src/extension/features/accounts/thunks/removeFromGroupThunk.ts b/src/extension/features/accounts/thunks/removeFromGroupThunk.ts new file mode 100644 index 00000000..506156dc --- /dev/null +++ b/src/extension/features/accounts/thunks/removeFromGroupThunk.ts @@ -0,0 +1,60 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + +// types +import type { + IAccountWithExtendedProps, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; + +// utils +import isWatchAccount from '@extension/utils/isWatchAccount/isWatchAccount'; +import serialize from '@extension/utils/serialize'; +import { findAccountWithoutExtendedProps } from '../utils'; + +const removeFromGroupThunk: AsyncThunk< + IAccountWithExtendedProps | null, // return + string, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountWithExtendedProps | null, + string, + IBaseAsyncThunkConfig +>(ThunkEnum.RemoveFromGroup, async (accountID, { getState }) => { + const logger = getState().system.logger; + const accounts = getState().accounts.items; + let account = serialize(findAccountWithoutExtendedProps(accountID, accounts)); + + if (!account) { + logger.debug( + `${ThunkEnum.RemoveFromGroup}: no account found for "${accountID}", ignoring` + ); + + return null; + } + + account = { + ...account, + groupID: null, + groupIndex: null, + }; + + await new AccountRepository().saveMany([account]); + + logger.debug( + `${ThunkEnum.RemoveFromGroup}: removed account "${accountID}" from group` + ); + + return { + ...account, + watchAccount: await isWatchAccount(account), + }; +}); + +export default removeFromGroupThunk; diff --git a/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts b/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts new file mode 100644 index 00000000..b190d7c0 --- /dev/null +++ b/src/extension/features/accounts/thunks/removeGroupByIDThunk.ts @@ -0,0 +1,60 @@ +import { type AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// types +import type { + IAccount, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; +import AccountRepository from '@extension/repositories/AccountRepository'; + +const removeGroupByIDThunk: AsyncThunk< + string | null, // return + string, // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + string | null, + string, + IBaseAsyncThunkConfig +>(ThunkEnum.RemoveGroupByID, async (id, { getState }) => { + const accounts = getState().accounts.items; + const logger = getState().system.logger; + const groups = getState().accounts.groups; + const group = groups.find((value) => value.id === id) || null; + let groupAccounts: IAccount[]; + + if (!group) { + logger.debug( + `${ThunkEnum.RemoveGroupByID}: group "${id}" does not exist, ignoring` + ); + + return null; + } + + groupAccounts = accounts.filter(({ groupID }) => groupID === group.id); + + await new AccountGroupRepository().removeByID(group.id); + + // if there are group accounts, remove the group ids and indexes + if (groupAccounts.length > 0) { + await new AccountRepository().saveMany( + groupAccounts.map((value) => ({ + ...value, + groupID: null, + groupIndex: null, + })) + ); + } + + logger.debug(`${ThunkEnum.RemoveGroupByID}: removed group "${id}"`); + + return id; +}); + +export default removeGroupByIDThunk; diff --git a/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts b/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts new file mode 100644 index 00000000..8802df48 --- /dev/null +++ b/src/extension/features/accounts/thunks/saveAccountGroupsThunk.ts @@ -0,0 +1,37 @@ +import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit'; + +// enums +import { ThunkEnum } from '../enums'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// types +import type { + IAccountGroup, + IBaseAsyncThunkConfig, + IMainRootState, +} from '@extension/types'; + +const saveAccountGroupsThunk: AsyncThunk< + IAccountGroup[], // return + IAccountGroup[], // args + IBaseAsyncThunkConfig +> = createAsyncThunk< + IAccountGroup[], + IAccountGroup[], + IBaseAsyncThunkConfig +>(ThunkEnum.SaveAccountGroups, async (groups, { getState }) => { + const logger = getState().system.logger; + const _groups = await new AccountGroupRepository().saveMany(groups); + + logger.debug( + `${ThunkEnum.SaveAccountGroups}: saved groups [${_groups + .map(({ id }) => `"${id}"`) + .join(',')}] to storage` + ); + + return _groups; +}); + +export default saveAccountGroupsThunk; diff --git a/src/extension/features/accounts/types/IAddToGroupPayload.ts b/src/extension/features/accounts/types/IAddToGroupPayload.ts new file mode 100644 index 00000000..77c94c95 --- /dev/null +++ b/src/extension/features/accounts/types/IAddToGroupPayload.ts @@ -0,0 +1,6 @@ +interface IAddToGroupPayload { + accountID: string; + groupID: string; +} + +export default IAddToGroupPayload; diff --git a/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts b/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts index 8884409a..aaa24d5c 100644 --- a/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts +++ b/src/extension/features/accounts/types/IFetchAccountsFromStorageResult.ts @@ -1,15 +1,18 @@ import type { + IAccountGroup, IAccountWithExtendedProps, IActiveAccountDetails, } from '@extension/types'; /** - * @property {IAccount[]} accounts - all the accounts stored in storage. - * @property {IActiveAccountDetails | null} activeAccountDetails - the details of the saved active account. + * @property {IAccount[]} accounts - All the accounts stored in storage. + * @property {IActiveAccountDetails | null} activeAccountDetails - The details of the saved active account. + * @property {IAccountGroup[]} groups - All account groups in storage. */ interface IFetchAccountsFromStorageResult { accounts: IAccountWithExtendedProps[]; activeAccountDetails: IActiveAccountDetails | null; + groups: IAccountGroup[]; } export default IFetchAccountsFromStorageResult; diff --git a/src/extension/features/accounts/types/IState.ts b/src/extension/features/accounts/types/IState.ts index f17bb7c1..a8990f75 100644 --- a/src/extension/features/accounts/types/IState.ts +++ b/src/extension/features/accounts/types/IState.ts @@ -1,5 +1,6 @@ // types import type { + IAccountGroup, IAccountWithExtendedProps, IActiveAccountDetails, } from '@extension/types'; @@ -8,7 +9,8 @@ import type IAccountUpdateRequest from './IAccountUpdateRequest'; /** * @property {IActiveAccountDetails | null} activeAccountDetails - details of the active account. * @property {boolean} fetching - true when fetching accounts from storage. - * @property {IAccount[]} items - all accounts + * @property {IAccountGroup[]} groups - All account groups. + * @property {IAccount[]} items - All accounts. * @property {number | null} pollingId - id of the polling interval. * @property {boolean} saving - true when the account is being saved to storage. * @property {IAccountUpdateRequest[]} updateRequests - a list of account update events being updated. @@ -16,6 +18,7 @@ import type IAccountUpdateRequest from './IAccountUpdateRequest'; interface IState { activeAccountDetails: IActiveAccountDetails | null; fetching: boolean; + groups: IAccountGroup[]; items: IAccountWithExtendedProps[]; pollingId: number | null; saving: boolean; diff --git a/src/extension/features/accounts/types/index.ts b/src/extension/features/accounts/types/index.ts index ebc0a253..73595c2e 100644 --- a/src/extension/features/accounts/types/index.ts +++ b/src/extension/features/accounts/types/index.ts @@ -1,4 +1,5 @@ export type { default as IAccountUpdateRequest } from './IAccountUpdateRequest'; +export type { default as IAddToGroupPayload } from './IAddToGroupPayload'; export type { default as IFetchAccountsFromStorageResult } from './IFetchAccountsFromStorageResult'; export type { default as ISaveAccountDetailsPayload } from './ISaveAccountDetailsPayload'; export type { default as ISaveNewAccountsPayload } from './ISaveNewAccountsPayload'; diff --git a/src/extension/features/accounts/utils/getInitialState.ts b/src/extension/features/accounts/utils/getInitialState.ts index 54fe91fb..6478662f 100644 --- a/src/extension/features/accounts/utils/getInitialState.ts +++ b/src/extension/features/accounts/utils/getInitialState.ts @@ -1,10 +1,11 @@ // types -import { IState } from '../types'; +import type { IState } from '../types'; export default function getInitialState(): IState { return { activeAccountDetails: null, fetching: false, + groups: [], items: [], pollingId: null, saving: false, diff --git a/src/extension/features/layout/slice.ts b/src/extension/features/layout/slice.ts index f2736305..3980f75c 100644 --- a/src/extension/features/layout/slice.ts +++ b/src/extension/features/layout/slice.ts @@ -13,7 +13,7 @@ const slice = createSlice({ initialState: getInitialState(), name: StoreNameEnum.Layout, reducers: { - setConfirmModal: ( + openConfirmModal: ( state: Draft, action: PayloadAction ) => { @@ -39,7 +39,7 @@ const slice = createSlice({ export const reducer: Reducer = slice.reducer; export const { - setConfirmModal, + openConfirmModal, setScanQRCodeModal, setSideBar, setWhatsNewModal, diff --git a/src/extension/features/manage-groups-modal/index.ts b/src/extension/features/manage-groups-modal/index.ts new file mode 100644 index 00000000..36c53b16 --- /dev/null +++ b/src/extension/features/manage-groups-modal/index.ts @@ -0,0 +1,2 @@ +export * from './slice'; +export * from './types'; diff --git a/src/extension/features/manage-groups-modal/slice.ts b/src/extension/features/manage-groups-modal/slice.ts new file mode 100644 index 00000000..9c506efa --- /dev/null +++ b/src/extension/features/manage-groups-modal/slice.ts @@ -0,0 +1,26 @@ +import { createSlice, Draft, Reducer } from '@reduxjs/toolkit'; + +// enums +import { StoreNameEnum } from '@extension/enums'; + +// types +import type { IState } from './types'; + +// utils +import getInitialState from './utils/getInitialState'; + +const slice = createSlice({ + initialState: getInitialState(), + name: StoreNameEnum.ManageGroupsModal, + reducers: { + closeModal: (state: Draft) => { + state.isOpen = false; + }, + openModal: (state: Draft) => { + state.isOpen = true; + }, + }, +}); + +export const reducer: Reducer = slice.reducer; +export const { closeModal, openModal } = slice.actions; diff --git a/src/extension/features/manage-groups-modal/types/IState.ts b/src/extension/features/manage-groups-modal/types/IState.ts new file mode 100644 index 00000000..0184e6f2 --- /dev/null +++ b/src/extension/features/manage-groups-modal/types/IState.ts @@ -0,0 +1,5 @@ +interface IState { + isOpen: boolean; +} + +export default IState; diff --git a/src/extension/features/manage-groups-modal/types/index.ts b/src/extension/features/manage-groups-modal/types/index.ts new file mode 100644 index 00000000..bf812279 --- /dev/null +++ b/src/extension/features/manage-groups-modal/types/index.ts @@ -0,0 +1 @@ +export type { default as IState } from './IState'; diff --git a/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts b/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts new file mode 100644 index 00000000..37735d0e --- /dev/null +++ b/src/extension/features/manage-groups-modal/utils/getInitialState/getInitialState.ts @@ -0,0 +1,8 @@ +// types +import type { IState } from '../../types'; + +export default function getInitialState(): IState { + return { + isOpen: false, + }; +} diff --git a/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts b/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts new file mode 100644 index 00000000..1f873320 --- /dev/null +++ b/src/extension/features/manage-groups-modal/utils/getInitialState/index.ts @@ -0,0 +1 @@ +export { default } from './getInitialState'; diff --git a/src/extension/features/move-group-modal/index.ts b/src/extension/features/move-group-modal/index.ts new file mode 100644 index 00000000..36c53b16 --- /dev/null +++ b/src/extension/features/move-group-modal/index.ts @@ -0,0 +1,2 @@ +export * from './slice'; +export * from './types'; diff --git a/src/extension/features/move-group-modal/slice.ts b/src/extension/features/move-group-modal/slice.ts new file mode 100644 index 00000000..5ffca648 --- /dev/null +++ b/src/extension/features/move-group-modal/slice.ts @@ -0,0 +1,26 @@ +import { createSlice, Draft, PayloadAction, Reducer } from '@reduxjs/toolkit'; + +// enums +import { StoreNameEnum } from '@extension/enums'; + +// types +import type { IState } from './types'; + +// utils +import getInitialState from './utils/getInitialState'; + +const slice = createSlice({ + initialState: getInitialState(), + name: StoreNameEnum.MoveGroupModal, + reducers: { + closeModal: (state: Draft) => { + state.accountID = null; + }, + openModal: (state: Draft, action: PayloadAction) => { + state.accountID = action.payload; + }, + }, +}); + +export const reducer: Reducer = slice.reducer; +export const { closeModal, openModal } = slice.actions; diff --git a/src/extension/features/move-group-modal/types/IState.ts b/src/extension/features/move-group-modal/types/IState.ts new file mode 100644 index 00000000..3e0a65a6 --- /dev/null +++ b/src/extension/features/move-group-modal/types/IState.ts @@ -0,0 +1,5 @@ +interface IState { + accountID: string | null; +} + +export default IState; diff --git a/src/extension/features/move-group-modal/types/index.ts b/src/extension/features/move-group-modal/types/index.ts new file mode 100644 index 00000000..bf812279 --- /dev/null +++ b/src/extension/features/move-group-modal/types/index.ts @@ -0,0 +1 @@ +export type { default as IState } from './IState'; diff --git a/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts b/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts new file mode 100644 index 00000000..16525633 --- /dev/null +++ b/src/extension/features/move-group-modal/utils/getInitialState/getInitialState.ts @@ -0,0 +1,8 @@ +// types +import type { IState } from '../../types'; + +export default function getInitialState(): IState { + return { + accountID: null, + }; +} diff --git a/src/extension/features/move-group-modal/utils/getInitialState/index.ts b/src/extension/features/move-group-modal/utils/getInitialState/index.ts new file mode 100644 index 00000000..1f873320 --- /dev/null +++ b/src/extension/features/move-group-modal/utils/getInitialState/index.ts @@ -0,0 +1 @@ +export { default } from './getInitialState'; diff --git a/src/extension/hooks/useGenericInput/useGenericInput.ts b/src/extension/hooks/useGenericInput/useGenericInput.ts index 825f1c6a..56ea27ce 100644 --- a/src/extension/hooks/useGenericInput/useGenericInput.ts +++ b/src/extension/hooks/useGenericInput/useGenericInput.ts @@ -31,7 +31,7 @@ export default function useGenericInput< let byteLength: number; // update the characters remaining - if (characterLimit) { + if (typeof characterLimit === 'number') { byteLength = new TextEncoder().encode(event.target.value).byteLength; setCharactersRemaining(characterLimit - byteLength); @@ -72,7 +72,7 @@ export default function useGenericInput< setValue, validate: _validate, value, - ...(charactersRemaining && { + ...(typeof charactersRemaining === 'number' && { charactersRemaining, }), }; diff --git a/src/extension/icons/BsFolderMove/BsFolderMove.tsx b/src/extension/icons/BsFolderMove/BsFolderMove.tsx new file mode 100644 index 00000000..86159082 --- /dev/null +++ b/src/extension/icons/BsFolderMove/BsFolderMove.tsx @@ -0,0 +1,21 @@ +import { GenIcon, type IconBaseProps, type IconType } from 'react-icons'; + +const BsFolderMove: IconType = (props: IconBaseProps) => + GenIcon({ + tag: 'svg', + attr: { + viewBox: '0 0 16 16', + }, + child: [ + { + tag: 'path', + attr: { + d: 'M 2.640625,13.99001 C 2.619141,13.98681 2.548828,13.97651 2.484375,13.96703 2.3355786,13.945126 2.0857527,13.863721 1.936177,13.788398 1.3884112,13.512556 0.99226373,12.997872 0.8694482,12.402479 0.85572525,12.335952 0.69588925,10.651968 0.51425706,8.6602919 0.18756484,5.0779689 0.18419634,5.0369641 0.20073327,4.84375 0.22808778,4.5241455 0.32483192,4.2189119 0.47813419,3.9685333 L 0.54045178,3.8667539 0.51961352,3.4529083 C 0.48976158,2.8600519 0.50802427,2.6412838 0.61196273,2.3466603 0.71829281,2.0452575 0.85610062,1.8286572 1.0922666,1.59174 1.3318357,1.3514086 1.570505,1.2032589 1.8813111,1.1019546 2.1823016,1.0038497 2.078647,1.0078125 4.34375,1.0078125 c 1.9411191,0 2.0786003,0.00177 2.203125,0.028372 0.1788497,0.038206 0.3808261,0.1068702 0.5189297,0.1764158 0.2391962,0.1204536 0.3337364,0.2004427 0.9108142,0.7706267 0.423723,0.4186611 0.5972472,0.5794418 0.703125,0.6514881 0.232208,0.1580097 0.4471759,0.25349 0.7265061,0.3226853 0.1310009,0.032451 0.1638228,0.033032 2.40625,0.042539 2.055967,0.00872 2.2839,0.012155 2.382813,0.035937 0.213146,0.051248 0.330155,0.092364 0.5,0.1756969 0.569315,0.27933 0.988054,0.8446875 1.087378,1.4681147 0.03655,0.2294164 0.0224,0.4783058 -0.108413,1.9076864 -0.07007,0.7656339 -0.127403,1.3966886 -0.127403,1.4023438 0,0.00566 -0.225,0.010282 -0.5,0.010282 h -0.5 l -4.7e-5,-0.035156 C 14.546797,7.9455082 14.607538,7.2687505 14.68181,6.460938 14.829175,4.8580494 14.82822,4.8828428 14.75109,4.6634057 14.67021,4.4332794 14.484787,4.2232297 14.264767,4.1124844 14.022342,3.9904614 14.58656,4.0005432 8.0000012,4.0005432 c -6.4751605,0 -6.019226,-0.00665 -6.2251944,0.090732 -0.2990245,0.141372 -0.5022711,0.4026981 -0.5659034,0.727615 l -0.027262,0.1392022 0.3252963,3.5789029 c 0.1789131,1.9683967 0.3347923,3.6248967 0.3463982,3.6811107 0.0569,0.275597 0.2660563,0.544103 0.5221938,0.67037 0.2412103,0.11891 -0.00831,0.110951 3.4877523,0.111253 L 9,13 V 13.5 14 L 5.8398437,13.9979 C 4.1017578,13.9967 2.6621094,13.9932 2.640625,13.99 Z M 1.6743886,3.0709225 c 0.080258,-0.021149 0.205505,-0.045758 0.2783266,-0.054687 0.08692,-0.010658 1.077224,-0.016321 2.8828125,-0.016487 L 7.5859375,2.9994967 7.171875,2.5879866 C 6.779123,2.1976556 6.750977,2.1731269 6.625,2.1113944 6.3876989,1.9951095 6.4998902,2.0005427 4.3359375,2.0005427 c -2.1643262,0 -2.0523945,-0.00543 -2.2885336,0.1109812 -0.3287309,0.1620544 -0.5461662,0.516695 -0.5471326,0.8923823 -1.5e-4,0.058008 0.00613,0.1054688 0.013961,0.1054688 0.00783,0 0.079898,-0.017304 0.1601563,-0.038453 z M 14.186728,13.18277 c -0.219425,-0.06204 -0.363856,-0.251556 -0.365538,-0.479645 -0.0013,-0.181672 0.04202,-0.258504 0.283771,-0.50283 l 0.197559,-0.199665 -1.459854,-0.0042 -1.459853,-0.0042 -0.07546,-0.03483 c -0.117545,-0.05425 -0.188279,-0.119309 -0.245909,-0.22618 -0.04843,-0.08982 -0.05305,-0.109912 -0.05305,-0.23118 0,-0.121292 0.0046,-0.141353 0.05309,-0.231272 0.05893,-0.109287 0.10754,-0.156372 0.225892,-0.218812 l 0.0798,-0.0421 1.45975,-0.0043 1.459752,-0.0043 -0.189687,-0.19187 C 13.863808,10.57147 13.819862,10.492625 13.82119,10.3125 c 0.001,-0.141055 0.0364,-0.230482 0.132238,-0.3344562 0.09856,-0.106926 0.213792,-0.1573201 0.359728,-0.1573201 0.219231,0 0.225169,0.00461 0.969816,0.7526853 0.731955,0.735325 0.709216,0.705366 0.709216,0.934403 0,0.225878 0.0042,0.220378 -0.723537,0.949969 -0.540194,0.541578 -0.649941,0.644407 -0.729588,0.6836 -0.111414,0.05482 -0.24805,0.07087 -0.352335,0.04139 z', + fill: 'currentColor', + }, + child: [], + }, + ], + })(props); + +export default BsFolderMove; diff --git a/src/extension/icons/BsFolderMove/index.ts b/src/extension/icons/BsFolderMove/index.ts new file mode 100644 index 00000000..1afd10bf --- /dev/null +++ b/src/extension/icons/BsFolderMove/index.ts @@ -0,0 +1 @@ +export { default } from './BsFolderMove'; diff --git a/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx new file mode 100644 index 00000000..874fea79 --- /dev/null +++ b/src/extension/modals/ManageGroupsModal/ManageGroupsModal.tsx @@ -0,0 +1,338 @@ +import { + Heading, + HStack, + Icon, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + Tooltip, + VStack, +} from '@chakra-ui/react'; +import { randomString } from '@stablelib/random'; +import React, { type FC, KeyboardEvent, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + IoFolderOutline, + IoPencilOutline, + IoTrashOutline, +} from 'react-icons/io5'; +import { useDispatch } from 'react-redux'; + +// components +import Button from '@extension/components/Button'; +import EditableText from '@extension/components/EditableText'; +import GenericInput from '@extension/components/GenericInput'; +import IconButton from '@extension/components/IconButton'; +import ModalSubHeading from '@extension/components/ModalSubHeading'; +import ScrollableContainer from '@extension/components/ScrollableContainer'; + +// constants +import { + ACCOUNT_GROUP_NAME_BYTE_LIMIT, + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, + INPUT_HEIGHT, +} from '@extension/constants'; + +// features +import { + removeGroupByIDThunk, + saveAccountGroupsThunk, +} from '@extension/features/accounts'; +import { openConfirmModal } from '@extension/features/layout'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useGenericInput from '@extension/hooks/useGenericInput'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// selectors +import { + useSelectAccounts, + useSelectAccountsSaving, + useSelectAccountGroups, + useSelectManageGroupsModalIsOpen, +} from '@extension/selectors'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { + IAppThunkDispatch, + IMainRootState, + IModalProps, +} from '@extension/types'; + +// utils +import calculateIconSize from '@extension/utils/calculateIconSize'; + +const ManageGroupsModal: FC = ({ onClose }) => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + // selectors + const accounts = useSelectAccounts(); + const isOpen = useSelectManageGroupsModalIsOpen(); + const groups = useSelectAccountGroups(); + const saving = useSelectAccountsSaving(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const { + charactersRemaining: nameCharactersRemaining, + error: nameError, + label: nameLabel, + onBlur: nameOnBlur, + onChange: nameOnChange, + required: isNameRequired, + reset: resetName, + value: nameValue, + validate: validateName, + } = useGenericInput({ + characterLimit: ACCOUNT_GROUP_NAME_BYTE_LIMIT, + label: t('labels.name'), + }); + const subTextColor = useSubTextColor(); + // memo + const _context = useMemo(() => randomString(9), []); + // states + const [editingGroupID, setEditingGroupID] = useState(null); + // handlers + const handleOnAddSubmit = async () => { + if (nameValue.length <= 0 || !!validateName(nameValue)) { + return; + } + + // add the new group + await dispatch( + saveAccountGroupsThunk([ + AccountGroupRepository.initializeDefaultAccountGroup(nameValue), + ]) + ).unwrap(); + + // reset input + resetName(); + }; + const handleCancelClick = () => handleClose(); + const handleClose = () => { + // reset inputs + resetName(); + // close + onClose && onClose(); + }; + const handleOnEditCancel = () => setEditingGroupID(null); + const handleOnEditClick = (id: string) => () => setEditingGroupID(id); + const handleOnEditSubmit = (id: string) => (value: string) => { + const group = groups.find((_value) => _value.id === id) || null; + + setEditingGroupID(null); + + if (!group || value === group.name || value.length <= 0) { + return; + } + + dispatch( + saveAccountGroupsThunk([ + { + ...group, + name: value, + }, + ]) + ); + }; + const handleOnKeyUp = async (event: KeyboardEvent) => { + if (event.key === 'Enter') { + await handleOnAddSubmit(); + } + }; + const handleOnRemoveClick = (id: string) => async () => { + const group = groups.find((value) => value.id === id) || null; + let numberOfAccounts: number; + + if (!group) { + return; + } + + numberOfAccounts = AccountGroupRepository.numberOfAccountsInGroup( + id, + accounts + ); + + // for groups with no accounts, just remove without warning + if (numberOfAccounts <= 0) { + dispatch(removeGroupByIDThunk(id)); + + return; + } + + dispatch( + openConfirmModal({ + description: t('captions.removeGroupConfirm', { + group: group.name, + numberOfAccounts, + }), + onConfirm: () => dispatch(removeGroupByIDThunk(id)), + title: t('headings.removeGroup'), + warningText: t('captions.removeGroupConfirmWarning'), + }) + ); + }; + // renders + const renderGroupItems = () => { + if (groups.length <= 0) { + return ( + + + {t('captions.noGroupsAvailable')} + + + ); + } + + return ( + + {groups.map((value) => ( + + {/*icon*/} + + + {/*name*/} + + + {`(${AccountGroupRepository.numberOfAccountsInGroup( + value.id, + accounts + )})`} + + + + + + {/*edit button*/} + ('labels.editGroup')}> + ('ariaLabels.pencilIcon')} + icon={IoPencilOutline} + onClick={handleOnEditClick(value.id)} + size="sm" + variant="ghost" + /> + + + {/*remove button*/} + ('labels.remove')}> + ('ariaLabels.deleteIcon')} + icon={IoTrashOutline} + onClick={handleOnRemoveClick(value.id)} + size="sm" + variant="ghost" + /> + + + ))} + + ); + }; + + return ( + + + {/*header*/} + + + {t('headings.manageGroups')} + + + + {/*body*/} + + + ('headings.addGroup')} /> + {/*add group*/} + ('placeholders.groupName')} + type="text" + validate={validateName} + value={nameValue} + /> + + ('headings.editGroups')} /> + + {/*remove groups*/} + {renderGroupItems()} + + + + {/*footer*/} + + {/*cancel button*/} + + + + + ); +}; + +export default ManageGroupsModal; diff --git a/src/extension/modals/ManageGroupsModal/index.ts b/src/extension/modals/ManageGroupsModal/index.ts new file mode 100644 index 00000000..f3352d1b --- /dev/null +++ b/src/extension/modals/ManageGroupsModal/index.ts @@ -0,0 +1 @@ +export { default } from './ManageGroupsModal'; diff --git a/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx b/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx new file mode 100644 index 00000000..6f4a9316 --- /dev/null +++ b/src/extension/modals/MoveGroupModal/MoveGroupModal.tsx @@ -0,0 +1,297 @@ +import { + Heading, + HStack, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Text, + Tooltip, + VStack, +} from '@chakra-ui/react'; +import React, { type FC, KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IoFolderOutline } from 'react-icons/io5'; +import { useDispatch } from 'react-redux'; + +// components +import ActionItem from '@extension/components/ActionItem'; +import Button from '@extension/components/Button'; +import GenericInput from '@extension/components/GenericInput'; +import ModalSubHeading from '@extension/components/ModalSubHeading'; +import ScrollableContainer from '@extension/components/ScrollableContainer'; + +// constants +import { + ACCOUNT_GROUP_NAME_BYTE_LIMIT, + BODY_BACKGROUND_COLOR, + DEFAULT_GAP, +} from '@extension/constants'; + +// features +import { + addToGroupThunk, + saveAccountGroupsThunk, +} from '@extension/features/accounts'; +import { create as createNotification } from '@extension/features/notifications'; + +// hooks +import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; +import useGenericInput from '@extension/hooks/useGenericInput'; +import useSubTextColor from '@extension/hooks/useSubTextColor'; + +// repositories +import AccountGroupRepository from '@extension/repositories/AccountGroupRepository'; + +// selectors +import { + useSelectAccounts, + useSelectAccountsSaving, + useSelectAccountGroups, + useSelectMoveGroupModalAccount, +} from '@extension/selectors'; + +// theme +import { theme } from '@extension/theme'; + +// types +import type { + IAccountGroup, + IAccountWithExtendedProps, + IAppThunkDispatch, + IMainRootState, + IModalProps, +} from '@extension/types'; + +// utils +import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; +import ellipseAddress from '@extension/utils/ellipseAddress'; + +const MoveGroupModal: FC = ({ onClose }) => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + // selectors + const account = useSelectMoveGroupModalAccount(); + const accounts = useSelectAccounts(); + const groups = useSelectAccountGroups(); + const saving = useSelectAccountsSaving(); + // hooks + const defaultTextColor = useDefaultTextColor(); + const { + charactersRemaining: nameCharactersRemaining, + error: nameError, + label: nameLabel, + onBlur: nameOnBlur, + onChange: nameOnChange, + required: isNameRequired, + reset: resetName, + value: nameValue, + validate: validateName, + } = useGenericInput({ + characterLimit: ACCOUNT_GROUP_NAME_BYTE_LIMIT, + label: t('labels.name'), + }); + const subTextColor = useSubTextColor(); + // handlers + const handleOnAddGroupSubmit = async () => { + if (nameValue.length <= 0 || !!validateName(nameValue)) { + return; + } + + // add the new group + await dispatch( + saveAccountGroupsThunk([ + AccountGroupRepository.initializeDefaultAccountGroup(nameValue), + ]) + ).unwrap(); + + // reset input + resetName(); + }; + const handleCancelClick = () => handleClose(); + const handleClose = () => { + // reset inputs + resetName(); + // close + onClose && onClose(); + }; + const handleOnKeyUp = async (event: KeyboardEvent) => { + if (event.key === 'Enter') { + await handleOnAddGroupSubmit(); + } + }; + const handleOnSelect = (groupID: string) => async () => { + let _account: IAccountWithExtendedProps | null; + let group: IAccountGroup | null; + + if (!account) { + return; + } + + // if it is the same group, just ignore + if (account.groupID === groupID) { + handleClose(); + + return; + } + + group = groups.find(({ id }) => id === groupID) || null; + + if (!group) { + return; + } + + _account = await dispatch( + addToGroupThunk({ + accountID: account.id, + groupID, + }) + ).unwrap(); + + if (_account && group) { + dispatch( + createNotification({ + description: t('captions.addedToGroup', { + group: group.name, + }), + ephemeral: true, + title: t('headings.accountUpdated'), + type: 'info', + }) + ); + } + + handleClose(); + }; + // renders + const renderGroupItems = () => { + if (groups.length <= 0) { + return ( + + + {t('captions.noGroupsAvailable')} + + + ); + } + + return ( + + {groups.map((value) => ( + + ))} + + ); + }; + + return ( + + + {/*header*/} + + + + {t('headings.selectGroup')} + + + {/*address*/} + {account && ( + + + {ellipseAddress( + convertPublicKeyToAVMAddress(account.publicKey), + { + end: 10, + start: 10, + } + )} + + + )} + + + + {/*body*/} + + + ('headings.addGroup')} /> + {/*add group*/} + ('placeholders.groupName')} + type="text" + validate={validateName} + value={nameValue} + /> + + ('headings.chooseGroup')} /> + + {/*choose group*/} + {renderGroupItems()} + + + + {/*footer*/} + + {/*cancel button*/} + + + + + ); +}; + +export default MoveGroupModal; diff --git a/src/extension/modals/MoveGroupModal/index.ts b/src/extension/modals/MoveGroupModal/index.ts new file mode 100644 index 00000000..8efee512 --- /dev/null +++ b/src/extension/modals/MoveGroupModal/index.ts @@ -0,0 +1 @@ +export { default } from './MoveGroupModal'; diff --git a/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx b/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx index 75b3897e..4757c5c4 100644 --- a/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx +++ b/src/extension/modals/WhatsNewModal/WhatsNewModal.tsx @@ -67,6 +67,7 @@ const WhatsNewModal: FC = ({ onClose }) => { const features = [ 'šŸ’… Change account icon.', 'šŸ’… Change account background color.', + 'šŸ—ƒļø Group accounts.', 'šŸ” Switch to new Voi testnet.', ]; const fixes: string[] = []; diff --git a/src/extension/pages/AccountPage/AccountPage.tsx b/src/extension/pages/AccountPage/AccountPage.tsx index bdb72976..01884a9c 100644 --- a/src/extension/pages/AccountPage/AccountPage.tsx +++ b/src/extension/pages/AccountPage/AccountPage.tsx @@ -16,6 +16,7 @@ import { import BigNumber from 'bignumber.js'; import React, { type FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { BsFolderMinus, BsFolderPlus } from 'react-icons/bs'; import { IoAdd, IoCloudOfflineOutline, @@ -58,16 +59,16 @@ import { AccountTabEnum } from '@extension/enums'; // features import { + removeFromGroupThunk, removeAccountByIdThunk, saveActiveAccountDetails, updateAccountsThunk, } from '@extension/features/accounts'; -import { setConfirmModal, setWhatsNewModal } from '@extension/features/layout'; +import { openConfirmModal, setWhatsNewModal } from '@extension/features/layout'; +import { openModal as openMoveGroupModal } from '@extension/features/move-group-modal'; +import { create as createNotification } from '@extension/features/notifications'; import { updateTransactionParamsForSelectedNetworkThunk } from '@extension/features/networks'; -import { - setAccountAndType as setReKeyAccount, - TReKeyType, -} from '@extension/features/re-key-account'; +import { setAccountAndType as setReKeyAccount } from '@extension/features/re-key-account'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; import { savePolisAccountIDThunk } from '@extension/features/system'; @@ -76,6 +77,9 @@ import useDefaultTextColor from '@extension/hooks/useDefaultTextColor'; import usePrimaryColorScheme from '@extension/hooks/usePrimaryColorScheme'; import useSubTextColor from '@extension/hooks/useSubTextColor'; +// icons +import BsFolderMove from '@extension/icons/BsFolderMove'; + // modals import EditAccountModal from '@extension/modals/EditAccountModal'; import ShareAddressModal from '@extension/modals/ShareAddressModal'; @@ -88,6 +92,7 @@ import { useSelectAccounts, useSelectActiveAccount, useSelectActiveAccountDetails, + useSelectActiveAccountGroup, useSelectActiveAccountInformation, useSelectActiveAccountTransactions, useSelectAccountsFetching, @@ -101,7 +106,9 @@ import { } from '@extension/selectors'; // types +import type { TReKeyType } from '@extension/features/re-key-account'; import type { + IAccountWithExtendedProps, IAppThunkDispatch, IMainRootState, INetwork, @@ -111,6 +118,8 @@ import type { import convertPublicKeyToAVMAddress from '@extension/utils/convertPublicKeyToAVMAddress'; import ellipseAddress from '@extension/utils/ellipseAddress'; import isReKeyedAuthAccountAvailable from '@extension/utils/isReKeyedAuthAccountAvailable'; +import { HiSave } from 'react-icons/hi'; +import GroupBadge from '@extension/components/GroupBadge'; const AccountPage: FC = () => { const { t } = useTranslation(); @@ -134,6 +143,7 @@ const AccountPage: FC = () => { const activeAccountDetails = useSelectActiveAccountDetails(); const fetchingAccounts = useSelectAccountsFetching(); const fetchingSettings = useSelectSettingsFetching(); + const group = useSelectActiveAccountGroup(); const online = useSelectIsOnline(); const network = useSelectSettingsSelectedNetwork(); const networks = useSelectNetworks(); @@ -189,6 +199,29 @@ const AccountPage: FC = () => { }) ); }; + const handleOnMoveGroupClick = () => + account && dispatch(openMoveGroupModal(account.id)); + const handleOnRemoveGroupClick = async () => { + let _account: IAccountWithExtendedProps | null; + + if (!account || !group) { + return; + } + + _account = await dispatch(removeFromGroupThunk(account.id)).unwrap(); + + if (!_account) { + return; + } + + dispatch( + createNotification({ + ephemeral: true, + title: t('headings.removedGroup'), + type: 'info', + }) + ); + }; const handleNetworkSelect = async (value: INetwork) => { await dispatch( saveSettingsToStorageThunk({ @@ -219,7 +252,7 @@ const AccountPage: FC = () => { const handleRemoveAccountClick = () => { if (account) { dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.removeAccount', { address: ellipseAddress( convertPublicKeyToAVMAddress( @@ -306,7 +339,7 @@ const AccountPage: FC = () => { {/*what's new*/} ('labels.whatsNew')}> ('labels.whatsNew')} + aria-label={t('ariaLabels.plusIcon')} icon={IoGiftOutline} onClick={handleOnWhatsNewClick} size="sm" @@ -433,6 +466,29 @@ const AccountPage: FC = () => { }, ] : []), + // add/remove to group + ...(group + ? [ + { + icon: BsFolderMove, + label: t('labels.moveGroup'), + onSelect: handleOnMoveGroupClick, + }, + { + icon: BsFolderMinus, + label: t('labels.removeFromGroup', { + name: group.name, + }), + onSelect: handleOnRemoveGroupClick, + }, + ] + : [ + { + icon: BsFolderPlus, + label: t('labels.addToGroup'), + onSelect: handleOnMoveGroupClick, + }, + ]), // re-key ...(canReKeyAccount() ? [ @@ -482,12 +538,22 @@ const AccountPage: FC = () => { )} + {/*group badge*/} + {group && } + + + {/*watch account badge*/} {renderWatchAccountBadge()} - - {/*re-keyed badge*/} - {renderReKeyedAccountBadge()} + {/*re-keyed badge*/} + {renderReKeyedAccountBadge()} + diff --git a/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx b/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx index 7b305ded..1589f367 100644 --- a/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx +++ b/src/extension/pages/CustomNodesPage/CustomNodesPage.tsx @@ -16,7 +16,7 @@ import ScrollableContainer from '@extension/components/ScrollableContainer'; import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { removeCustomNodeThunk } from '@extension/features/networks'; import { create as createNotification } from '@extension/features/notifications'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; @@ -125,7 +125,7 @@ const CustomNodesPage: FC = () => { } dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.removeCustomNodeConfirm', { name: item.name, }), diff --git a/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx b/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx index c0776400..0e5357e9 100644 --- a/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/src/extension/pages/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -13,7 +13,7 @@ import SettingsSubHeading from '@extension/components/SettingsSubHeading'; import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { sendFactoryResetThunk } from '@extension/features/messages'; import { saveToStorageThunk as saveSettingsToStorageThunk } from '@extension/features/settings'; @@ -59,7 +59,7 @@ const GeneralSettingsPage: FC = () => { // handlers const handleClearAllDataClick = () => dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.factoryResetModal'), onConfirm: () => dispatch(sendFactoryResetThunk()), // dispatch an event to the background title: t('headings.factoryReset'), diff --git a/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx b/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx index 114acd16..ea2e66a2 100644 --- a/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx +++ b/src/extension/pages/SessionsSettingsPage/SessionsSettingsPage.tsx @@ -17,7 +17,7 @@ import SettingsSessionItem, { import { DEFAULT_GAP } from '@extension/constants'; // features -import { setConfirmModal } from '@extension/features/layout'; +import { openConfirmModal } from '@extension/features/layout'; import { removeAllFromStorageThunk as removeAllSessionsFromStorageThunk, removeByIdFromStorageThunk as removeSessionByIdFromStorageThunk, @@ -62,7 +62,7 @@ const SessionsSettingsPage: FC = () => { setSession(sessions.find((value) => value.id === id) || null); const handleDisconnectAllSessionsClick = () => dispatch( - setConfirmModal({ + openConfirmModal({ description: t('captions.disconnectAllSessions'), onConfirm: () => dispatch(removeAllSessionsFromStorageThunk()), title: t('headings.disconnectAllSessions'), diff --git a/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts new file mode 100644 index 00000000..f39743d8 --- /dev/null +++ b/src/extension/repositories/AccountGroupRepository/AccountGroupRepository.ts @@ -0,0 +1,133 @@ +import { v4 as uuid } from 'uuid'; + +// constants +import { ACCOUNT_GROUPS_ITEM_KEY } from '@extension/constants'; + +// enums +import { DelimiterEnum } from '@extension/enums'; + +// repositories +import BaseRepository from '@extension/repositories/BaseRepository'; + +// types +import type { IAccount, IAccountGroup } from '@extension/types'; + +// utils +import upsertItemsById from '@extension/utils/upsertItemsById'; + +export default class AccountGroupRepository extends BaseRepository { + /** + * public static functions + */ + + public static initializeDefaultAccountGroup(name: string): IAccountGroup { + return { + _delimiter: DelimiterEnum.Group, + createdAt: new Date().getTime(), + id: uuid(), + index: null, + name, + }; + } + + /** + * Convenience function to count the number of accounts that belong to a group. + * @param {string} groupID - The group ID to check. + * @param {IAccount[]} accounts - A list of accounts to check through. + * @returns {number} The number of accounts that belong to a group. + * @public + * @static + */ + public static numberOfAccountsInGroup( + groupID: string, + accounts: IAccount[] + ): number { + return accounts.filter( + (value) => !!value.groupID && value.groupID === groupID + ).length; + } + + /** + * private functions + */ + + private _sanitize(group: IAccountGroup): IAccountGroup { + return { + _delimiter: DelimiterEnum.Group, + createdAt: group.createdAt, + id: group.id, + index: typeof group.index === 'number' ? group.index : null, // if 0, this is "falsy" in the js world, so let's be specific + name: group.name, + }; + } + + /** + * public functions + */ + + /** + * Fetches the account groups from storage. + * @returns {Promise} A promise that resolves to the account groups. + * @public + */ + public async fetchAll(): Promise { + const items = await this._fetchByKey( + ACCOUNT_GROUPS_ITEM_KEY + ); + + if (!items) { + return []; + } + + return items.map(this._sanitize); + } + + /** + * Removes a group by its ID. + * @param {string} id - the group ID. + * @public + */ + public async removeByID(id: string): Promise { + const items = await this.fetchAll(); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: items.filter((value) => value.id !== id), + }); + } + + /** + * Saves the account group to storage. + * @param {IAccountGroup} value - The account group to upsert. + * @returns {Promise} A promise that resolves to the account group. + * @public + */ + public async save(value: IAccountGroup): Promise { + let items = await this.fetchAll(); + + items = upsertItemsById(items, [value]); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: items.map(this._sanitize), + }); + + return value; + } + + /** + * Saves a list of account groups to storage. + * @param {IAccountGroup[]} items - The account groups to upsert. + * @returns {Promise} A promise that resolves to the account groups. + * @public + */ + public async saveMany(items: IAccountGroup[]): Promise { + let _items = await this.fetchAll(); + + _items = upsertItemsById(_items, items); + + await this._save({ + [ACCOUNT_GROUPS_ITEM_KEY]: _items.map(this._sanitize), + }); + + return items; + } +} diff --git a/src/extension/repositories/AccountGroupRepository/index.ts b/src/extension/repositories/AccountGroupRepository/index.ts new file mode 100644 index 00000000..bc863901 --- /dev/null +++ b/src/extension/repositories/AccountGroupRepository/index.ts @@ -0,0 +1 @@ +export { default } from './AccountGroupRepository'; diff --git a/src/extension/repositories/AccountRepository/AccountRepository.test.ts b/src/extension/repositories/AccountRepository/AccountRepository.test.ts deleted file mode 100644 index 5f7ce301..00000000 --- a/src/extension/repositories/AccountRepository/AccountRepository.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { randomBytes } from 'tweetnacl'; - -// repositories -import AccountRepository from './AccountRepository'; - -// types -import type { IAccount } from '@extension/types'; - -interface ISortTestParams { - accounts: IAccount[]; - expectedIDs: string[]; - name: string; -} - -describe(AccountRepository.name, () => { - const now = new Date(); - - describe(`${AccountRepository.name}#sort()`, () => { - it.each([ - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 2, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 3, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by position', - }, - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 2), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by createdAt date', - }, - { - accounts: [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ], - expectedIDs: ['0', '1', '2', '3'], - name: 'should sort by a mix of position and createdAt date', - }, - ])(`$name`, ({ accounts, expectedIDs }) => { - expect(AccountRepository.sort(accounts).map(({ id }) => id)).toEqual( - expectedIDs - ); - }); - - it('should apply positions to null-indexed elements', () => { - // arrange - const items: IAccount[] = [ - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: now.getTime(), - id: '2', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '3', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 3), - id: '0', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 0, - }, - { - ...AccountRepository.initializeDefaultAccount({ - createdAt: new Date(now).setDate(now.getDate() + 1), - id: '1', - publicKey: AccountRepository.encode(randomBytes(32)), - }), - index: 1, - }, - ]; - // act - const result: IAccount[] = AccountRepository.sort(items, { - mutateIndex: true, - }); - - // assert - expect(result.map(({ id, index }) => [id, index])).toEqual([ - ['0', 0], - ['1', 1], - ['2', 2], - ['3', 3], - ]); - }); - }); -}); diff --git a/src/extension/repositories/AccountRepository/AccountRepository.ts b/src/extension/repositories/AccountRepository/AccountRepository.ts index 700bf4b1..ea3b67d8 100644 --- a/src/extension/repositories/AccountRepository/AccountRepository.ts +++ b/src/extension/repositories/AccountRepository/AccountRepository.ts @@ -7,7 +7,7 @@ import { networks } from '@extension/config'; import { ACCOUNTS_ITEM_KEY_PREFIX } from '@extension/constants'; // enums -import { AssetTypeEnum } from '@extension/enums'; +import { AssetTypeEnum, DelimiterEnum } from '@extension/enums'; // repositories import BaseRepository from '@extension/repositories/BaseRepository'; @@ -17,13 +17,15 @@ import type { IAccount, IAccountInformation, IAccountTransactions, + IAccountWithExtendedProps, IInitializeAccountOptions, INetwork, } from '@extension/types'; -import { ISaveOptions, ISortOptions } from './types'; +import type { ISaveOptions } from './types'; // utils import convertGenesisHashToHex from '@extension/utils/convertGenesisHashToHex'; +import sortByIndex from '@extension/utils/sortByIndex'; export default class AccountRepository extends BaseRepository { /** @@ -82,8 +84,11 @@ export default class AccountRepository extends BaseRepository { const createdAtOrNow: number = createdAt || new Date().getTime(); return { + _delimiter: DelimiterEnum.Account, color: null, createdAt: createdAtOrNow, + groupID: null, + groupIndex: null, icon: null, id: id || uuid(), name: name || null, @@ -131,47 +136,35 @@ export default class AccountRepository extends BaseRepository { } /** - * Sorts the accounts by the `index` property, where lower indexes take precedence. If `index` is null they are put + * Sorts a list by the `groupIndex` property, where lower indexes take precedence. If `groupIndex` is null they are put * to the back and sorted by the `createdAt` property, ascending order (oldest first). - * @param {Type extends IAccount[]} items - The accounts to sort. - * @param {ISortOptions} options - [optional] applies indexes on accounts that do not have indexes. - * @returns {Type extends IAccount[]} the sorted accounts. + * @param {IAccountWithExtendedProps[]} items - The items to sort. + * @returns {IAccountWithExtendedProps[]} the sorted items. * @public * @static */ - public static sort( - items: Type[], - { mutateIndex }: ISortOptions = { mutateIndex: false } - ): Type[] { - const _items = items.sort((a, b) => { + public static sortByGroupIndex( + items: IAccountWithExtendedProps[] + ): IAccountWithExtendedProps[] { + return items.sort((a, b) => { // if both positions are non-null, sort by position - if (a.index !== null && b.index !== null) { - return a.index - b.index; + if (a.groupIndex !== null && b.groupIndex !== null) { + return a.groupIndex - b.groupIndex; } // if `a` position is null, place it after a `b` non-null position - if (a.index === null && b.index !== null) { + if (a.groupIndex === null && b.groupIndex !== null) { return 1; // `a` comes after `b` } // if `b` position is null, place it after a `a` non-null position - if (a.index !== null && b.index === null) { + if (a.groupIndex !== null && b.groupIndex === null) { return -1; // `a` comes before `b` } // if both positions are null, sort by `createdat` (ascending) return a.createdAt - b.createdAt; }); - - if (!mutateIndex) { - return _items; - } - - // apply the positions to the - return _items.map((value, index) => ({ - ...value, - index, - })); } /** @@ -195,8 +188,12 @@ export default class AccountRepository extends BaseRepository { */ private _sanitize(account: IAccount): IAccount { return { + _delimiter: DelimiterEnum.Account, color: account.color, createdAt: account.createdAt, + groupID: account.groupID, + groupIndex: + typeof account.groupIndex === 'number' ? account.groupIndex : null, // if 0, this is "falsy" in the js world, so let's be specific icon: account.icon, id: account.id, name: account.name, @@ -222,7 +219,7 @@ export default class AccountRepository extends BaseRepository { }), {} ), - index: typeof account.index === 'number' ? account.index : null, + index: typeof account.index === 'number' ? account.index : null, // if 0, this is "falsy" in the js world, so let's be specific publicKey: account.publicKey, updatedAt: account.updatedAt, }; @@ -279,6 +276,7 @@ export default class AccountRepository extends BaseRepository { accounts = accounts.map((account) => ({ ...account, + _delimiter: DelimiterEnum.Account, // if there are new networks in the config, create default account information and transactions for these new networks networkInformation: networks.reduce>( (acc, { genesisHash }) => { @@ -330,7 +328,7 @@ export default class AccountRepository extends BaseRepository { }, {}), })); - return AccountRepository.sort(accounts); + return sortByIndex(accounts); } /** diff --git a/src/extension/repositories/AccountRepository/types/ISortOptions.ts b/src/extension/repositories/AccountRepository/types/ISortOptions.ts deleted file mode 100644 index dcc5ef39..00000000 --- a/src/extension/repositories/AccountRepository/types/ISortOptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface ISortOptions { - mutateIndex?: boolean; -} - -export default ISortOptions; diff --git a/src/extension/repositories/AccountRepository/types/index.ts b/src/extension/repositories/AccountRepository/types/index.ts index b0368256..24398c58 100644 --- a/src/extension/repositories/AccountRepository/types/index.ts +++ b/src/extension/repositories/AccountRepository/types/index.ts @@ -1,2 +1 @@ export type { default as ISaveOptions } from './ISaveOptions'; -export type { default as ISortOptions } from './ISortOptions'; diff --git a/src/extension/selectors/accounts/index.ts b/src/extension/selectors/accounts/index.ts index 58a9444c..3df79a1f 100644 --- a/src/extension/selectors/accounts/index.ts +++ b/src/extension/selectors/accounts/index.ts @@ -1,10 +1,12 @@ export { default as useSelectAccountByAddress } from './useSelectAccountByAddress'; export { default as useSelectAccountById } from './useSelectAccountById'; +export { default as useSelectAccountGroups } from './useSelectAccountGroups'; export { default as useSelectAccountsFetching } from './useSelectAccountsFetching'; export { default as useSelectAccounts } from './useSelectAccounts'; export { default as useSelectAccountsSaving } from './useSelectAccountsSaving'; export { default as useSelectActiveAccount } from './useSelectActiveAccount'; export { default as useSelectActiveAccountDetails } from './useSelectActiveAccountDetails'; +export { default as useSelectActiveAccountGroup } from './useSelectActiveAccountGroup'; export { default as useSelectActiveAccountInformation } from './useSelectActiveAccountInformation'; export { default as useSelectActiveAccountTransactions } from './useSelectActiveAccountTransactions'; export { default as useSelectActiveAccountTransactionsUpdating } from './useSelectActiveAccountTransactionsUpdating'; diff --git a/src/extension/selectors/accounts/useSelectAccountGroups.ts b/src/extension/selectors/accounts/useSelectAccountGroups.ts new file mode 100644 index 00000000..554212f4 --- /dev/null +++ b/src/extension/selectors/accounts/useSelectAccountGroups.ts @@ -0,0 +1,14 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IAccountGroup, IMainRootState } from '@extension/types'; + +/** + * Selects all account groups. + * @returns {IAccountGroup[]} All account groups. + */ +export default function useSelectAccounts(): IAccountGroup[] { + return useSelector( + (state) => state.accounts.groups + ); +} diff --git a/src/extension/selectors/accounts/useSelectAccountsSaving.ts b/src/extension/selectors/accounts/useSelectAccountsSaving.ts index 1667fbcc..5afb25e9 100644 --- a/src/extension/selectors/accounts/useSelectAccountsSaving.ts +++ b/src/extension/selectors/accounts/useSelectAccountsSaving.ts @@ -2,6 +2,7 @@ import { useSelector } from 'react-redux'; // types import { IMainRootState } from '@extension/types'; + export default function useSelectAccountsSaving(): boolean { return useSelector((state) => state.accounts.saving); } diff --git a/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts new file mode 100644 index 00000000..d81f183c --- /dev/null +++ b/src/extension/selectors/accounts/useSelectActiveAccountGroup.ts @@ -0,0 +1,21 @@ +// selectors +import useSelectActiveAccount from './useSelectActiveAccount'; +import useSelectAccountGroups from './useSelectAccountGroups'; + +// types +import type { IAccountGroup } from '@extension/types'; + +/** + * Selects the active account group, or null if it doesn't exist. + * @returns {IAccountGroup | null} The active account group, or null. + */ +export default function useSelectActiveAccountGroup(): IAccountGroup | null { + const account = useSelectActiveAccount(); + const groups = useSelectAccountGroups(); + + if (!account || !account.groupID) { + return null; + } + + return groups.find(({ id }) => id === account.groupID) || null; +} diff --git a/src/extension/selectors/index.ts b/src/extension/selectors/index.ts index 2b5d4649..7c186fc8 100644 --- a/src/extension/selectors/index.ts +++ b/src/extension/selectors/index.ts @@ -5,7 +5,9 @@ export * from './arc-0200-assets'; export * from './credential-lock'; export * from './events'; export * from './layout'; +export * from './manage-groups-modal'; export * from './misc'; +export * from './move-group-modal'; export * from './networks'; export * from './notifications'; export * from './passkeys'; diff --git a/src/extension/selectors/manage-groups-modal/index.ts b/src/extension/selectors/manage-groups-modal/index.ts new file mode 100644 index 00000000..b599c12f --- /dev/null +++ b/src/extension/selectors/manage-groups-modal/index.ts @@ -0,0 +1 @@ +export { default as useSelectManageGroupsModalIsOpen } from './useSelectManageGroupsModalIsOpen'; diff --git a/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts b/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts new file mode 100644 index 00000000..20ca9e70 --- /dev/null +++ b/src/extension/selectors/manage-groups-modal/useSelectManageGroupsModalIsOpen.ts @@ -0,0 +1,10 @@ +import { useSelector } from 'react-redux'; + +// types +import type { IMainRootState } from '@extension/types'; + +export default function useSelectManageGroupsModalIsOpen(): boolean { + return useSelector( + (state) => state.manageGroupsModal.isOpen + ); +} diff --git a/src/extension/selectors/move-group-modal/index.ts b/src/extension/selectors/move-group-modal/index.ts new file mode 100644 index 00000000..c0a5be41 --- /dev/null +++ b/src/extension/selectors/move-group-modal/index.ts @@ -0,0 +1 @@ +export { default as useSelectMoveGroupModalAccount } from './useSelectMoveGroupModalAccount'; diff --git a/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts b/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts new file mode 100644 index 00000000..6d1338a3 --- /dev/null +++ b/src/extension/selectors/move-group-modal/useSelectMoveGroupModalAccount.ts @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux'; + +// types +import type { + IAccountWithExtendedProps, + IMainRootState, +} from '@extension/types'; + +export default function useSelectMoveGroupModalAccount(): IAccountWithExtendedProps | null { + return useSelector( + (state) => + state.moveGroupModal.accountID + ? state.accounts.items.find( + (value) => value.id === state.moveGroupModal.accountID + ) || null + : null + ); +} diff --git a/src/extension/translations/en.ts b/src/extension/translations/en.ts index 47d888d6..f69ac570 100644 --- a/src/extension/translations/en.ts +++ b/src/extension/translations/en.ts @@ -5,6 +5,15 @@ import { AssetTypeEnum, TransactionTypeEnum } from '@extension/enums'; import { IResourceLanguage } from '@extension/types'; const translation: IResourceLanguage = { + ariaLabels: { + checkIcon: 'A checkmark icon.', + crossIcon: 'A cross icon.', + deleteIcon: 'A trash can icon.', + forwardArrow: 'A forward arrow.', + informationIcon: 'An "i" icon for information.', + pencilIcon: 'A pencil icon.', + plusIcon: 'A plus icon.', + }, buttons: { add: 'Add', addAccount: 'Add Account', @@ -23,6 +32,7 @@ const translation: IResourceLanguage = { create: 'Create', disconnectAllSessions: 'Disconnect All Sessions', dismiss: 'Dismiss', + done: 'Done', encrypt: 'Encrypt', getStarted: 'Get Started', hide: 'Hide', @@ -46,6 +56,7 @@ const translation: IResourceLanguage = { selectReceiverAccount: 'Select Receiver Account', send: 'Send', sign: 'Sign', + submit: 'Submit', undo: 'Undo', unlock: 'Unlock', view: 'View', @@ -65,6 +76,7 @@ const translation: IResourceLanguage = { 'You are about to add the following asset. Select which account your would like to add the asset to.', addedAccount: 'Account {{address}} has been added.', addedAccounts: 'Added {{amount}} accounts.', + addedToGroup: 'Added to "{{group}}" group.', addPasskey1: 'Adding a passkey allows you to sign transactions without your password.', addPasskey2: `The passkey will be used to to encrypt/decrypt the private keys of your accounts.`, @@ -201,6 +213,7 @@ const translation: IResourceLanguage = { noAssetsFound: 'You have not added any assets. Try adding one now.', noBlockExplorersAvailable: 'No block explorers available', noFontsAvailable: 'No fonts available', + noGroupsAvailable: 'No groups available', noNFTExplorersAvailable: 'No NFT explorers available', noNFTsFound: `You don't have any NFTs.`, noSessionsFound: 'Enabled dApps will appear here.', @@ -246,7 +259,13 @@ const translation: IResourceLanguage = { removeCredentialLock_password: 'You will need to enter your password to unlock.', removeCustomNodeConfirm: 'Are you sure you want to remove "{{name}}"?', + removedFromGroupConfirm: + 'Are you sure you want to remove account "{{account}}" from "{{group}}" group?', removedCustomNode: 'The custom node {{name}} has been removed.', + removeGroupConfirm: + 'This group contains {{numberOfAccounts}} accounts. Are you sure you want to remove "{{group}}"?', + removeGroupConfirmWarning: + 'This action will ONLY remove the group your accounts will remain.', removePasskey: 'You are about to remove the passkey "{{name}}". This action will re-enable password authentication.', removePasskeyInstruction1: @@ -339,6 +358,7 @@ const translation: IResourceLanguage = { addedAccount: 'Added Account', addedAccounts: 'Added Account(s)', addedAsset: 'Added Asset {{symbol}}!', + addGroup: 'Add Group', addPasskey: 'Add Passkey', addWatchAccount: 'Add A Watch Account', algodDetails: 'Algod Details', @@ -350,6 +370,7 @@ const translation: IResourceLanguage = { beta: 'Beta', cameraDenied: 'Camera Denied', cameraLoading: 'Camera Loading', + chooseGroup: 'Choose Group', comingSoon: 'Coming Soon!', confirm: 'Confirm', congratulations: 'Congratulations!', @@ -361,6 +382,7 @@ const translation: IResourceLanguage = { developer: 'Developer', disconnectAllSessions: 'Disconnect All Sessions', editAccount: 'Edit Account', + editGroups: 'Edit Groups', enterAnAddress: 'Enter an address', enterYourSeedPhrase: 'Enter your seed phrase', experimental: 'Experimental', @@ -373,6 +395,7 @@ const translation: IResourceLanguage = { importAccountViaQRCode: 'Import An Account Via A QR Code', importAccountViaSeedPhrase: 'Import An Account Via Seed Phrase', indexerDetails: 'Indexer Details', + manageGroups: 'Manage Groups', nameYourAccount: 'Name your account', network: 'Network', networks: 'Networks', @@ -402,6 +425,9 @@ const translation: IResourceLanguage = { [`removedAsset_${AssetTypeEnum.ARC0200}`]: 'Asset {{symbol}} Hidden!', removeCustomNode: 'Remove Custom Node', removedCustomNode: 'Removed Custom Node', + removedFromGroupConfirm: 'Remove From Group', + removedGroup: 'Removed Group', + removeGroup: 'Remove Group', removePasskey: 'Remove Passkey', scanQrCode: 'Scan QR Code(s)', selectAccount: 'Select Account', @@ -411,6 +437,7 @@ const translation: IResourceLanguage = { selectANetwork: 'Select A Network', selectAnOption: 'Select An Option', selectColor: 'Select Color', + selectGroup: 'Select Group', selectIcon: 'Select Icon', selectReceiverAccount: 'Select Receiver Account', selectSenderAccount: 'Select Sender Account', @@ -470,11 +497,13 @@ const translation: IResourceLanguage = { activity: 'Activity', address: 'Address', addressToSign: 'Address To Sign', + addToGroup: 'Add To Group', advanced: 'Advanced', accountName: 'Account Name', accountToFreeze: 'Account To Freeze', accountToUnfreeze: 'Account To Unfreeze', addAccount: 'Add Account', + addGroup: 'Add Group', addMaximumAmount: 'Add Maximum Amount', algorithm: 'Algorithm', allowActionTracking: 'Allow certain actions to be tracked?', @@ -533,6 +562,7 @@ const translation: IResourceLanguage = { disabled: 'Disabled', disconnect: 'Disconnect', editAccount: 'Edit Account', + editGroup: 'Edit Group', enableCredentialsLock: 'Enable Credential Lock?', enabled: 'Enabled', experimental: 'Experimental', @@ -562,9 +592,11 @@ const translation: IResourceLanguage = { makePrimary: 'Make Primary', manage: 'Manage', managerAccount: 'Manager Account', + manageGroups: 'Manage Groups', max: 'Max', message: 'Message', moreInformation: 'More Information', + moveGroup: 'Move Group', name: 'Name', network: 'Network', newPassword: 'New Password', @@ -595,6 +627,7 @@ const translation: IResourceLanguage = { removeAsset: 'Remove Asset', [`removeAsset_${AssetTypeEnum.ARC0200}`]: 'Hide Asset', removedAccount: 'Removed Account', + removeFromGroup: 'Remove From "{{name}}" Group', removeSession: 'Remove Session', reserveAccount: 'Reserve Account', resetSeedPhrase: 'Reset Seed Phrase', @@ -643,6 +676,7 @@ const translation: IResourceLanguage = { enterAddress: 'Enter address', enterNote: 'Enter an optional note', enterPassword: 'Enter password', + groupName: 'e.g. Work', nameAccount: 'Enter a name for this account', passkeyName: 'e.g. Kibisis', pleaseSelect: 'Please select...', diff --git a/src/extension/types/accounts/IAccount.ts b/src/extension/types/accounts/IAccount.ts index 9ab518d8..b9c953f0 100644 --- a/src/extension/types/accounts/IAccount.ts +++ b/src/extension/types/accounts/IAccount.ts @@ -1,26 +1,34 @@ +// enums +import { DelimiterEnum } from '@extension/enums'; + // types -import IAccountInformation from './IAccountInformation'; -import IAccountTransactions from './IAccountTransactions'; -import TAccountColors from './TAccountColors'; -import TAccountIcons from './TAccountIcons'; +import type IAccountInformation from './IAccountInformation'; +import type IAccountTransactions from './IAccountTransactions'; +import type TAccountColors from './TAccountColors'; +import type TAccountIcons from './TAccountIcons'; /** * @property {TAccountColors | null} color - The background color. - * @property {number} createdAt - a timestamp (in milliseconds) when this account was created in storage. + * @property {number} createdAt - A timestamp (in milliseconds) when this account was created in storage. * @property {TAccountIcons | null} icon - An icon for the account. - * @property {string} id - a unique identifier (in UUID). - * @property {number | null} index - the position of the account as it appears in a list. - * @property {string | null} name - a canonical name given to this account. - * @property {Record} networkInformation - information specific for each network, indexed by + * @property {string | null} groupID - The ID of the group this account belongs to. + * @property {number | null} groupIndex - The index of where this item belongs in the group. + * @property {string} id - A unique identifier (in UUIDv4). + * @property {number | null} index - The position of the account as it appears in a list. + * @property {string | null} name - A canonical name given to this account. + * @property {Record} networkInformation - Information specific for each network, indexed by * their hex encoded genesis hash. - * @property {Record} networkInformation - transactions specific for each network, indexed + * @property {Record} networkInformation - Transactions specific for each network, indexed * by their hex encoded genesis hash. - * @property {string} publicKey - the hexadecimal encoded public key. - * @property {number} updatedAt - a timestamp (in milliseconds) for when this account was last saved to storage. + * @property {string} publicKey - The hexadecimal encoded public key. + * @property {number} updatedAt - A timestamp (in milliseconds) for when this account was last saved to storage. */ interface IAccount { + _delimiter: DelimiterEnum.Account; color: TAccountColors | null; createdAt: number; + groupID: string | null; + groupIndex: number | null; icon: TAccountIcons | null; id: string; index: number | null; diff --git a/src/extension/types/accounts/IAccountGroup.ts b/src/extension/types/accounts/IAccountGroup.ts new file mode 100644 index 00000000..af0d2e8f --- /dev/null +++ b/src/extension/types/accounts/IAccountGroup.ts @@ -0,0 +1,18 @@ +// enums +import { DelimiterEnum } from '@extension/enums'; + +/** + * @property {number} createdAt - a timestamp (in milliseconds) when this account was created in storage. + * @property {string} id - a unique identifier (in UUIDv4 format). + * @property {number | null} index - The position of the group as it appears in a list. + * @property {string} name - The name of the group. Limited to 32 bytes. + */ +interface IAccountGroup { + _delimiter: DelimiterEnum.Group; + createdAt: number; + id: string; + index: number | null; + name: string; +} + +export default IAccountGroup; diff --git a/src/extension/types/accounts/index.ts b/src/extension/types/accounts/index.ts index d483f4cc..2e0d88cc 100644 --- a/src/extension/types/accounts/index.ts +++ b/src/extension/types/accounts/index.ts @@ -1,4 +1,5 @@ export type { default as IAccount } from './IAccount'; +export type { default as IAccountGroup } from './IAccountGroup'; export type { default as IAccountInformation } from './IAccountInformation'; export type { default as IAccountTransactions } from './IAccountTransactions'; export type { default as IAccountWithExtendedProps } from './IAccountWithExtendedProps'; diff --git a/src/extension/types/i18n/IResourceLanguage.ts b/src/extension/types/i18n/IResourceLanguage.ts index 913ed3ad..ec6d6dc8 100644 --- a/src/extension/types/i18n/IResourceLanguage.ts +++ b/src/extension/types/i18n/IResourceLanguage.ts @@ -1,4 +1,5 @@ interface IResourceLanguage { + ariaLabels: Record; buttons: Record; captions: Record; errors: { diff --git a/src/extension/types/states/IMainRootState.ts b/src/extension/types/states/IMainRootState.ts index 66431efd..3e95586a 100644 --- a/src/extension/types/states/IMainRootState.ts +++ b/src/extension/types/states/IMainRootState.ts @@ -4,6 +4,8 @@ import type { IState as IAddAssetsState } from '@extension/features/add-assets'; import type { IState as IARC0072AssetsState } from '@extension/features/arc0072-assets'; import type { IState as ICredentialLockState } from '@extension/features/credential-lock'; import type { IState as IEventsState } from '@extension/features/events'; +import type { IState as IManageGroupsModalState } from '@extension/features/manage-groups-modal'; +import type { IState as IMoveGroupModalState } from '@extension/features/move-group-modal'; import type { IState as INetworksState } from '@extension/features/networks'; import type { IState as INotificationsState } from '@extension/features/notifications'; import type { IState as IPasskeysState } from '@extension/features/passkeys'; @@ -23,6 +25,8 @@ interface IMainRootState extends IBaseRootState { arc0072Assets: IARC0072AssetsState; credentialLock: ICredentialLockState; events: IEventsState; + manageGroupsModal: IManageGroupsModalState; + moveGroupModal: IMoveGroupModalState; networks: INetworksState; notifications: INotificationsState; passkeys: IPasskeysState; diff --git a/src/extension/types/storage/TStorageItemTypes.ts b/src/extension/types/storage/TStorageItemTypes.ts index 09dc8864..5afb37a5 100644 --- a/src/extension/types/storage/TStorageItemTypes.ts +++ b/src/extension/types/storage/TStorageItemTypes.ts @@ -1,6 +1,10 @@ // types import type { ISerializableNetworkWithTransactionParams } from '@extension/repositories/NetworksRepository'; -import type { IAccount, IActiveAccountDetails } from '../accounts'; +import type { + IAccount, + IAccountGroup, + IActiveAccountDetails, +} from '../accounts'; import type { IARC0072Asset, IARC0200Asset, IStandardAsset } from '../assets'; import type { IPasskeyCredential, @@ -21,6 +25,7 @@ import type { ISystemInfo } from '../system'; type TStorageItemTypes = | IAccount + | IAccountGroup[] | IActiveAccountDetails | IAdvancedSettings | IAppearanceSettings diff --git a/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts b/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts new file mode 100644 index 00000000..303d7bd6 --- /dev/null +++ b/src/extension/utils/convertToKebabCase/convertToKebabCase.test.ts @@ -0,0 +1,38 @@ +// utils +import convertToKebabCase from './convertToKebabCase'; + +interface ITestParams { + expected: string; + input: string; +} + +describe('convertToKebabCase()', () => { + it.each([ + { + expected: 'lowercase', + input: 'lowercase', + }, + { + expected: 'capital', + input: 'Capital', + }, + { + expected: 'pascal-case', + input: 'PascalCase', + }, + { + expected: 'title-case', + input: 'Title Case', + }, + { + expected: 'cases-with-apostrophe', + input: `Cases' with apostrophe`, + }, + { + expected: 'special-symbols', + input: 'Special symbols!!!', + }, + ])(`should convert "$input" to "$expected"`, ({ expected, input }) => { + expect(convertToKebabCase(input)).toBe(expected); + }); +}); diff --git a/src/extension/utils/convertToKebabCase/convertToKebabCase.ts b/src/extension/utils/convertToKebabCase/convertToKebabCase.ts new file mode 100644 index 00000000..1df44263 --- /dev/null +++ b/src/extension/utils/convertToKebabCase/convertToKebabCase.ts @@ -0,0 +1,12 @@ +/** + * Convenience function that converts a string value to kebab-case. + * @param {string} value - a string to convert. + * @returns {string} the converted value to kebab-case. + */ +export default function convertToKebabCase(value: string): string { + return value + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_',"!?<>Ā£$%^&*()+={}]+/g, '-') // replace whitespace and ',"!?<>Ā£$%^&*()+={} with a hyphen "-" + .replace(/^-+|-+$/g, '') // trim any hyphens from the beginning and end of the string + .toLowerCase(); +} diff --git a/src/extension/utils/convertToKebabCase/index.ts b/src/extension/utils/convertToKebabCase/index.ts new file mode 100644 index 00000000..b68b8d3c --- /dev/null +++ b/src/extension/utils/convertToKebabCase/index.ts @@ -0,0 +1 @@ +export { default } from './convertToKebabCase'; diff --git a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts index 2b2cda4e..73c51281 100644 --- a/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts +++ b/src/extension/utils/mapAccountWithExtendedPropsToAccount/mapAccountWithExtendedPropsToAccount.ts @@ -1,4 +1,7 @@ -// +// enums +import { DelimiterEnum } from '@extension/enums'; + +// types import type { IAccount, IAccountWithExtendedProps } from '@extension/types'; /** @@ -10,6 +13,8 @@ import type { IAccount, IAccountWithExtendedProps } from '@extension/types'; export default function mapAccountWithExtendedPropsToAccount({ color, createdAt, + groupID, + groupIndex, icon, id, name, @@ -20,8 +25,11 @@ export default function mapAccountWithExtendedPropsToAccount({ updatedAt, }: IAccountWithExtendedProps): IAccount { return { + _delimiter: DelimiterEnum.Account, color, createdAt, + groupID, + groupIndex, icon, id, name, diff --git a/src/extension/utils/sortByIndex/index.ts b/src/extension/utils/sortByIndex/index.ts new file mode 100644 index 00000000..37b60a1b --- /dev/null +++ b/src/extension/utils/sortByIndex/index.ts @@ -0,0 +1 @@ +export { default } from './sortByIndex'; diff --git a/src/extension/utils/sortByIndex/sortByIndex.test.ts b/src/extension/utils/sortByIndex/sortByIndex.test.ts new file mode 100644 index 00000000..220c9e96 --- /dev/null +++ b/src/extension/utils/sortByIndex/sortByIndex.test.ts @@ -0,0 +1,177 @@ +import { randomBytes } from 'tweetnacl'; + +// repositories +import AccountRepository from '@extension/repositories/AccountRepository'; + +// types +import type { IAccount } from '@extension/types'; + +// utils +import sortByIndex from './sortByIndex'; + +interface ITestParams { + accounts: IAccount[]; + expectedIDs: string[]; + name: string; +} + +describe(`${__dirname}/sortByIndex`, () => { + const now = new Date(); + + it.each([ + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 2, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 3, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by position', + }, + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 2), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by createdAt date', + }, + { + accounts: [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ], + expectedIDs: ['0', '1', '2', '3'], + name: 'should sort by a mix of position and createdAt date', + }, + ])(`$name`, ({ accounts, expectedIDs }) => { + expect(sortByIndex(accounts).map(({ id }) => id)).toEqual(expectedIDs); + }); + + it('should apply positions to null-indexed elements', () => { + // arrange + const items: IAccount[] = [ + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: now.getTime(), + id: '2', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '3', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 3), + id: '0', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 0, + }, + { + ...AccountRepository.initializeDefaultAccount({ + createdAt: new Date(now).setDate(now.getDate() + 1), + id: '1', + publicKey: AccountRepository.encode(randomBytes(32)), + }), + index: 1, + }, + ]; + // act + const result: IAccount[] = sortByIndex(items, { + mutateIndex: true, + }); + + // assert + expect(result.map(({ id, index }) => [id, index])).toEqual([ + ['0', 0], + ['1', 1], + ['2', 2], + ['3', 3], + ]); + }); +}); diff --git a/src/extension/utils/sortByIndex/sortByIndex.ts b/src/extension/utils/sortByIndex/sortByIndex.ts new file mode 100644 index 00000000..00abeac6 --- /dev/null +++ b/src/extension/utils/sortByIndex/sortByIndex.ts @@ -0,0 +1,44 @@ +// types +import type { IOptions, IType } from './types'; + +/** + * Sorts a list by the `index` property, where lower indexes take precedence. If `index` is null they are put + * to the back and sorted by the `createdAt` property, ascending order (oldest first). + * @param {Type extends IType[]} items - The items to sort. + * @param {IOptions} options - [optional] applies indexes on items that do not have indexes. + * @returns {Type extends IType[]} the sorted items. + */ +export default function sortByIndex( + items: Type[], + { mutateIndex }: IOptions = { mutateIndex: false } +): Type[] { + const _items = items.sort((a, b) => { + // if both positions are non-null, sort by position + if (a.index !== null && b.index !== null) { + return a.index - b.index; + } + + // if `a` position is null, place it after a `b` non-null position + if (a.index === null && b.index !== null) { + return 1; // `a` comes after `b` + } + + // if `b` position is null, place it after a `a` non-null position + if (a.index !== null && b.index === null) { + return -1; // `a` comes before `b` + } + + // if both positions are null, sort by `createdat` (ascending) + return a.createdAt - b.createdAt; + }); + + if (!mutateIndex) { + return _items; + } + + // apply the positions to the list + return _items.map((value, index) => ({ + ...value, + index, + })); +} diff --git a/src/extension/utils/sortByIndex/types/IOptions.ts b/src/extension/utils/sortByIndex/types/IOptions.ts new file mode 100644 index 00000000..800f7c41 --- /dev/null +++ b/src/extension/utils/sortByIndex/types/IOptions.ts @@ -0,0 +1,5 @@ +interface IOptions { + mutateIndex?: boolean; +} + +export default IOptions; diff --git a/src/extension/utils/sortByIndex/types/IType.ts b/src/extension/utils/sortByIndex/types/IType.ts new file mode 100644 index 00000000..e6949ee1 --- /dev/null +++ b/src/extension/utils/sortByIndex/types/IType.ts @@ -0,0 +1,6 @@ +interface IType { + index: number | null; + createdAt: number; +} + +export default IType; diff --git a/src/extension/utils/sortByIndex/types/index.ts b/src/extension/utils/sortByIndex/types/index.ts new file mode 100644 index 00000000..e0cdd88c --- /dev/null +++ b/src/extension/utils/sortByIndex/types/index.ts @@ -0,0 +1,2 @@ +export type { default as IOptions } from './IOptions'; +export type { default as IType } from './IType'; diff --git a/src/manifest.common.json b/src/manifest.common.json index 7c258b72..01b3b55f 100644 --- a/src/manifest.common.json +++ b/src/manifest.common.json @@ -1,7 +1,7 @@ { "name": "Kibisis", "version": "2.4.0", - "description": "The wallet for your lifestyle.", + "description": "Kibisis is more than just a wallet. It is your gateway to a secure and self-custodial lifestyle.", "author": "Kibisis OƜ", "icons": { "48": "icons/icon-48.png", @@ -9,12 +9,8 @@ }, "content_scripts": [ { - "matches": [ - "*://*/*" - ], - "js": [ - "content-script.js" - ] + "matches": ["*://*/*"], + "js": ["content-script.js"] } ], "omnibox": { diff --git a/tsconfig.json b/tsconfig.json index 615ee83e..01d27b63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ "@extension/features/*": ["src/extension/features/*"], "@extension/fonts/*": ["src/extension/fonts/*"], "@extension/hooks/*": ["src/extension/hooks/*"], + "@extension/icons/*": ["src/extension/icons/*"], "@extension/images/*": ["src/extension/images/*"], "@extension/managers/*": ["src/extension/managers/*"], "@extension/modals/*": ["src/extension/modals/*"], diff --git a/webpack/utils/createCommonConfig.ts b/webpack/utils/createCommonConfig.ts index 6fbb5f51..b1338af0 100644 --- a/webpack/utils/createCommonConfig.ts +++ b/webpack/utils/createCommonConfig.ts @@ -35,6 +35,7 @@ export default function createCommonConfig(): Configuration { ['@extension/features']: resolve(extensionPath, 'features'), ['@extension/fonts']: resolve(extensionPath, 'fonts'), ['@extension/hooks']: resolve(extensionPath, 'hooks'), + ['@extension/icons']: resolve(extensionPath, 'icons'), ['@extension/images']: resolve(extensionPath, 'images'), ['@extension/managers']: resolve(extensionPath, 'managers'), ['@extension/modals']: resolve(extensionPath, 'modals'), diff --git a/yarn.lock b/yarn.lock index 12fe4422..e414b877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4410,7 +4410,7 @@ atomic-sleep@^1.0.0: resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -axios@^1.6.2, axios@^1.7.4: +axios@^1.7.4: version "1.7.5" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.5.tgz#21eed340eb5daf47d29b6e002424b3e88c8c54b1" integrity sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw== @@ -6675,11 +6675,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fflate@^0.4.8: - version "0.4.8" - resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" - integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== - figures@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz" @@ -10699,27 +10694,6 @@ postcss@8.4.31, postcss@^8.4.21: picocolors "^1.0.0" source-map-js "^1.0.2" -posthog-js@^1.130.1: - version "1.130.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.130.1.tgz#e8d037043f801d438f785f441843cce7d8af7ec3" - integrity sha512-BC283kxeJnVIeAxn7ZPHf5sCTA6oXs4uvo9fdGAsbKwwfmF9g09rnJOOaoF95J/auf8HT4YB6Vt2KytqtJD44w== - dependencies: - fflate "^0.4.8" - preact "^10.19.3" - -posthog-node@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-4.0.1.tgz#eb8b6cdf68c3fdd0dc2b75e8aab2e0ec3727fb2a" - integrity sha512-rtqm2h22QxLGBrW2bLYzbRhliIrqgZ0k+gF0LkQ1SNdeD06YE5eilV0MxZppFSxC8TfH0+B0cWCuebEnreIDgQ== - dependencies: - axios "^1.6.2" - rusha "^0.8.14" - -preact@^10.19.3: - version "10.21.0" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.21.0.tgz#5b0335c873a1724deb66e517830db4fd310c24f6" - integrity sha512-aQAIxtzWEwH8ou+OovWVSVNlFImL7xUCwJX3YMqA3U8iKCNC34999fFOnWjYNsylgfPgMexpbk7WYOLtKr/mxg== - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -11458,11 +11432,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rusha@^0.8.14: - version "0.8.14" - resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68" - integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA== - rxjs@^7.0.0: version "7.8.0" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz"