From 8e129e28c768b01dd09cc84d07f9841ea0801ce5 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 22 Aug 2024 12:01:39 +1000 Subject: [PATCH] feat: add block/unblock modal --- ts/components/basic/StyledI18nSubText.tsx | 29 +++-- ts/components/dialog/ModalContainer.tsx | 4 + ts/components/dialog/ModeratorsAddDialog.tsx | 1 - ts/components/dialog/OpenUrlModal.tsx | 16 +-- ts/components/dialog/SessionConfirm.tsx | 10 +- .../blockOrUnblock/BlockOrUnblockDialog.tsx | 109 ++++++++++++++++++ .../BlockOrUnblockModalState.ts | 5 + .../shared/ModalDescriptionContainer.tsx | 6 + ts/components/settings/BlockedList.tsx | 20 ++-- ts/hooks/useParamSelector.ts | 13 +++ ts/interactions/conversationInteractions.ts | 55 +++------ ts/state/ducks/modalDialog.tsx | 7 ++ ts/state/selectors/modal.ts | 5 + ts/test/util/blockedNumberController_test.ts | 2 +- ts/types/Localizer.ts | 2 +- ts/util/blockedNumberController.ts | 4 +- ts/util/theme.ts | 6 +- 17 files changed, 202 insertions(+), 92 deletions(-) create mode 100644 ts/components/dialog/blockOrUnblock/BlockOrUnblockDialog.tsx create mode 100644 ts/components/dialog/blockOrUnblock/BlockOrUnblockModalState.ts create mode 100644 ts/components/dialog/shared/ModalDescriptionContainer.tsx diff --git a/ts/components/basic/StyledI18nSubText.tsx b/ts/components/basic/StyledI18nSubText.tsx index a5e6d9bd8..38f5c6e48 100644 --- a/ts/components/basic/StyledI18nSubText.tsx +++ b/ts/components/basic/StyledI18nSubText.tsx @@ -3,15 +3,15 @@ import { forwardRef } from 'react'; import { I18n } from './I18n'; import { I18nProps, LocalizerToken } from '../../types/Localizer'; -const StyledI18nSubTextContainer = styled('div')<{ textLength: number }>` +const StyledI18nSubTextContainer = styled('div')` font-size: var(--font-size-md); line-height: 1.5; margin-bottom: var(--margins-lg); - max-width: ${props => - props.textLength > 90 - ? '60ch' - : '33ch'}; // this is ugly, but we want the dialog description to have multiple lines when a short text is displayed + // TODO: we'd like the description to be on two lines instead of one when it is short. + // setting the max-width depending on the text length is **not** the way to go. + // We should set the width on the dialog itself, depending on what we display. + max-width: '60ch'; `; const StyledI18nSubMessageTextContainer = styled('div')` @@ -20,16 +20,15 @@ const StyledI18nSubMessageTextContainer = styled('div')` margin-bottom: var(--margins-md); `; -export const StyledI18nSubText = forwardRef< - HTMLSpanElement, - I18nProps & { textLength: number } ->(({ textLength = 90, className, ...props }) => { - return ( - - - - ); -}); +export const StyledI18nSubText = forwardRef>( + ({ className, ...props }) => { + return ( + + + + ); + } +); export const StyledI18nSubMessageText = forwardRef>( ({ className, ...props }) => { diff --git a/ts/components/dialog/ModalContainer.tsx b/ts/components/dialog/ModalContainer.tsx index 18f37580d..0eecfde9e 100644 --- a/ts/components/dialog/ModalContainer.tsx +++ b/ts/components/dialog/ModalContainer.tsx @@ -2,6 +2,7 @@ import { useSelector } from 'react-redux'; import { getAddModeratorsModal, getBanOrUnbanUserModalState, + getBlockOrUnblockUserModalState, getChangeNickNameDialog, getConfirmModal, getDeleteAccountModalState, @@ -41,6 +42,7 @@ import { UpdateGroupNameDialog } from './UpdateGroupNameDialog'; import { UserDetailsDialog } from './UserDetailsDialog'; import { EditProfileDialog } from './edit-profile/EditProfileDialog'; import { OpenUrlModal } from './OpenUrlModal'; +import { BlockOrUnblockDialog } from './blockOrUnblock/BlockOrUnblockDialog'; export const ModalContainer = () => { const confirmModalState = useSelector(getConfirmModal); @@ -57,6 +59,7 @@ export const ModalContainer = () => { const sessionPasswordModalState = useSelector(getSessionPasswordDialog); const deleteAccountModalState = useSelector(getDeleteAccountModalState); const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState); + const blockOrUnblockModalState = useSelector(getBlockOrUnblockUserModalState); const reactListModalState = useSelector(getReactListDialog); const reactClearAllModalState = useSelector(getReactClearAllDialog); const editProfilePictureModalState = useSelector(getEditProfilePictureModalState); @@ -67,6 +70,7 @@ export const ModalContainer = () => { return ( <> {banOrUnbanUserModalState && } + {blockOrUnblockModalState && } {inviteModalState && } {addModeratorsModalState && } {removeModeratorsModalState && } diff --git a/ts/components/dialog/ModeratorsAddDialog.tsx b/ts/components/dialog/ModeratorsAddDialog.tsx index 1c285feb6..6464c8cf5 100644 --- a/ts/components/dialog/ModeratorsAddDialog.tsx +++ b/ts/components/dialog/ModeratorsAddDialog.tsx @@ -84,7 +84,6 @@ export const AddModeratorsDialog = (props: Props) => { }} > -

