Get countries and phone codes from server (#1422)
This commit is contained in:
parent
4a6dc47be0
commit
00daafc654
8
src/@types/global.d.ts
vendored
8
src/@types/global.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -26,7 +26,7 @@ export {
|
||||
} from './messages';
|
||||
|
||||
export {
|
||||
fetchFullUser, fetchNearestCountry,
|
||||
fetchFullUser, fetchNearestCountry, fetchCountryList,
|
||||
fetchTopUsers, fetchContactList, fetchUsers,
|
||||
updateContact, deleteUser, fetchProfilePhotos,
|
||||
} from './users';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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',
|
||||
]),
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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']),
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -15,6 +15,10 @@ export const INITIAL_STATE: GlobalState = {
|
||||
serverTimeOffset: 0,
|
||||
|
||||
authRememberMe: true,
|
||||
countryList: {
|
||||
phoneCodes: [],
|
||||
general: [],
|
||||
},
|
||||
|
||||
blocked: {
|
||||
ids: [],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user