Get countries and phone codes from server (#1422)

This commit is contained in:
Alexander Zinchuk 2021-09-03 18:31:24 +03:00
parent 4a6dc47be0
commit 00daafc654
22 changed files with 306 additions and 118 deletions

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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();

View File

@ -26,7 +26,7 @@ export {
} from './messages';
export {
fetchFullUser, fetchNearestCountry,
fetchFullUser, fetchNearestCountry, fetchCountryList,
fetchTopUsers, fetchContactList, fetchUsers,
updateContact, deleteUser, fetchProfilePhotos,
} from './users';

View File

@ -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,

View File

@ -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[];
}

View File

@ -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<GlobalState, (
'connectionState' | 'authState' |
'authPhoneNumber' | 'authIsLoading' | 'authIsLoadingQrCode' | 'authError' | 'authRememberMe' | 'authNearestCountry'
'authPhoneNumber' | 'authIsLoading' |
'authIsLoadingQrCode' | 'authError' |
'authRememberMe' | 'authNearestCountry'
)> & {
language?: LangCode;
phoneCodeList: ApiCountryCode[];
};
type DispatchProps = Pick<GlobalActions, (
'setAuthPhoneNumber' | 'setAuthRememberMe' | 'loadNearestCountry' | 'clearAuthError' | 'goToAuthQrCode' |
'setSettingOption'
'setAuthPhoneNumber' | 'setAuthRememberMe' | 'loadNearestCountry' | 'loadCountryList' | 'clearAuthError' |
'goToAuthQrCode' | 'setSettingOption'
)>;
const MIN_NUMBER_LENGTH = 7;
@ -52,10 +56,12 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
authError,
authRememberMe,
authNearestCountry,
phoneCodeList,
language,
setAuthPhoneNumber,
setAuthRememberMe,
loadNearestCountry,
loadCountryList,
clearAuthError,
goToAuthQrCode,
setSettingOption,
@ -66,13 +72,13 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
const suggestedLanguage = getSuggestedLanguage();
const continueText = useLangString(suggestedLanguage, 'ContinueOnThisLanguage');
const [country, setCountry] = useState<Country | undefined>();
const [country, setCountry] = useState<ApiCountryCode | undefined>();
const [phoneNumber, setPhoneNumber] = useState<string | undefined>();
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<StateProps & DispatchProps> = ({
}, [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<StateProps & DispatchProps> = ({
});
}, []);
const handleCountryChange = useCallback((value: ApiCountryCode) => {
setCountry(value);
setPhoneNumber('');
}, []);
const handlePhoneNumberChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (authError) {
clearAuthError();
@ -169,7 +185,7 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
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<HTMLInputElement>) => {
@ -201,7 +217,7 @@ const AuthPhoneNumber: FC<StateProps & DispatchProps> = ({
id="sign-in-phone-code"
value={country}
isLoading={!authNearestCountry && !country}
onChange={setCountry}
onChange={handleCountryChange}
/>
<InputText
ref={inputRef}
@ -244,6 +260,7 @@ export default memo(withGlobal(
(global): StateProps => {
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',
]),

View File

@ -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<OwnProps> = ({
const CountryCodeInput: FC<OwnProps & StateProps> = ({
id,
value,
isLoading,
onChange,
phoneCodeList,
}) => {
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(null);
const [filter, setFilter] = useState<string | undefined>();
const [filteredList, setFilteredList] = useState(countryList);
const [filteredList, setFilteredList] = useState<ApiCountryCode[]>([]);
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<HTMLElement>) => {
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<HTMLInputElement>) => {
updateFilter(e.currentTarget.value);
}, []);
}, [updateFilter]);
const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.keyCode !== 8) {
@ -69,7 +84,7 @@ const CountryCodeInput: FC<OwnProps> = ({
}
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<OwnProps> = ({
formEl.scrollTo({ top: formEl.scrollHeight, behavior: 'smooth' });
};
const inputValue = filter !== undefined
? filter
: (value?.name) || '';
const handleCodeInput = (e: React.FormEvent<HTMLInputElement>) => {
handleInput(e);
handleTrigger();
};
const inputValue = filter ?? (value?.name || value?.defaultName || '');
return (
<div className={buildClassName('input-group', value && 'touched')}>
@ -102,7 +120,7 @@ const CountryCodeInput: FC<OwnProps> = ({
autoComplete="off"
onClick={handleTrigger}
onFocus={handleTrigger}
onInput={handleInput}
onInput={handleCodeInput}
onKeyDown={handleInputKeyDown}
/>
<label>{lang('Login.SelectCountry.Title')}</label>
@ -120,18 +138,19 @@ const CountryCodeInput: FC<OwnProps> = ({
className="CountryCodeInput"
trigger={CodeInput}
>
{filteredList.map((country: Country) => (
<MenuItem
key={country.id}
className={value && country.id === value.id ? 'selected' : ''}
onClick={handleChange}
>
<span data-country-id={country.id} />
<span className="country-flag">{renderText(country.flag, ['hq_emoji'])}</span>
<span className="country-name">{country.name}</span>
<span className="country-code">{country.code}</span>
</MenuItem>
))}
{filteredList
.map((country: ApiCountryCode) => (
<MenuItem
key={country.countryCode}
className={value && country.iso2 === value.iso2 ? 'selected' : ''}
onClick={handleChange}
>
<span data-country-code={country.countryCode} />
<span className="country-flag">{renderText(isoToEmoji(country.iso2), ['hq_emoji'])}</span>
<span className="country-name">{country.name || country.defaultName}</span>
<span className="country-code">{country.countryCode}</span>
</MenuItem>
))}
{!filteredList.length && (
<MenuItem
key="no-results"
@ -145,10 +164,19 @@ const CountryCodeInput: FC<OwnProps> = ({
);
};
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<OwnProps>(
(global): StateProps => {
const { countryList: { phoneCodes: phoneCodeList } } = global;
return {
phoneCodeList,
};
},
)(CountryCodeInput));

View File

@ -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<GlobalState, 'lastSyncTime'>;
type DispatchProps = Pick<GlobalActions, 'loadFullUser' | 'updateChatMutedState' | 'showNotification'>;
@ -42,6 +43,7 @@ const ChatExtra: FC<OwnProps & StateProps & DispatchProps> = ({
forceShowSelf,
canInviteUsers,
isMuted,
phoneCodeList,
loadFullUser,
showNotification,
updateChatMutedState,
@ -75,7 +77,7 @@ const ChatExtra: FC<OwnProps & StateProps & DispatchProps> = ({
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<OwnProps & StateProps & DispatchProps> = ({
export default memo(withGlobal<OwnProps>(
(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<OwnProps>(
);
return {
lastSyncTime, chat, user, canInviteUsers, isMuted,
lastSyncTime, phoneCodeList, chat, user, canInviteUsers, isMuted,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -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<number, ApiChat>;
usersByIds: Record<number, ApiUser>;
blockedIds: number[];
phoneCodeList: ApiCountryCode[];
};
type DispatchProps = Pick<GlobalActions, 'unblockContact'>;
@ -44,6 +45,7 @@ const SettingsPrivacyBlockedUsers: FC<OwnProps & StateProps & DispatchProps> = (
chatsByIds,
usersByIds,
blockedIds,
phoneCodeList,
unblockContact,
}) => {
const handleUnblockClick = useCallback((contactId: number) => {
@ -83,7 +85,7 @@ const SettingsPrivacyBlockedUsers: FC<OwnProps & StateProps & DispatchProps> = (
<div className="contact-info" dir="auto">
<h3 dir="auto">{renderText((isPrivate ? getUserFullName(user) : getChatTitle(lang, chat!)) || '')}</h3>
{user?.phoneNumber && (
<div className="contact-phone" dir="auto">{formatPhoneNumberWithCode(user.phoneNumber)}</div>
<div className="contact-phone" dir="auto">{formatPhoneNumberWithCode(phoneCodeList, user.phoneNumber)}</div>
)}
{user && !user.phoneNumber && user.username && (
<div className="contact-username" dir="auto">@{user.username}</div>
@ -142,12 +144,16 @@ export default memo(withGlobal<OwnProps>(
blocked: {
ids,
},
countryList: {
phoneCodes: phoneCodeList,
},
} = global;
return {
chatsByIds,
usersByIds,
blockedIds: ids,
phoneCodeList,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['unblockContact']),

View File

@ -64,7 +64,7 @@ type StateProps = {
type DispatchProps = Pick<GlobalActions, (
'loadAnimatedEmojis' | 'loadNotificationSettings' | 'loadNotificationExceptions' | 'updateIsOnline' |
'loadTopInlineBots' | 'loadEmojiKeywords' | 'openStickerSetShortName'
'loadTopInlineBots' | 'loadEmojiKeywords' | 'openStickerSetShortName' | 'loadCountryList'
)>;
const NOTIFICATION_INTERVAL = 1000;
@ -95,6 +95,7 @@ const Main: FC<StateProps & DispatchProps> = ({
updateIsOnline,
loadTopInlineBots,
loadEmojiKeywords,
loadCountryList,
openStickerSetShortName,
}) => {
if (DEBUG && !DEBUG_isLogged) {
@ -116,10 +117,12 @@ const Main: FC<StateProps & DispatchProps> = ({
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));

View File

@ -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<GlobalActions, 'openUserInfo'>;
const Contact: FC<OwnProps & StateProps & DispatchProps> = ({
contact, user, openUserInfo,
contact, user, openUserInfo, phoneCodeList,
}) => {
const {
firstName,
@ -45,7 +46,7 @@ const Contact: FC<OwnProps & StateProps & DispatchProps> = ({
<Avatar size="large" user={user} text={firstName || lastName} />
<div className="contact-info">
<div className="contact-name">{firstName} {lastName}</div>
<div className="contact-phone">{formatPhoneNumberWithCode(phoneNumber)}</div>
<div className="contact-phone">{formatPhoneNumberWithCode(phoneCodeList, phoneNumber)}</div>
</div>
</div>
);
@ -53,8 +54,10 @@ const Contact: FC<OwnProps & StateProps & DispatchProps> = ({
export default withGlobal<OwnProps>(
(global, { contact }): StateProps => {
const { countryList: { phoneCodes: phoneCodeList } } = global;
return {
user: selectUser(global, contact.userId),
phoneCodeList,
};
},
(setGlobal, actions): DispatchProps => pick(actions, [

View File

@ -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';

View File

@ -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';

View File

@ -15,6 +15,10 @@ export const INITIAL_STATE: GlobalState = {
serverTimeOffset: 0,
authRememberMe: true,
countryList: {
phoneCodes: [],
general: [],
},
blocked: {
ids: [],

View File

@ -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

View File

@ -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;

View File

@ -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<int> = messages.AffectedMessages;
channels.getMessages#ad8c9a23 channel:InputChannel id:Vector<InputMessage> = messages.Messages;

View File

@ -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<int> = messages.AffectedMessages;
channels.getMessages#ad8c9a23 channel:InputChannel id:Vector<InputMessage> = messages.Messages;

View File

@ -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,

View File

@ -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);
}

View File

@ -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 };