diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 0b85686cf..d6cf98c52 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -22,14 +22,6 @@ type AnyFunction = (...args: any) => any; type AnyToVoidFunction = (...args: any) => void; type NoneToVoidFunction = () => void; -type Country = { - id: string; - name: string; - flag: string; - code: string; - phoneFormat: RegExp; -}; - type EmojiCategory = { id: string; name: string; diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 63740244b..89cff69b4 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -1,11 +1,11 @@ import { Api as GramJs } from '../../../lib/gramjs'; -import { ApiSession, ApiWallpaper } from '../../types'; +import { ApiCountry, ApiSession, ApiWallpaper } from '../../types'; import { ApiPrivacySettings, ApiPrivacyKey, PrivacyVisibility } from '../../../types'; import { buildApiDocument } from './messages'; import { getApiChatIdFromMtpPeer } from './chats'; -import { pick } from '../../../util/iteratees'; +import { flatten, pick } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; export function buildApiWallpaper(wallpaper: GramJs.TypeWallPaper): ApiWallpaper | undefined { @@ -113,3 +113,41 @@ export function buildApiNotifyException( ...(showPreviews !== undefined && { shouldShowPreviews: Boolean(showPreviews) }), }; } +function buildApiCountry(country: GramJs.help.Country, code?: GramJs.help.CountryCode) { + const { + hidden, iso2, defaultName, name, + } = country; + const { countryCode, prefixes, patterns } = code || {}; + + return { + isHidden: hidden, + iso2, + defaultName, + name, + countryCode, + prefixes, + patterns, + }; +} + +export function buildApiCountryList(countries: GramJs.help.Country[]) { + const listByCode = flatten(countries + .filter((country) => !country.hidden) + .map((country) => ( + country.countryCodes.map((code) => buildApiCountry(country, code)) + ))).sort((a: ApiCountry, b: ApiCountry) => ( + a.name ? a.name.localeCompare(b.name!) : a.defaultName.localeCompare(b.defaultName) + )); + + const generalList = countries + .filter((country) => !country.hidden) + .map((country) => buildApiCountry(country)) + .sort((a, b) => ( + a.name ? a.name.localeCompare(b.name!) : a.defaultName.localeCompare(b.defaultName) + )); + + return { + phoneCodes: listByCode, + general: generalList, + }; +} diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 5942e36fc..498bb16f4 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -66,6 +66,11 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) if (DEBUG) { // eslint-disable-next-line no-console console.log('[GramJs/client] CONNECTING'); + + // eslint-disable-next-line no-restricted-globals + (self as any).invoke = invokeRequest; + // eslint-disable-next-line no-restricted-globals + (self as any).GramJs = GramJs; } try { @@ -95,11 +100,6 @@ export async function init(_onUpdate: OnApiUpdate, initialArgs: ApiInitialArgs) console.log('>>> FINISH INIT API'); // eslint-disable-next-line no-console console.log('[GramJs/client] CONNECTED'); - - // eslint-disable-next-line no-restricted-globals - (self as any).invoke = invokeRequest; - // eslint-disable-next-line no-restricted-globals - (self as any).GramJs = GramJs; } onAuthReady(); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 7b4ced51d..be17d239f 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -26,7 +26,7 @@ export { } from './messages'; export { - fetchFullUser, fetchNearestCountry, + fetchFullUser, fetchNearestCountry, fetchCountryList, fetchTopUsers, fetchContactList, fetchUsers, updateContact, deleteUser, fetchProfilePhotos, } from './users'; diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index f2c312333..e940318eb 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -3,6 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { OnApiUpdate, ApiUser, ApiChat, ApiPhoto, } from '../../types'; +import { LangCode } from '../../../types'; import { PROFILE_PHOTOS_LIMIT } from '../../../config'; import { invokeRequest } from './client'; @@ -18,6 +19,7 @@ import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiPhoto } from '../apiBuilders/common'; import localDb from '../localDb'; import { addPhotoToLocalDb } from '../helpers'; +import { buildApiCountryList } from '../apiBuilders/misc'; let onUpdate: OnApiUpdate; @@ -60,6 +62,17 @@ export async function fetchNearestCountry() { return dcInfo?.country; } +export async function fetchCountryList({ langCode = 'en' }: { langCode?: LangCode }) { + const countryList = await invokeRequest(new GramJs.help.GetCountriesList({ + langCode, + })); + + if (!(countryList instanceof GramJs.help.CountriesList)) { + return undefined; + } + return buildApiCountryList(countryList.countries); +} + export async function fetchTopUsers({ hash = 0 }: { hash?: number }) { const topPeers = await invokeRequest(new GramJs.contacts.GetTopPeers({ hash, diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 10b305735..28634a6a4 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -91,3 +91,16 @@ export type ApiInviteInfo = { isChannel?: boolean; participantsCount?: number; }; + +export interface ApiCountry { + isHidden?: boolean; + iso2: string; + defaultName: string; + name?: string; +} + +export interface ApiCountryCode extends ApiCountry { + countryCode: string; + prefixes?: string[]; + patterns?: string[]; +} diff --git a/src/components/auth/AuthPhoneNumber.tsx b/src/components/auth/AuthPhoneNumber.tsx index c8b89cf72..7901d692d 100644 --- a/src/components/auth/AuthPhoneNumber.tsx +++ b/src/components/auth/AuthPhoneNumber.tsx @@ -10,12 +10,13 @@ import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions, GlobalState } from '../../global/types'; import { LangCode } from '../../types'; +import { ApiCountryCode } from '../../api/types'; import { IS_SAFARI, IS_TOUCH_ENV } from '../../util/environment'; import { preloadImage } from '../../util/files'; import preloadFonts from '../../util/fonts'; import { pick } from '../../util/iteratees'; -import { formatPhoneNumber, getCountryById, getCountryFromPhoneNumber } from '../../util/phoneNumber'; +import { formatPhoneNumber, getCountryCodesByIso, getCountryFromPhoneNumber } from '../../util/phoneNumber'; import { setLanguage } from '../../util/langProvider'; import useLang from '../../hooks/useLang'; import useFlag from '../../hooks/useFlag'; @@ -30,13 +31,16 @@ import CountryCodeInput from './CountryCodeInput'; type StateProps = Pick & { language?: LangCode; + phoneCodeList: ApiCountryCode[]; }; type DispatchProps = Pick; const MIN_NUMBER_LENGTH = 7; @@ -52,10 +56,12 @@ const AuthPhoneNumber: FC = ({ authError, authRememberMe, authNearestCountry, + phoneCodeList, language, setAuthPhoneNumber, setAuthRememberMe, loadNearestCountry, + loadCountryList, clearAuthError, goToAuthQrCode, setSettingOption, @@ -66,13 +72,13 @@ const AuthPhoneNumber: FC = ({ const suggestedLanguage = getSuggestedLanguage(); const continueText = useLangString(suggestedLanguage, 'ContinueOnThisLanguage'); - const [country, setCountry] = useState(); + const [country, setCountry] = useState(); const [phoneNumber, setPhoneNumber] = useState(); const [isTouched, setIsTouched] = useState(false); const [lastSelection, setLastSelection] = useState<[number, number] | undefined>(); const [isLoading, markIsLoading, unmarkIsLoading] = useFlag(); - const fullNumber = country ? `${country.code} ${phoneNumber || ''}` : phoneNumber; + const fullNumber = country ? `+${country.countryCode} ${phoneNumber || ''}` : phoneNumber; const canSubmit = fullNumber && fullNumber.replace(/[^\d]+/g, '').length >= MIN_NUMBER_LENGTH; useEffect(() => { @@ -88,31 +94,36 @@ const AuthPhoneNumber: FC = ({ }, [connectionState, authNearestCountry, loadNearestCountry]); useEffect(() => { - if (authNearestCountry && !country && !isTouched) { - setCountry(getCountryById(authNearestCountry)); + if (connectionState === 'connectionStateReady') { + loadCountryList({ langCode: language }); } - }, [country, authNearestCountry, isTouched]); + }, [connectionState, language, loadCountryList]); + + useEffect(() => { + if (authNearestCountry && phoneCodeList && !country && !isTouched) { + setCountry(getCountryCodesByIso(phoneCodeList, authNearestCountry)[0]); + } + }, [country, authNearestCountry, isTouched, phoneCodeList]); const parseFullNumber = useCallback((newFullNumber: string) => { if (!newFullNumber.length) { setPhoneNumber(''); } - const suggestedCountry = getCountryFromPhoneNumber(newFullNumber); + const suggestedCountry = phoneCodeList && getCountryFromPhoneNumber(phoneCodeList, newFullNumber); // Any phone numbers should be allowed, in some cases ignoring formatting const selectedCountry = !country - || (suggestedCountry && suggestedCountry.id !== country.id) + || (suggestedCountry && suggestedCountry.iso2 !== country.iso2) || (!suggestedCountry && newFullNumber.length) ? suggestedCountry : country; - if (!country || !selectedCountry || (selectedCountry && selectedCountry.code !== country.code)) { + if (!country || !selectedCountry || (selectedCountry && selectedCountry.iso2 !== country.iso2)) { setCountry(selectedCountry); } - setPhoneNumber(formatPhoneNumber(newFullNumber, selectedCountry)); - }, [country]); + }, [phoneCodeList, country]); const handleLangChange = useCallback(() => { markIsLoading(); @@ -144,6 +155,11 @@ const AuthPhoneNumber: FC = ({ }); }, []); + const handleCountryChange = useCallback((value: ApiCountryCode) => { + setCountry(value); + setPhoneNumber(''); + }, []); + const handlePhoneNumberChange = useCallback((e: ChangeEvent) => { if (authError) { clearAuthError(); @@ -169,7 +185,7 @@ const AuthPhoneNumber: FC = ({ IS_SAFARI && country && fullNumber !== undefined && value.length - fullNumber.length > 1 && !isJustPastedRef.current ); - parseFullNumber(shouldFixSafariAutoComplete ? `${country!.code} ${value}` : value); + parseFullNumber(shouldFixSafariAutoComplete ? `${country!.countryCode} ${value}` : value); }, [authError, clearAuthError, country, fullNumber, parseFullNumber]); const handleKeepSessionChange = useCallback((e: ChangeEvent) => { @@ -201,7 +217,7 @@ const AuthPhoneNumber: FC = ({ id="sign-in-phone-code" value={country} isLoading={!authNearestCountry && !country} - onChange={setCountry} + onChange={handleCountryChange} /> { const { settings: { byKey: { language } }, + countryList: { phoneCodes: phoneCodeList }, } = global; return { @@ -258,6 +275,7 @@ export default memo(withGlobal( 'authNearestCountry', ]), language, + phoneCodeList, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ @@ -265,6 +283,7 @@ export default memo(withGlobal( 'setAuthRememberMe', 'clearAuthError', 'loadNearestCountry', + 'loadCountryList', 'goToAuthQrCode', 'setSettingOption', ]), diff --git a/src/components/auth/CountryCodeInput.tsx b/src/components/auth/CountryCodeInput.tsx index c9514cead..b527e726c 100644 --- a/src/components/auth/CountryCodeInput.tsx +++ b/src/components/auth/CountryCodeInput.tsx @@ -1,13 +1,17 @@ import React, { FC, useState, memo, useCallback, useRef, } from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { ApiCountryCode } from '../../api/types'; import { ANIMATION_END_DELAY } from '../../config'; -import { countryList } from '../../util/phoneNumber'; import searchWords from '../../util/searchWords'; import buildClassName from '../../util/buildClassName'; import renderText from '../common/helpers/renderText'; import useLang from '../../hooks/useLang'; +import { isoToEmoji } from '../../util/emoji'; +import useOnChange from '../../hooks/useOnChange'; import DropdownMenu from '../ui/DropdownMenu'; import MenuItem from '../ui/MenuItem'; @@ -15,48 +19,59 @@ import Spinner from '../ui/Spinner'; import './CountryCodeInput.scss'; +type StateProps = { + phoneCodeList: ApiCountryCode[]; +}; + type OwnProps = { id: string; - value?: Country; + value?: ApiCountryCode; isLoading?: boolean; - onChange: (value: Country) => void; + onChange: (value: ApiCountryCode) => void; }; const MENU_HIDING_DURATION = 200 + ANIMATION_END_DELAY; const SELECT_TIMEOUT = 50; -const CountryCodeInput: FC = ({ +const CountryCodeInput: FC = ({ id, value, isLoading, onChange, + phoneCodeList, }) => { const lang = useLang(); // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); const [filter, setFilter] = useState(); - const [filteredList, setFilteredList] = useState(countryList); + const [filteredList, setFilteredList] = useState([]); - function updateFilter(filterValue?: string) { + const updateFilter = useCallback((filterValue?: string) => { setFilter(filterValue); - setFilteredList(getFilteredList(filterValue)); - } + setFilteredList(getFilteredList(phoneCodeList, filterValue)); + }, [phoneCodeList]); + + useOnChange(([prevPhoneCodeList]) => { + if (prevPhoneCodeList?.length === 0 && phoneCodeList.length > 0) { + updateFilter(filter); + } + }, [phoneCodeList, updateFilter]); const handleChange = useCallback((e: React.SyntheticEvent) => { - const { countryId } = (e.currentTarget.firstElementChild as HTMLDivElement).dataset; - const country = countryList.find((c) => c.id === countryId); + const { countryCode } = (e.currentTarget.firstElementChild as HTMLDivElement).dataset; + const country = phoneCodeList.find((c) => c.countryCode === countryCode); if (country) { onChange(country); } setTimeout(() => updateFilter(undefined), MENU_HIDING_DURATION); - }, [onChange]); + }, [phoneCodeList, onChange, updateFilter]); const handleInput = useCallback((e: React.FormEvent) => { updateFilter(e.currentTarget.value); - }, []); + }, [updateFilter]); const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.keyCode !== 8) { @@ -69,7 +84,7 @@ const CountryCodeInput: FC = ({ } updateFilter(target.value); - }, [filter, value]); + }, [filter, updateFilter, value]); const CodeInput: FC<{ onTrigger: () => void; isOpen?: boolean }> = ({ onTrigger, isOpen }) => { const handleTrigger = () => { @@ -87,9 +102,12 @@ const CountryCodeInput: FC = ({ formEl.scrollTo({ top: formEl.scrollHeight, behavior: 'smooth' }); }; - const inputValue = filter !== undefined - ? filter - : (value?.name) || ''; + const handleCodeInput = (e: React.FormEvent) => { + handleInput(e); + handleTrigger(); + }; + + const inputValue = filter ?? (value?.name || value?.defaultName || ''); return (
@@ -102,7 +120,7 @@ const CountryCodeInput: FC = ({ autoComplete="off" onClick={handleTrigger} onFocus={handleTrigger} - onInput={handleInput} + onInput={handleCodeInput} onKeyDown={handleInputKeyDown} /> @@ -120,18 +138,19 @@ const CountryCodeInput: FC = ({ className="CountryCodeInput" trigger={CodeInput} > - {filteredList.map((country: Country) => ( - - - {renderText(country.flag, ['hq_emoji'])} - {country.name} - {country.code} - - ))} + {filteredList + .map((country: ApiCountryCode) => ( + + + {renderText(isoToEmoji(country.iso2), ['hq_emoji'])} + {country.name || country.defaultName} + {country.countryCode} + + ))} {!filteredList.length && ( = ({ ); }; -function getFilteredList(filter = ''): Country[] { - return filter.length - ? countryList.filter((country) => searchWords(country.name, filter)) - : countryList; +function getFilteredList(countryList: ApiCountryCode[], filter = ''): ApiCountryCode[] { + const filtered = filter.length + ? countryList.filter((country) => ( + searchWords(country.defaultName, filter) || (country.name && searchWords(country.name, filter)) + )) : countryList; + return filtered; } -export default memo(CountryCodeInput); +export default memo(withGlobal( + (global): StateProps => { + const { countryList: { phoneCodes: phoneCodeList } } = global; + return { + phoneCodeList, + }; + }, +)(CountryCodeInput)); diff --git a/src/components/common/ChatExtra.tsx b/src/components/common/ChatExtra.tsx index a232e4e69..d47a51967 100644 --- a/src/components/common/ChatExtra.tsx +++ b/src/components/common/ChatExtra.tsx @@ -4,7 +4,7 @@ import React, { import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions, GlobalState } from '../../global/types'; -import { ApiChat, ApiUser } from '../../api/types'; +import { ApiChat, ApiCountryCode, ApiUser } from '../../api/types'; import { selectChat, selectNotifyExceptions, selectNotifySettings, selectUser, @@ -31,6 +31,7 @@ type StateProps = { chat?: ApiChat; canInviteUsers?: boolean; isMuted?: boolean; + phoneCodeList: ApiCountryCode[]; } & Pick; type DispatchProps = Pick; @@ -42,6 +43,7 @@ const ChatExtra: FC = ({ forceShowSelf, canInviteUsers, isMuted, + phoneCodeList, loadFullUser, showNotification, updateChatMutedState, @@ -75,7 +77,7 @@ const ChatExtra: FC = ({ showNotification({ message: `${entity} was copied` }); } - const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneNumber); + const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneCodeList, phoneNumber); const link = getChatLink(chat); const description = (fullInfo?.bio) || getChatDescription(chat); @@ -135,7 +137,7 @@ const ChatExtra: FC = ({ export default memo(withGlobal( (global, { chatOrUserId }): StateProps => { - const { lastSyncTime } = global; + const { lastSyncTime, countryList: { phoneCodes: phoneCodeList } } = global; const chat = chatOrUserId ? selectChat(global, chatOrUserId) : undefined; const user = isChatPrivate(chatOrUserId) ? selectUser(global, chatOrUserId) : undefined; @@ -147,7 +149,7 @@ export default memo(withGlobal( ); return { - lastSyncTime, chat, user, canInviteUsers, isMuted, + lastSyncTime, phoneCodeList, chat, user, canInviteUsers, isMuted, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx index 0ec373bcf..4b8504f1b 100644 --- a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx +++ b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx @@ -4,7 +4,7 @@ import React, { import { withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions } from '../../../global/types'; -import { ApiChat, ApiUser } from '../../../api/types'; +import { ApiChat, ApiCountryCode, ApiUser } from '../../../api/types'; import { SettingsScreens } from '../../../types'; import { CHAT_HEIGHT_PX } from '../../../config'; @@ -33,6 +33,7 @@ type StateProps = { chatsByIds: Record; usersByIds: Record; blockedIds: number[]; + phoneCodeList: ApiCountryCode[]; }; type DispatchProps = Pick; @@ -44,6 +45,7 @@ const SettingsPrivacyBlockedUsers: FC = ( chatsByIds, usersByIds, blockedIds, + phoneCodeList, unblockContact, }) => { const handleUnblockClick = useCallback((contactId: number) => { @@ -83,7 +85,7 @@ const SettingsPrivacyBlockedUsers: FC = (

{renderText((isPrivate ? getUserFullName(user) : getChatTitle(lang, chat!)) || '')}

{user?.phoneNumber && ( -
{formatPhoneNumberWithCode(user.phoneNumber)}
+
{formatPhoneNumberWithCode(phoneCodeList, user.phoneNumber)}
)} {user && !user.phoneNumber && user.username && (
@{user.username}
@@ -142,12 +144,16 @@ export default memo(withGlobal( blocked: { ids, }, + countryList: { + phoneCodes: phoneCodeList, + }, } = global; return { chatsByIds, usersByIds, blockedIds: ids, + phoneCodeList, }; }, (setGlobal, actions): DispatchProps => pick(actions, ['unblockContact']), diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 1259c0461..86f10a5a7 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -64,7 +64,7 @@ type StateProps = { type DispatchProps = Pick; const NOTIFICATION_INTERVAL = 1000; @@ -95,6 +95,7 @@ const Main: FC = ({ updateIsOnline, loadTopInlineBots, loadEmojiKeywords, + loadCountryList, openStickerSetShortName, }) => { if (DEBUG && !DEBUG_isLogged) { @@ -116,10 +117,12 @@ const Main: FC = ({ if (language !== BASE_EMOJI_KEYWORD_LANG) { loadEmojiKeywords({ language }); } + + loadCountryList({ langCode: language }); } }, [ lastSyncTime, loadAnimatedEmojis, loadNotificationExceptions, loadNotificationSettings, updateIsOnline, - loadTopInlineBots, loadEmojiKeywords, language, + loadTopInlineBots, loadEmojiKeywords, loadCountryList, language, ]); useEffect(() => { @@ -298,6 +301,6 @@ export default memo(withGlobal( }, (setGlobal, actions): DispatchProps => pick(actions, [ 'loadAnimatedEmojis', 'loadNotificationSettings', 'loadNotificationExceptions', 'updateIsOnline', - 'loadTopInlineBots', 'loadEmojiKeywords', 'openStickerSetShortName', + 'loadTopInlineBots', 'loadEmojiKeywords', 'openStickerSetShortName', 'loadCountryList', ]), )(Main)); diff --git a/src/components/middle/message/Contact.tsx b/src/components/middle/message/Contact.tsx index 47bc14fcd..f1240ceb1 100644 --- a/src/components/middle/message/Contact.tsx +++ b/src/components/middle/message/Contact.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback } from '../../../lib/teact/teact'; import { withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions } from '../../../global/types'; -import { ApiUser, ApiContact } from '../../../api/types'; +import { ApiUser, ApiContact, ApiCountryCode } from '../../../api/types'; import { selectUser } from '../../../modules/selectors'; import { formatPhoneNumberWithCode } from '../../../util/phoneNumber'; @@ -19,12 +19,13 @@ type OwnProps = { type StateProps = { user?: ApiUser; + phoneCodeList: ApiCountryCode[]; }; type DispatchProps = Pick; const Contact: FC = ({ - contact, user, openUserInfo, + contact, user, openUserInfo, phoneCodeList, }) => { const { firstName, @@ -45,7 +46,7 @@ const Contact: FC = ({
{firstName} {lastName}
-
{formatPhoneNumberWithCode(phoneNumber)}
+
{formatPhoneNumberWithCode(phoneCodeList, phoneNumber)}
); @@ -53,8 +54,10 @@ const Contact: FC = ({ export default withGlobal( (global, { contact }): StateProps => { + const { countryList: { phoneCodes: phoneCodeList } } = global; return { user: selectUser(global, contact.userId), + phoneCodeList, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ diff --git a/src/components/payment/PaymentInfo.tsx b/src/components/payment/PaymentInfo.tsx index 5c32920d5..30ee4fbe7 100644 --- a/src/components/payment/PaymentInfo.tsx +++ b/src/components/payment/PaymentInfo.tsx @@ -4,7 +4,7 @@ import React, { import { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer'; import useLang from '../../hooks/useLang'; -import { countryList } from '../../util/phoneNumber'; +import countryList from '../../util/countries'; import InputText from '../ui/InputText'; import Checkbox from '../ui/Checkbox'; diff --git a/src/components/payment/ShippingInfo.tsx b/src/components/payment/ShippingInfo.tsx index cebe0531b..aa0bba943 100644 --- a/src/components/payment/ShippingInfo.tsx +++ b/src/components/payment/ShippingInfo.tsx @@ -5,7 +5,7 @@ import React, { import { FormState, FormEditDispatch } from '../../hooks/reducers/usePaymentReducer'; import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import useLang from '../../hooks/useLang'; -import { countryList } from '../../util/phoneNumber'; +import countryList from '../../util/countries'; import InputText from '../ui/InputText'; import Select from '../ui/Select'; diff --git a/src/global/initial.ts b/src/global/initial.ts index e39852aa6..d4e03d141 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -15,6 +15,10 @@ export const INITIAL_STATE: GlobalState = { serverTimeOffset: 0, authRememberMe: true, + countryList: { + phoneCodes: [], + general: [], + }, blocked: { ids: [], diff --git a/src/global/types.ts b/src/global/types.ts index 945c74b13..ee0111f54 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -19,6 +19,8 @@ import { ApiSession, ApiNewPoll, ApiInviteInfo, + ApiCountryCode, + ApiCountry, } from '../api/types'; import { FocusDirection, @@ -96,6 +98,10 @@ export type GlobalState = { token: string; expires: number; }; + countryList: { + phoneCodes: ApiCountryCode[]; + general: ApiCountry[]; + }; contactList?: { hash: number; @@ -428,7 +434,6 @@ export type GlobalState = { historyCalendarSelectedAt?: number; openedStickerSetShortName?: string; - // TODO To be removed in August 2021 shouldShowContextMenuHint?: boolean; }; @@ -479,8 +484,9 @@ export type ActionTypes = ( 'togglePreHistoryHidden' | 'updateChatDefaultBannedRights' | 'updateChatMemberBannedRights' | 'updateChatAdmin' | 'acceptInviteConfirmation' | // users - 'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' | - 'updateProfile' | 'checkUsername' | 'updateContact' | 'deleteUser' | 'loadUser' | 'setUserSearchQuery' | + 'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadCountryList' | 'loadTopUsers' | 'loadContactList' | + 'loadCurrentUser' | 'updateProfile' | 'checkUsername' | 'updateContact' | + 'deleteUser' | 'loadUser' | 'setUserSearchQuery' | // Channel / groups creation 'createChannel' | 'createGroupChat' | 'resetChatCreation' | // settings diff --git a/src/hooks/reducers/usePaymentReducer.ts b/src/hooks/reducers/usePaymentReducer.ts index 8bcfdc873..bb5c83a6a 100644 --- a/src/hooks/reducers/usePaymentReducer.ts +++ b/src/hooks/reducers/usePaymentReducer.ts @@ -1,5 +1,5 @@ import useReducer, { StateReducer, Dispatch } from '../useReducer'; -import { countryList } from '../../util/phoneNumber'; +import countryList from '../../util/countries'; export type FormState = { streetLine1: string; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 21b3848a0..d00c381b1 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1054,6 +1054,7 @@ help.getConfig#c4f9186b = Config; help.getNearestDc#1fb33026 = NearestDc; help.getSupport#9cdf08cd = help.Support; help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index 7af017bd8..895aa67f7 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -1055,6 +1055,7 @@ help.getConfig#c4f9186b = Config; help.getNearestDc#1fb33026 = NearestDc; help.getSupport#9cdf08cd = help.Support; help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; diff --git a/src/modules/actions/api/initial.ts b/src/modules/actions/api/initial.ts index 567b8b3e4..1f209947d 100644 --- a/src/modules/actions/api/initial.ts +++ b/src/modules/actions/api/initial.ts @@ -180,6 +180,21 @@ addReducer('loadNearestCountry', (global) => { })(); }); +addReducer('loadCountryList', (global, actions, payload = {}) => { + let { langCode } = payload; + if (!langCode) langCode = global.settings.byKey.language; + + (async () => { + const countryList = await callApi('fetchCountryList', { langCode }); + if (!countryList) return; + + setGlobal({ + ...getGlobal(), + countryList, + }); + })(); +}); + addReducer('setDeviceToken', (global, actions, deviceToken) => { setGlobal({ ...global, diff --git a/src/util/emoji.ts b/src/util/emoji.ts index 8d01acbf8..0ebf365f7 100644 --- a/src/util/emoji.ts +++ b/src/util/emoji.ts @@ -4,6 +4,8 @@ import EMOJI_REGEX from '../lib/twemojiRegex'; // https://github.com/iamcal/emoji-data/issues/136 const EXCLUDE_EMOJIS = ['female_sign', 'male_sign', 'medical_symbol']; +const ISO_FLAGS_OFFSET = 127397; + export type EmojiRawData = typeof import('emoji-data-ios/emoji-data.json'); export type EmojiModule = { default: EmojiRawData }; @@ -17,7 +19,7 @@ const EMOJI_EXCEPTIONS: [string | RegExp, string][] = [ [/\u{1f3f3}\u200d\u{1f308}/gu, '\u{1f3f3}\ufe0f\u200d\u{1f308}'], // 🏳‍🌈 [/\u{1f3f3}\u200d\u26a7\ufe0f/gu, '\u{1f3f3}\ufe0f\u200d\u26a7\ufe0f'], // 🏳️‍⚧️ [/\u{1f937}\u200d\u2642/gu, '\u{1f937}\u200d\u2642\ufe0f'], // 🤷‍♂️ -] +]; function unifiedToNative(unified: string) { const unicodes = unified.split('-'); @@ -29,7 +31,8 @@ function unifiedToNative(unified: string) { export function fixNonStandardEmoji(text: string) { // Non-standard sequences typically parsed as separate emojis, so no need to fix text without any if (!text.match(EMOJI_REGEX)) return text; - for (let [regex, replacement] of EMOJI_EXCEPTIONS) { + // eslint-disable-next-line no-restricted-syntax + for (const [regex, replacement] of EMOJI_EXCEPTIONS) { text = text.replace(regex, replacement); } @@ -90,3 +93,11 @@ export function uncompressEmoji(data: EmojiRawData): EmojiData { return emojiData; } + +export function isoToEmoji(iso: string) { + const code = iso.toUpperCase(); + + if (!/^[A-Z]{2}$/.test(code)) return iso; + const codePoints = [...code].map((c) => c.codePointAt(0)! + ISO_FLAGS_OFFSET); + return String.fromCodePoint(...codePoints); +} diff --git a/src/util/phoneNumber.ts b/src/util/phoneNumber.ts index 961e4c08c..b979a7c99 100644 --- a/src/util/phoneNumber.ts +++ b/src/util/phoneNumber.ts @@ -1,57 +1,90 @@ -import countryList, { defaultPhoneNumberFormat } from './countries'; +import { ApiCountryCode } from '../api/types'; +import { flatten } from './iteratees'; -export function getCountryById(id: string) { - return countryList.find((c) => c.id === id) as Country; +const PATTERN_PLACEHOLDER = 'X'; +const DEFAULT_PATTERN = 'XXX XXX XXX XXX'; + +export function getCountryCodesByIso(phoneCodeList: ApiCountryCode[], iso: string) { + return phoneCodeList.filter((country) => country.iso2 === iso); } -// Empty groups are used to preserve 5 callback arguments for `replace` method -function getPhoneNumberFormat(country?: Country) { - return country ? country.phoneFormat : defaultPhoneNumberFormat; -} - -export function getCountryFromPhoneNumber(input: string) { +export function getCountryFromPhoneNumber(phoneCodeList: ApiCountryCode[], input: string = '') { let phoneNumber = input.replace(/[^\d+]+/g, ''); - if (!phoneNumber.startsWith('+')) { - phoneNumber = `+${phoneNumber}`; + if (phoneNumber.startsWith('+')) { + phoneNumber = phoneNumber.substr(1); } - const possibleCountries = countryList - .filter((country: Country) => phoneNumber.startsWith(country.code)) + const possibleCountries = phoneCodeList + .filter((country) => phoneNumber.startsWith(country.countryCode)); + const codesWithPrefix: { code: string; country: ApiCountryCode }[] = flatten(possibleCountries + .map((country) => (country.prefixes || ['']).map((prefix) => { + return { + code: `${country.countryCode}${prefix}`, + country, + }; + }))); + + const bestMatches = codesWithPrefix + .filter(({ code }) => phoneNumber.startsWith(code)) .sort((a, b) => a.code.length - b.code.length); - return possibleCountries[possibleCountries.length - 1]; + return bestMatches[bestMatches.length - 1]?.country; } -export function formatPhoneNumber(input: string, country?: Country) { +export function formatPhoneNumber(input: string, country?: ApiCountryCode) { let phoneNumber = input.replace(/[^\d]+/g, ''); if (country) { - phoneNumber = phoneNumber.substr(country.code.length - 1); + phoneNumber = phoneNumber.substr(country.countryCode.length); } else if (input.startsWith('+')) { return input; } + const pattern = getBestPattern(phoneNumber, country?.patterns); - phoneNumber = phoneNumber.replace(getPhoneNumberFormat(country), (_, p1, p2, p3, p4, p5) => { - const separator = country && country.id === 'GB' ? ' ' : '-'; + const result: string[] = []; // Result character array + let j = 0; // Position inside pattern + for (let i = 0; i < phoneNumber.length; i++) { + while (pattern[j] !== PATTERN_PLACEHOLDER && j < pattern.length) { + result.push(pattern[j]); + if (pattern[j] === phoneNumber[i]) { // If pattern contains digits, move input position too + i++; + if (i === phoneNumber.length) break; // But don't overdo it, or it will insert full pattern unexpectedly + } + j++; + } - let output = ''; - if (p1) output = `${p1}`; - if (p2) output += ` ${p2}`; - if (p3) output += `${separator}${p3}`; - if (p4) output += `${separator}${p4}`; - if (p5) output += `${separator}${p5}`; - return output; - }); + result.push(phoneNumber[i]); // For placeholder characters, setting current input digit + j++; + } - return phoneNumber; + return result.join(''); } -export function formatPhoneNumberWithCode(phoneNumber: string) { +function getBestPattern(numberWithoutCode: string, patterns?: string[]) { + if (!patterns || patterns.length === 0) return DEFAULT_PATTERN; + if (patterns.length === 1) return patterns[0]; + + const defaultPattern = patterns.find((pattern) => pattern.startsWith(PATTERN_PLACEHOLDER)) || DEFAULT_PATTERN; + + const bestMatches = patterns.filter((pattern) => { + const stripped = pattern.replace(/[^\dX]+/g, ''); + if (stripped.startsWith(PATTERN_PLACEHOLDER)) return false; // Don't consider default number format here + for (let i = 0; i < numberWithoutCode.length; i++) { + if (i > stripped.length - 1 || (stripped[i] !== PATTERN_PLACEHOLDER && stripped[i] !== numberWithoutCode[i])) { + return false; + } + } + return true; + }); + + // Playing it safe: if not sure, use default for that region + return bestMatches.length === 1 ? bestMatches[0] : defaultPattern; +} + +export function formatPhoneNumberWithCode(phoneCodeList: ApiCountryCode[], phoneNumber: string) { const numberWithPlus = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`; - const country = getCountryFromPhoneNumber(numberWithPlus); + const country = getCountryFromPhoneNumber(phoneCodeList, numberWithPlus); if (!country) { return numberWithPlus; } - return `${country.code} ${formatPhoneNumber(numberWithPlus, country)}`; + return `+${country.countryCode} ${formatPhoneNumber(numberWithPlus, country)}`; } - -export { countryList };