From 6d063c2132d64d067e26f1a23f74678828329c71 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 19 Mar 2022 21:20:02 +0100 Subject: [PATCH] New Contact: Allow to rename and add phone number (#1777) --- .stylelintrc.json | 3 + src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/users.ts | 24 +- src/bundles/extra.ts | 1 + src/components/left/main/ContactList.tsx | 12 + src/components/main/Main.tsx | 12 + src/components/main/NewContactModal.async.tsx | 16 ++ src/components/main/NewContactModal.scss | 38 +++ src/components/main/NewContactModal.tsx | 235 ++++++++++++++++++ src/components/middle/ChatReportPanel.tsx | 7 +- src/components/middle/HeaderMenuContainer.tsx | 6 +- src/components/right/RightHeader.tsx | 6 +- src/components/ui/Checkbox.tsx | 3 + src/components/ui/InputText.tsx | 3 + src/global/actions/api/users.ts | 50 ++-- src/global/actions/ui/users.ts | 24 +- src/global/reducers/users.ts | 7 + src/global/types.ts | 26 +- 18 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 src/components/main/NewContactModal.async.tsx create mode 100644 src/components/main/NewContactModal.scss create mode 100644 src/components/main/NewContactModal.tsx diff --git a/.stylelintrc.json b/.stylelintrc.json index e8d8ae1c4..10bcc404e 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -2,6 +2,9 @@ "extends": [ "stylelint-config-recommended-scss" ], + "ignoreFiles": [ + "dist/*.css" + ], "plugins": [ "stylelint-declaration-block-no-ignored-properties", "stylelint-high-performance-animation", diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index c14840cd2..f24b48978 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -29,7 +29,7 @@ export { export { fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers, - addContact, updateContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, + updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, } from './users'; export { diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 22cfb375d..a1ba309a0 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -17,8 +17,7 @@ import { import { buildApiUser, buildApiUserFromFull, buildApiUsersAndStatuses } from '../apiBuilders/users'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiPhoto } from '../apiBuilders/common'; -import localDb from '../localDb'; -import { addEntitiesWithPhotosToLocalDb, addPhotoToLocalDb } from '../helpers'; +import { addEntitiesWithPhotosToLocalDb, addPhotoToLocalDb, addUserToLocalDb } from '../helpers'; import { buildApiPeerId } from '../apiBuilders/peers'; let onUpdate: OnApiUpdate; @@ -115,7 +114,7 @@ export async function fetchContactList() { result.users.forEach((user) => { if (user instanceof GramJs.User) { - localDb.users[buildApiPeerId(user.id, 'user')] = user; + addUserToLocalDb(user, true); } }); @@ -135,14 +134,14 @@ export async function fetchUsers({ users }: { users: ApiUser[] }) { result.forEach((user) => { if (user instanceof GramJs.User) { - localDb.users[buildApiPeerId(user.id, 'user')] = user; + addUserToLocalDb(user, true); } }); return buildApiUsersAndStatuses(result); } -export function updateContact({ +export async function importContact({ phone, firstName, lastName, @@ -151,33 +150,42 @@ export function updateContact({ firstName?: string; lastName?: string; }) { - return invokeRequest(new GramJs.contacts.ImportContacts({ + const result = await invokeRequest(new GramJs.contacts.ImportContacts({ contacts: [buildInputContact({ phone: phone || '', firstName: firstName || '', lastName: lastName || '', })], - }), true); + })); + + if (result instanceof GramJs.contacts.ImportedContacts && result.users.length) { + addUserToLocalDb(result.users[0]); + } + + return result?.imported.length ? buildApiPeerId(result.imported[0].userId, 'user') : undefined; } -export function addContact({ +export function updateContact({ id, accessHash, phoneNumber = '', firstName = '', lastName = '', + shouldSharePhoneNumber = false, }: { id: string; accessHash?: string; phoneNumber?: string; firstName?: string; lastName?: string; + shouldSharePhoneNumber?: boolean; }) { return invokeRequest(new GramJs.contacts.AddContact({ id: buildInputEntity(id, accessHash) as GramJs.InputUser, firstName, lastName, phone: phoneNumber, + ...(shouldSharePhoneNumber && { addPhonePrivacyException: shouldSharePhoneNumber }), }), true); } diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 6d42f390f..495cf98f6 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -5,6 +5,7 @@ export { default as Dialogs } from '../components/main/Dialogs'; export { default as Notifications } from '../components/main/Notifications'; export { default as SafeLinkModal } from '../components/main/SafeLinkModal'; export { default as HistoryCalendar } from '../components/main/HistoryCalendar'; +export { default as NewContactModal } from '../components/main/NewContactModal'; export { default as CalendarModal } from '../components/common/CalendarModal'; export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal'; diff --git a/src/components/left/main/ContactList.tsx b/src/components/left/main/ContactList.tsx index 8043df7f3..62e7ad404 100644 --- a/src/components/left/main/ContactList.tsx +++ b/src/components/left/main/ContactList.tsx @@ -10,11 +10,13 @@ import { throttle } from '../../../util/schedulers'; import { filterUsersByName, sortUserIds } from '../../../global/helpers'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; import PrivateChatInfo from '../../common/PrivateChatInfo'; import InfiniteScroll from '../../ui/InfiniteScroll'; import ListItem from '../../ui/ListItem'; import Loading from '../../ui/Loading'; +import FloatingActionButton from '../../ui/FloatingActionButton'; export type OwnProps = { filter: string; @@ -43,8 +45,11 @@ const ContactList: FC = ({ const { loadContactList, openChat, + openNewContactDialog, } = getActions(); + const lang = useLang(); + // Due to the parent Transition, this component never gets unmounted, // that's why we use throttled API call on every update. useEffect(() => { @@ -91,6 +96,13 @@ const ContactList: FC = ({ ) : ( )} + + + ); }; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index e06755a47..3eb30a452 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -48,6 +48,7 @@ import HistoryCalendar from './HistoryCalendar.async'; import GroupCall from '../calls/group/GroupCall.async'; import ActiveCallHeader from '../calls/ActiveCallHeader.async'; import CallFallbackConfirm from '../calls/CallFallbackConfirm.async'; +import NewContactModal from './NewContactModal.async'; import './Main.scss'; @@ -73,6 +74,8 @@ type StateProps = { wasTimeFormatSetManually?: boolean; isCallFallbackConfirmOpen: boolean; addedSetIds?: string[]; + newContactUserId?: string; + newContactByPhoneNumber?: boolean; }; const NOTIFICATION_INTERVAL = 1000; @@ -104,6 +107,8 @@ const Main: FC = ({ wasTimeFormatSetManually, isCallFallbackConfirmOpen, addedSetIds, + newContactUserId, + newContactByPhoneNumber, }) => { const { sync, @@ -330,6 +335,11 @@ const Main: FC = ({ )} + @@ -388,6 +398,8 @@ export default memo(withGlobal( wasTimeFormatSetManually, isCallFallbackConfirmOpen: Boolean(global.groupCalls.isFallbackConfirmOpen), addedSetIds: global.stickers.added.setIds, + newContactUserId: global.newContact?.userId, + newContactByPhoneNumber: global.newContact?.isByPhoneNumber, }; }, )(Main)); diff --git a/src/components/main/NewContactModal.async.tsx b/src/components/main/NewContactModal.async.tsx new file mode 100644 index 000000000..f681e9f73 --- /dev/null +++ b/src/components/main/NewContactModal.async.tsx @@ -0,0 +1,16 @@ +import React, { FC, memo } from '../../lib/teact/teact'; +import { Bundles } from '../../util/moduleLoader'; + +import { OwnProps } from './NewContactModal'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const NewContactModalAsync: FC = (props) => { + const { isOpen } = props; + const NewContactModal = useModuleLoader(Bundles.Extra, 'NewContactModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return NewContactModal ? : undefined; +}; + +export default memo(NewContactModalAsync); diff --git a/src/components/main/NewContactModal.scss b/src/components/main/NewContactModal.scss new file mode 100644 index 000000000..960125f63 --- /dev/null +++ b/src/components/main/NewContactModal.scss @@ -0,0 +1,38 @@ +.NewContactModal { + .modal-dialog { + max-width: 28rem; + } + + &__new-contact { + display: flex; + + &-fieldset { + flex: 1; + margin-inline-start: 1rem; + } + } + + &__profile { + display: flex; + align-items: center; + margin-bottom: 2rem; + + &-info { + margin-inline-start: 1rem; + } + } + + &__user-status { + color: var(--color-text-secondary); + } + + &__phone-number { + font-size: 1.5rem; + margin-bottom: 0; + } + + &__help-text { + font-size: 0.9375rem; + color: var(--color-text-secondary); + } +} diff --git a/src/components/main/NewContactModal.tsx b/src/components/main/NewContactModal.tsx new file mode 100644 index 000000000..1f08e1ce0 --- /dev/null +++ b/src/components/main/NewContactModal.tsx @@ -0,0 +1,235 @@ +import React, { + FC, memo, useCallback, useEffect, useRef, useState, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import { ApiCountryCode, ApiUser, ApiUserStatus } from '../../api/types'; + +import { IS_TOUCH_ENV } from '../../util/environment'; +import { getUserStatus } from '../../global/helpers'; +import { selectUser, selectUserStatus } from '../../global/selectors'; +import renderText from '../common/helpers/renderText'; +import { formatPhoneNumberWithCode } from '../../util/phoneNumber'; +import useLang from '../../hooks/useLang'; +import useFlag from '../../hooks/useFlag'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; + +import Modal from '../ui/Modal'; +import Avatar from '../common/Avatar'; +import InputText from '../ui/InputText'; +import Checkbox from '../ui/Checkbox'; +import Button from '../ui/Button'; + +import './NewContactModal.scss'; + +const ANIMATION_DURATION = 200; + +export type OwnProps = { + isOpen: boolean; + userId?: string; + isByPhoneNumber?: boolean; +}; + +type StateProps = { + user?: ApiUser; + userStatus?: ApiUserStatus; + phoneCodeList: ApiCountryCode[]; + serverTimeOffset?: number; +}; + +const NewContactModal: FC = ({ + isOpen, + userId, + isByPhoneNumber, + user, + userStatus, + phoneCodeList, + serverTimeOffset, +}) => { + const { updateContact, importContact, closeNewContactDialog } = getActions(); + + const lang = useLang(); + const renderingUser = useCurrentOrPrev(user); + const renderingIsByPhoneNumber = useCurrentOrPrev(isByPhoneNumber); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + + const [isShown, markIsShown, unmarkIsShown] = useFlag(); + const [firstName, setFirstName] = useState(renderingUser?.firstName ?? ''); + const [lastName, setLastName] = useState(renderingUser?.lastName ?? ''); + const [phone, setPhone] = useState(renderingUser?.phoneNumber ?? ''); + const [shouldSharePhoneNumber, setShouldSharePhoneNumber] = useState(true); + const canBeSubmitted = Boolean(firstName && (!isByPhoneNumber || phone)); + + useEffect(() => { + if (isOpen) { + markIsShown(); + setFirstName(renderingUser?.firstName ?? ''); + setLastName(renderingUser?.lastName ?? ''); + setPhone(renderingUser?.phoneNumber ?? ''); + setShouldSharePhoneNumber(true); + } + }, [isOpen, markIsShown, renderingUser?.firstName, renderingUser?.lastName, renderingUser?.phoneNumber]); + + useEffect(() => { + if (!IS_TOUCH_ENV && isShown) { + setTimeout(() => { inputRef.current?.focus(); }, ANIMATION_DURATION); + } + }, [isShown]); + + const handleFirstNameChange = useCallback((e: React.ChangeEvent) => { + setFirstName(e.target.value); + }, []); + + const handlePhoneChange = useCallback((e: React.ChangeEvent) => { + setPhone(formatPhoneNumberWithCode(phoneCodeList, e.target.value)); + }, [phoneCodeList]); + + const handleLastNameChange = useCallback((e: React.ChangeEvent) => { + setLastName(e.target.value); + }, []); + + const handleClose = useCallback(() => { + closeNewContactDialog(); + setFirstName(''); + setLastName(''); + setPhone(''); + }, [closeNewContactDialog]); + + const handleSubmit = useCallback(() => { + if (isByPhoneNumber || !userId) { + importContact({ + firstName, + lastName, + phoneNumber: phone, + }); + } else { + updateContact({ + userId, + firstName, + lastName, + shouldSharePhoneNumber, + }); + } + }, [firstName, importContact, isByPhoneNumber, lastName, phone, shouldSharePhoneNumber, updateContact, userId]); + + if (!isOpen && !isShown) { + return undefined; + } + + function renderAddContact() { + return ( + <> +
+ +
+

+ {renderingUser?.phoneNumber + ? formatPhoneNumberWithCode(phoneCodeList, renderingUser.phoneNumber) + : lang('MobileHidden')} +

+ + {getUserStatus(lang, renderingUser!, userStatus, serverTimeOffset!)} + +
+
+ + +

+ {renderText(lang('NewContact.Phone.Hidden.Text', renderingUser?.firstName), ['emoji', 'simple_markdown'])} +

+ +

+ {renderText(lang('AddContact.SharedContactExceptionInfo', renderingUser?.firstName))} +

+ + ); + } + + function renderCreateContact() { + return ( +
+ +
+ + + +
+
+ ); + } + + return ( + + {renderingUser && renderAddContact()} + {renderingIsByPhoneNumber && renderCreateContact()} +
+ + +
+
+ ); +}; + +export default memo(withGlobal( + (global, { userId }): StateProps => { + return { + user: userId ? selectUser(global, userId) : undefined, + userStatus: userId ? selectUserStatus(global, userId) : undefined, + serverTimeOffset: global.serverTimeOffset, + phoneCodeList: global.countryList.phoneCodes, + }; + }, +)(NewContactModal)); diff --git a/src/components/middle/ChatReportPanel.tsx b/src/components/middle/ChatReportPanel.tsx index c0c5f9194..5079cfe7c 100644 --- a/src/components/middle/ChatReportPanel.tsx +++ b/src/components/middle/ChatReportPanel.tsx @@ -35,7 +35,7 @@ const ChatReportPanel: FC = ({ chatId, className, chat, user, settings, currentUserId, }) => { const { - addContact, + openAddContactDialog, blockContact, reportSpam, deleteChat, @@ -57,11 +57,11 @@ const ChatReportPanel: FC = ({ const isBasicGroup = chat && isChatBasicGroup(chat); const handleAddContact = useCallback(() => { - addContact({ chatId }); + openAddContactDialog({ userId: chatId }); if (isAutoArchived) { toggleChatArchived({ chatId }); } - }, [addContact, isAutoArchived, toggleChatArchived, chatId]); + }, [openAddContactDialog, isAutoArchived, toggleChatArchived, chatId]); const handleConfirmBlock = useCallback(() => { closeBlockUserModal(); @@ -103,7 +103,6 @@ const ChatReportPanel: FC = ({ {canAddContact && (