Add Moderator:

- - - + + +
diff --git a/ts/components/dialog/SessionConfirm.tsx b/ts/components/dialog/SessionConfirm.tsx index a5ba68459..18a2c4dd6 100644 --- a/ts/components/dialog/SessionConfirm.tsx +++ b/ts/components/dialog/SessionConfirm.tsx @@ -15,8 +15,6 @@ import { I18nProps, LocalizerToken } from '../../types/Localizer'; import { StyledI18nSubText } from '../basic/StyledI18nSubText'; export interface SessionConfirmDialogProps { - // message?: string; - // messageSub?: string; i18nMessage?: I18nProps; i18nMessageSub?: I18nProps; title?: string; @@ -135,13 +133,9 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { {!showHeader && }
- {i18nMessage ? : null} + {i18nMessage ? : null} {i18nMessageSub ? ( - + ) : null} {radioOptions && chosenOption !== '' ? ( ; + +function getUnblockTokenAndArgs(names: Array) { + // multiple unblock is supported + switch (names.length) { + case 1: + return { token: 'blockUnblockName', args: { name: names[0] } } as const; + case 2: + return { token: 'blockUnblockNameTwo', args: { name: names[0] } } as const; + + default: + return { + token: 'blockUnblockNameMultiple', + args: { name: names[0], count: names.length - 1 }, + } as const; + } +} + +function useBlockUnblockI18nDescriptionArgs({ + action, + pubkeys, +}: Pick) { + const names = useConversationsNicknameRealNameOrShortenPubkey(pubkeys); + if (!pubkeys.length) { + throw new Error('useI18nDescriptionArgsForAction called with empty list of pubkeys'); + } + if (action === 'block') { + if (pubkeys.length !== 1 || names.length !== 1) { + throw new Error('we can only block a single user at a time'); + } + return { token: 'blockDescription', args: { name: names[0] } } as const; + } + + return getUnblockTokenAndArgs(names); +} + +export const BlockOrUnblockDialog = ({ pubkeys, action, onConfirmed }: NonNullable) => { + const dispatch = useDispatch(); + + const localizedAction = action === 'block' ? window.i18n('block') : window.i18n('blockUnblock'); + + const args = useBlockUnblockI18nDescriptionArgs({ action, pubkeys }); + + const closeModal = useCallback(() => { + dispatch(updateBlockOrUnblockModal(null)); + }, [dispatch]); + useHotkey('Escape', closeModal); + + const [, onConfirm] = useAsyncFn(async () => { + if (action === 'block') { + // we never block more than one user from the UI, so this is not very useful, just a type guard + for (let index = 0; index < pubkeys.length; index++) { + const pubkey = pubkeys[index]; + await BlockedNumberController.block(pubkey); + } + } else { + await BlockedNumberController.unblockAll(pubkeys); + } + closeModal(); + onConfirmed?.(); + }, [action, onConfirmed, pubkeys]); + + if (isEmpty(pubkeys)) { + closeModal(); + return null; + } + return ( + + + + + + +
+ + +
+
+
+
+ ); +}; diff --git a/ts/components/dialog/blockOrUnblock/BlockOrUnblockModalState.ts b/ts/components/dialog/blockOrUnblock/BlockOrUnblockModalState.ts new file mode 100644 index 000000000..0f8e026f5 --- /dev/null +++ b/ts/components/dialog/blockOrUnblock/BlockOrUnblockModalState.ts @@ -0,0 +1,5 @@ +export type BlockOrUnblockModalState = { + action: 'block' | 'unblock'; + pubkeys: Array; + onConfirmed?: () => void; +} | null; diff --git a/ts/components/dialog/shared/ModalDescriptionContainer.tsx b/ts/components/dialog/shared/ModalDescriptionContainer.tsx new file mode 100644 index 000000000..4056c0572 --- /dev/null +++ b/ts/components/dialog/shared/ModalDescriptionContainer.tsx @@ -0,0 +1,6 @@ +import styled from 'styled-components'; + +export const StyledModalDescriptionContainer = styled.div` + padding: var(--margins-md); + max-width: 500px; +`; diff --git a/ts/components/settings/BlockedList.tsx b/ts/components/settings/BlockedList.tsx index f467aacd4..874eebd80 100644 --- a/ts/components/settings/BlockedList.tsx +++ b/ts/components/settings/BlockedList.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; +import { useDispatch } from 'react-redux'; import useUpdate from 'react-use/lib/useUpdate'; import styled from 'styled-components'; import { useSet } from '../../hooks/useSet'; -import { ToastUtils } from '../../session/utils'; +import { updateBlockOrUnblockModal } from '../../state/ducks/modalDialog'; import { BlockedNumberController } from '../../util'; import { MemberListItem } from '../MemberListItem'; import { SessionButton, SessionButtonColor } from '../basic/SessionButton'; @@ -76,6 +77,7 @@ const NoBlockedContacts = () => { }; export const BlockedContactsList = () => { + const dispatch = useDispatch(); const [expanded, setExpanded] = useState(false); const { uniqueValues: selectedIds, @@ -98,15 +100,17 @@ export const BlockedContactsList = () => { async function unBlockThoseUsers() { if (selectedIds.length) { - await BlockedNumberController.unblockAll(selectedIds); - emptySelected(); - ToastUtils.pushToastSuccess( - 'unblocked', - window.i18n('blockUnblockedUser', { - name: selectedIds.join(', '), + dispatch( + updateBlockOrUnblockModal({ + action: 'unblock', + pubkeys: selectedIds, + onConfirmed: () => { + // annoying, but until that BlockedList is in redux, we need to force a refresh of this component when a change is made. + emptySelected(); + forceUpdate(); + }, }) ); - forceUpdate(); } } diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index 4152bdc24..d322fb53b 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -76,6 +76,19 @@ export function useConversationsUsernameWithQuoteOrFullPubkey(pubkeys: Array) { + return useSelector((state: StateType) => { + return pubkeys.map(pk => { + if (pk === UserUtils.getOurPubKeyStrFromCache() || pk.toLowerCase() === 'you') { + return window.i18n('you'); + } + const convo = state.conversations.conversationLookup[pk]; + + return convo?.nickname || convo?.displayNameInProfile || PubKey.shorten(pk); + }); + }); +} + export function useOurConversationUsername() { return useConversationUsername(UserUtils.getOurPubKeyStrFromCache()); } diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 0f78fe96b..19207efbd 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -9,6 +9,7 @@ import { SessionButtonColor } from '../components/basic/SessionButton'; import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings'; import { Data } from '../data/data'; import { SettingsKey } from '../data/settings-key'; +import { ConversationTypeEnum } from '../models/types'; import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; @@ -30,6 +31,7 @@ import { changeNickNameModal, updateAddModeratorsModal, updateBanOrUnbanUserModal, + updateBlockOrUnblockModal, updateConfirmModal, updateGroupMembersModal, updateGroupNameModal, @@ -40,13 +42,12 @@ import { MIME } from '../types'; import { IMAGE_JPEG } from '../types/MIME'; import { processNewAttachment } from '../types/MessageAttachment'; import { urlToBlob } from '../types/attachments/VisualAttachment'; -import { BlockedNumberController } from '../util/blockedNumberController'; import { encryptProfile } from '../util/crypto/profileEncrypter'; import { ReleasedFeatures } from '../util/releaseFeature'; import { Storage, setLastProfileUpdateTimestamp } from '../util/storage'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; -import { ConversationTypeEnum } from '../models/types'; import { ConversationInteractionStatus, ConversationInteractionType } from './types'; +import { BlockedNumberController } from '../util'; export async function copyPublicKeyByConvoId(convoId: string) { if (OpenGroupUtils.isOpenGroupV2(convoId)) { @@ -67,51 +68,21 @@ export async function copyPublicKeyByConvoId(convoId: string) { } export async function blockConvoById(conversationId: string) { - const conversation = getConversationController().get(conversationId); - - if (!conversation.id || conversation.isPublic()) { - return; - } - - // I don't think we want to reset the approved fields when blocking a contact - // if (conversation.isPrivate()) { - // await conversation.setIsApproved(false); - // } - - await BlockedNumberController.block(conversation.id); - await conversation.commit(); - ToastUtils.pushToastSuccess( - 'blocked', - window.i18n('blockBlockedUser', { name: conversation.getNicknameOrRealUsernameOrPlaceholder() }) + window.inboxStore?.dispatch( + updateBlockOrUnblockModal({ + action: 'block', + pubkeys: [conversationId], + }) ); } export async function unblockConvoById(conversationId: string) { - const conversation = getConversationController().get(conversationId); - - if (!conversation) { - // we assume it's a block contact and not group. - // this is to be able to unlock a contact we don't have a conversation with. - await BlockedNumberController.unblockAll([conversationId]); - ToastUtils.pushToastSuccess( - 'unblocked', - window.i18n('blockUnblockedUser', { - name: conversationId, - }) - ); - return; - } - if (!conversation.id || conversation.isPublic()) { - return; - } - await BlockedNumberController.unblockAll([conversationId]); - ToastUtils.pushToastSuccess( - 'unblocked', - window.i18n('blockUnblockedUser', { - name: conversation.getNicknameOrRealUsernameOrPlaceholder() ?? '', + window.inboxStore?.dispatch( + updateBlockOrUnblockModal({ + action: 'unblock', + pubkeys: [conversationId], }) ); - await conversation.commit(); } /** @@ -155,7 +126,7 @@ export async function declineConversationWithoutConfirm({ // this will update the value in the wrapper if needed but not remove the entry if we want it gone. The remove is done below with removeContactFromWrapper await conversationToDecline.commit(); if (blockContact) { - await blockConvoById(conversationId); + await BlockedNumberController.block(conversationId); } // when removing a message request, without blocking it, we actually have no need to store the conversation in the wrapper. So just remove the entry diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index a526e295e..1b6c5bc69 100644 --- a/ts/state/ducks/modalDialog.tsx +++ b/ts/state/ducks/modalDialog.tsx @@ -1,4 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { BlockOrUnblockModalState } from '../../components/dialog/blockOrUnblock/BlockOrUnblockModalState'; import { EnterPasswordModalProps } from '../../components/dialog/EnterPasswordModal'; import { HideRecoveryPasswordDialogProps } from '../../components/dialog/HideRecoveryPasswordDialog'; import { SessionConfirmDialogProps } from '../../components/dialog/SessionConfirm'; @@ -54,6 +55,7 @@ export type ModalState = { confirmModal: ConfirmModalState; inviteContactModal: InviteContactModalState; banOrUnbanUserModal: BanOrUnbanUserModalState; + blockOrUnblockModal: BlockOrUnblockModalState; removeModeratorsModal: RemoveModeratorsModalState; addModeratorsModal: AddModeratorsModalState; groupNameModal: UpdateGroupNameModalState; @@ -79,6 +81,7 @@ export const initialModalState: ModalState = { addModeratorsModal: null, removeModeratorsModal: null, banOrUnbanUserModal: null, + blockOrUnblockModal: null, groupNameModal: null, groupMembersModal: null, userDetailsModal: null, @@ -109,6 +112,9 @@ const ModalSlice = createSlice({ updateBanOrUnbanUserModal(state, action: PayloadAction) { return { ...state, banOrUnbanUserModal: action.payload }; }, + updateBlockOrUnblockModal(state, action: PayloadAction) { + return { ...state, blockOrUnblockModal: action.payload }; + }, updateAddModeratorsModal(state, action: PayloadAction) { return { ...state, addModeratorsModal: action.payload }; }, @@ -193,6 +199,7 @@ export const { sessionPassword, updateDeleteAccountModal, updateBanOrUnbanUserModal, + updateBlockOrUnblockModal, updateReactListModal, updateReactClearAllModal, updateEditProfilePictureModal, diff --git a/ts/state/selectors/modal.ts b/ts/state/selectors/modal.ts index d3e68bbf7..3a6c5f7e9 100644 --- a/ts/state/selectors/modal.ts +++ b/ts/state/selectors/modal.ts @@ -63,6 +63,11 @@ export const getBanOrUnbanUserModalState = createSelector( (state: ModalState): BanOrUnbanUserModalState => state.banOrUnbanUserModal ); +export const getBlockOrUnblockUserModalState = createSelector( + getModal, + (state: ModalState) => state.blockOrUnblockModal +); + export const getUpdateGroupNameModal = createSelector( getModal, (state: ModalState): UpdateGroupNameModalState => state.groupNameModal diff --git a/ts/test/util/blockedNumberController_test.ts b/ts/test/util/blockedNumberController_test.ts index 904a3d3a9..3812d012f 100644 --- a/ts/test/util/blockedNumberController_test.ts +++ b/ts/test/util/blockedNumberController_test.ts @@ -53,7 +53,7 @@ describe('BlockedNumberController', () => { it('should block the user', async () => { const other = TestUtils.generateFakePubKey(); - await BlockedNumberController.block(other); + await BlockedNumberController.block(other.key); const blockedNumbers = BlockedNumberController.getBlockedNumbers(); expect(blockedNumbers).to.have.lengthOf(1); diff --git a/ts/types/Localizer.ts b/ts/types/Localizer.ts index 7ec995219..c28779b05 100644 --- a/ts/types/Localizer.ts +++ b/ts/types/Localizer.ts @@ -29,7 +29,7 @@ type DynamicArgs = : /** If a string segment follows the variable form parse its variable name and recursively * check for more dynamic args */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- We dont care about _Pre TODO: see if we can remove this infer - LocalizedString extends `${infer _Pre}{${infer Var}}${infer Rest}` + LocalizedString extends `${string}{${infer Var}}${infer Rest}` ? Var | DynamicArgs : never; diff --git a/ts/util/blockedNumberController.ts b/ts/util/blockedNumberController.ts index 76d5aa77c..d86b9522b 100644 --- a/ts/util/blockedNumberController.ts +++ b/ts/util/blockedNumberController.ts @@ -29,7 +29,7 @@ export class BlockedNumberController { * * @param user The user to block. */ - public static async block(user: string | PubKey): Promise { + public static async block(user: string): Promise { // The reason we add all linked device to block number set instead of checking if any device of a user is in the `isBlocked` function because // `isBlocked` is used synchronously in the code. To check if any device is blocked needs it to be async, which would mean all calls to `isBlocked` will also need to be async and so on // This is too much of a hassle at the moment as some UI code will have to be migrated to work with this async call. @@ -78,7 +78,7 @@ export class BlockedNumberController { } } - public static async setBlocked(user: string | PubKey, blocked: boolean): Promise { + public static async setBlocked(user: string, blocked: boolean): Promise { if (blocked) { return BlockedNumberController.block(user); } diff --git a/ts/util/theme.ts b/ts/util/theme.ts index e84baf84e..ac59a1d86 100644 --- a/ts/util/theme.ts +++ b/ts/util/theme.ts @@ -1,7 +1,7 @@ import { ThemeStateType } from '../themes/constants/colors'; -export const checkDarkTheme = (theme: ThemeStateType): boolean => theme.includes('dark'); -export const checkLightTheme = (theme: ThemeStateType): boolean => theme.includes('light'); +export const checkDarkTheme = (theme: ThemeStateType): boolean => theme?.includes('dark'); +export const checkLightTheme = (theme: ThemeStateType): boolean => theme?.includes('light'); export function getOppositeTheme(themeName: ThemeStateType): ThemeStateType { if (checkDarkTheme(themeName)) { @@ -11,7 +11,7 @@ export function getOppositeTheme(themeName: ThemeStateType): ThemeStateType { return themeName.replace('light', 'dark') as ThemeStateType; } // If neither 'dark' nor 'light' is in the theme name, return the original theme name. - return themeName as ThemeStateType; + return themeName; } export function isThemeMismatched(themeName: ThemeStateType, prefersDark: boolean): boolean {