Show confirmation modal before joining with invite links (#1225)

This commit is contained in:
Alexander Zinchuk 2021-07-03 15:34:03 +03:00
parent 676e914597
commit 7c860f27b3
21 changed files with 232 additions and 124 deletions

View File

@ -907,12 +907,15 @@ export async function openChatByInvite(hash: string) {
let chat: ApiChat | undefined;
if (result instanceof GramJs.ChatInvite) {
const updates = await invokeRequest(new GramJs.messages.ImportChatInvite({ hash }), true);
if (!(updates instanceof GramJs.Updates) || !updates.chats.length) {
return undefined;
}
chat = buildApiChatFromPreview(updates.chats[0]);
onUpdate({
'@type': 'showInvite',
data: {
title: result.title,
hash,
participantsCount: result.participantsCount,
isChannel: result.channel,
},
});
} else {
chat = buildApiChatFromPreview(result.chat);
@ -978,3 +981,14 @@ function updateLocalDb(result: (
});
}
}
export async function importChatInvite({ hash }: {hash: string}) {
const updates = await invokeRequest(new GramJs.messages.ImportChatInvite({ hash }), true);
if (!(updates instanceof GramJs.Updates) || !updates.chats.length) {
return undefined;
}
const chat = buildApiChatFromPreview(updates.chats[0]);
return chat;
}

View File

@ -14,7 +14,7 @@ export {
fetchChatFolders, editChatFolder, deleteChatFolder, fetchRecommendedChatFolders,
getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights,
updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup,
migrateChat, openChatByInvite, fetchMembers,
migrateChat, openChatByInvite, fetchMembers, importChatInvite,
} from './chats';
export {

View File

@ -74,6 +74,11 @@ export type ApiUpdateChatJoin = {
id: number;
};
export type ApiUpdateShowInvite = {
'@type': 'showInvite';
data: ApiInviteInfo;
};
export type ApiUpdateChatLeave = {
'@type': 'updateChatLeave';
id: number;
@ -311,6 +316,13 @@ export type ApiError = {
textParams?: Record<string, string>;
};
export type ApiInviteInfo = {
title: string;
hash: string;
isChannel?: boolean;
participantsCount?: number;
};
export type ApiUpdateError = {
'@type': 'error';
error: ApiError;
@ -395,7 +407,7 @@ export type ApiUpdate = (
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
ApiUpdateTwoFaError | updateTwoFaStateWaitCode |
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
ApiUpdateServerTimeOffset
ApiUpdateServerTimeOffset | ApiUpdateShowInvite
);
export type OnApiUpdate = (update: ApiUpdate) => void;

View File

@ -1,7 +1,7 @@
export { default as MediaViewer } from '../components/mediaViewer/MediaViewer';
export { default as ForwardPicker } from '../components/main/ForwardPicker';
export { default as Errors } from '../components/main/Errors';
export { default as Dialogs } from '../components/main/Dialogs';
export { default as Notifications } from '../components/main/Notifications';
export { default as SafeLinkModal } from '../components/main/SafeLinkModal';
export { default as HistoryCalendar } from '../components/main/HistoryCalendar';

View File

@ -35,7 +35,7 @@ type StateProps = {
notifyExceptions?: Record<number, NotifyException>;
};
type DispatchProps = Pick<GlobalActions, 'loadRecommendedChatFolders' | 'addChatFolder' | 'showError'>;
type DispatchProps = Pick<GlobalActions, 'loadRecommendedChatFolders' | 'addChatFolder' | 'showDialog'>;
const runThrottledForLoadRecommended = throttle((cb) => cb(), 60000, true);
@ -53,7 +53,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
notifyExceptions,
loadRecommendedChatFolders,
addChatFolder,
showError,
showDialog,
}) => {
const [animationData, setAnimationData] = useState<Record<string, any>>();
const [isAnimationLoaded, setIsAnimationLoaded] = useState(false);
@ -75,8 +75,8 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
const handleCreateFolder = useCallback(() => {
if (Object.keys(foldersById).length >= MAX_ALLOWED_FOLDERS) {
showError({
error: {
showDialog({
data: {
message: 'DIALOG_FILTERS_TOO_MUCH',
},
});
@ -85,7 +85,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
}
onCreateFolder();
}, [foldersById, showError, onCreateFolder]);
}, [foldersById, showDialog, onCreateFolder]);
const lang = useLang();
@ -111,8 +111,8 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
const handleCreateFolderFromRecommended = useCallback((folder: ApiChatFolder) => {
if (Object.keys(foldersById).length >= MAX_ALLOWED_FOLDERS) {
showError({
error: {
showDialog({
data: {
message: 'DIALOG_FILTERS_TOO_MUCH',
},
});
@ -121,7 +121,7 @@ const SettingsFoldersMain: FC<OwnProps & StateProps & DispatchProps> = ({
}
addChatFolder({ folder });
}, [foldersById, addChatFolder, showError]);
}, [foldersById, addChatFolder, showDialog]);
return (
<div className="settings-content custom-scroll">
@ -238,5 +238,5 @@ export default memo(withGlobal<OwnProps>(
notifyExceptions: selectNotifyExceptions(global),
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['loadRecommendedChatFolders', 'addChatFolder', 'showError']),
(setGlobal, actions): DispatchProps => pick(actions, ['loadRecommendedChatFolders', 'addChatFolder', 'showDialog']),
)(SettingsFoldersMain));

View File

@ -3,11 +3,11 @@ import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const ErrorsAsync: FC = ({ isOpen }) => {
const Errors = useModuleLoader(Bundles.Extra, 'Errors', !isOpen);
const DialogsAsync: FC = ({ isOpen }) => {
const Dialogs = useModuleLoader(Bundles.Extra, 'Dialogs', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return Errors ? <Errors /> : undefined;
return Dialogs ? <Dialogs /> : undefined;
};
export default memo(ErrorsAsync);
export default memo(DialogsAsync);

View File

@ -1,4 +1,4 @@
#Errors {
#Dialogs {
position: fixed;
top: 0;
left: 0;
@ -6,3 +6,11 @@
height: 100vh;
z-index: var(--z-modal);
}
.buttons {
display: flex;
button {
flex: 1;
}
}

View File

@ -0,0 +1,103 @@
import React, { FC, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { ApiError, ApiInviteInfo } from '../../api/types';
import getReadableErrorText from '../../util/getReadableErrorText';
import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import './Dialogs.scss';
type StateProps = {
dialogs: (ApiError | ApiInviteInfo)[];
};
type DispatchProps = Pick<GlobalActions, 'dismissDialog' | 'acceptInviteConfirmation'>;
const Dialogs: FC<StateProps & DispatchProps> = ({ dialogs, dismissDialog, acceptInviteConfirmation }) => {
const lang = useLang();
if (!dialogs.length) {
return undefined;
}
const renderInvite = (invite: ApiInviteInfo) => {
const {
hash, title, participantsCount, isChannel,
} = invite;
const handleJoinClick = () => {
acceptInviteConfirmation({
hash,
});
dismissDialog();
};
const participantsText = isChannel
? lang('Subscribers', participantsCount, 'i')
: lang('Members', participantsCount, 'i');
const joinText = isChannel ? lang('ChannelJoin') : lang('JoinGroup');
return (
<Modal
isOpen
onClose={dismissDialog}
className="error"
title={title}
>
{participantsCount !== undefined && <p>{participantsText}</p>}
<Button isText className="confirm-dialog-button" onClick={handleJoinClick}>{joinText}</Button>
<Button isText className="confirm-dialog-button" onClick={dismissDialog}>{lang('Cancel')}</Button>
</Modal>
);
};
const renderError = (error: ApiError) => {
return (
<Modal
isOpen
onClose={dismissDialog}
className="error"
title={getErrorHeader(error)}
>
<p>{getReadableErrorText(error)}</p>
<div className="buttons">
<Button isText onClick={dismissDialog}>{lang('OK')}</Button>
</div>
</Modal>
);
};
const renderDialog = (dialog: ApiError | ApiInviteInfo) => {
if ('hash' in dialog) {
return renderInvite(dialog);
}
return renderError(dialog);
};
return (
<div id="Dialogs">
{dialogs.map(renderDialog)}
</div>
);
};
function getErrorHeader(error: ApiError) {
if (error.isSlowMode) {
return 'Slowmode enabled';
}
return 'Something went wrong';
}
export default memo(withGlobal(
(global): StateProps => pick(global, ['dialogs']),
(setGlobal, actions): DispatchProps => pick(actions, ['dismissDialog', 'acceptInviteConfirmation']),
)(Dialogs));

View File

@ -1,57 +0,0 @@
import React, { FC, memo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types';
import { ApiError } from '../../api/types';
import getReadableErrorText from '../../util/getReadableErrorText';
import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import './Errors.scss';
type StateProps = {
errors: ApiError[];
};
type DispatchProps = Pick<GlobalActions, 'dismissError'>;
const Errors: FC<StateProps & DispatchProps> = ({ errors, dismissError }) => {
const lang = useLang();
if (!errors.length) {
return undefined;
}
return (
<div id="Errors">
{errors.map((error) => (
<Modal
isOpen
onClose={dismissError}
className="error"
title={getErrorHeader(error)}
>
<p>{getReadableErrorText(error)}</p>
<Button isText onClick={dismissError}>{lang('OK')}</Button>
</Modal>
))}
</div>
);
};
function getErrorHeader(error: ApiError) {
if (error.isSlowMode) {
return 'Slowmode enabled';
}
return 'Something went wrong';
}
export default memo(withGlobal(
(global): StateProps => pick(global, ['errors']),
(setGlobal, actions): DispatchProps => pick(actions, ['dismissError']),
)(Errors));

View File

@ -30,7 +30,7 @@ import RightColumn from '../right/RightColumn';
import MediaViewer from '../mediaViewer/MediaViewer.async';
import AudioPlayer from '../middle/AudioPlayer';
import Notifications from './Notifications.async';
import Errors from './Errors.async';
import Dialogs from './Dialogs.async';
import ForwardPicker from './ForwardPicker.async';
import SafeLinkModal from './SafeLinkModal.async';
import HistoryCalendar from './HistoryCalendar.async';
@ -45,7 +45,7 @@ type StateProps = {
isMediaViewerOpen: boolean;
isForwardModalOpen: boolean;
hasNotifications: boolean;
hasErrors: boolean;
hasDialogs: boolean;
audioMessage?: ApiMessage;
safeLinkModalUrl?: string;
isHistoryCalendarOpen: boolean;
@ -71,7 +71,7 @@ const Main: FC<StateProps & DispatchProps> = ({
isForwardModalOpen,
animationLevel,
hasNotifications,
hasErrors,
hasDialogs,
audioMessage,
safeLinkModalUrl,
isHistoryCalendarOpen,
@ -192,7 +192,7 @@ const Main: FC<StateProps & DispatchProps> = ({
<MediaViewer isOpen={isMediaViewerOpen} />
<ForwardPicker isOpen={isForwardModalOpen} />
<Notifications isOpen={hasNotifications} />
<Errors isOpen={hasErrors} />
<Dialogs isOpen={hasDialogs} />
{audioMessage && <AudioPlayer key={audioMessage.id} message={audioMessage} noUi />}
<SafeLinkModal url={safeLinkModalUrl} />
<HistoryCalendar isOpen={isHistoryCalendarOpen} />
@ -228,7 +228,7 @@ export default memo(withGlobal(
isMediaViewerOpen: selectIsMediaViewerOpen(global),
isForwardModalOpen: selectIsForwardModalOpen(global),
hasNotifications: Boolean(global.notifications.length),
hasErrors: Boolean(global.errors.length),
hasDialogs: Boolean(global.dialogs.length),
audioMessage,
safeLinkModalUrl: global.safeLinkModalUrl,
isHistoryCalendarOpen: Boolean(global.historyCalendarSelectedAt),

View File

@ -128,7 +128,7 @@ type StateProps = {
type DispatchProps = Pick<GlobalActions, (
'sendMessage' | 'editMessage' | 'saveDraft' | 'forwardMessages' |
'clearDraft' | 'showError' | 'setStickerSearchQuery' | 'setGifSearchQuery' |
'clearDraft' | 'showDialog' | 'setStickerSearchQuery' | 'setGifSearchQuery' |
'openPollModal' | 'closePollModal' | 'loadScheduledHistory' | 'openChat' | 'closePaymentModal' |
'clearReceipt' | 'addRecentEmoji' | 'loadEmojiKeywords'
)>;
@ -189,7 +189,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
editMessage,
saveDraft,
clearDraft,
showError,
showDialog,
setStickerSearchQuery,
setGifSearchQuery,
forwardMessages,
@ -428,8 +428,8 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
if (currentAttachments.length && text && text.length > CAPTION_MAX_LENGTH) {
const extraLength = text.length - CAPTION_MAX_LENGTH;
showError({
error: {
showDialog({
data: {
message: 'CAPTION_TOO_LONG_PLEASE_REMOVE_CHARACTERS',
textParams: {
'{EXTRA_CHARS_COUNT}': extraLength,
@ -454,8 +454,8 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
const secondsRemaining = nextSendDateNotReached
? slowMode.nextSendDate! - nowSeconds
: slowMode.seconds - secondsSinceLastMessage!;
showError({
error: {
showDialog({
data: {
message: `A wait of ${secondsRemaining} seconds is required before sending another message in this chat`,
isSlowMode: true,
},
@ -488,7 +488,7 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
requestAnimationFrame(resetComposer);
}, [
connectionState, attachments, activeVoiceRecording, isForwarding, serverTimeOffset, clearDraft, chatId,
resetComposer, stopRecordingVoice, showError, slowMode, isAdmin, sendMessage, forwardMessages,
resetComposer, stopRecordingVoice, showDialog, slowMode, isAdmin, sendMessage, forwardMessages,
]);
const handleStickerSelect = useCallback((sticker: ApiSticker) => {
@ -972,7 +972,7 @@ export default memo(withGlobal<OwnProps>(
'editMessage',
'saveDraft',
'clearDraft',
'showError',
'showDialog',
'setStickerSearchQuery',
'setGifSearchQuery',
'forwardMessages',

View File

@ -5,12 +5,12 @@ import { withGlobal } from '../../lib/teact/teactn';
import { GlobalActions, GlobalState } from '../../global/types';
import { PaymentStep, ShippingOption, Price } from '../../types';
import { ApiError } from '../../api/types';
import { ApiError, ApiInviteInfo } from '../../api/types';
import { pick } from '../../util/iteratees';
import { getCurrencySign } from '../middle/helpers/getCurrencySign';
import { detectCardTypeText } from '../common/helpers/detectCardType';
import { getShippingError } from '../../modules/helpers/payments';
import { getShippingErrors } from '../../modules/helpers/payments';
import usePaymentReducer, { FormState } from '../../hooks/reducers/usePaymentReducer';
import useLang from '../../hooks/useLang';
@ -46,7 +46,7 @@ type StateProps = {
needCardholderName?: boolean;
needCountry?: boolean;
needZip?: boolean;
globalErrors?: ApiError[];
globalDialogs?: (ApiError | ApiInviteInfo)[];
};
type GlobalStateProps = Pick<GlobalState['payment'], 'step' | 'shippingOptions' |
@ -79,7 +79,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
needCountry,
needZip,
error,
globalErrors,
globalDialogs,
validateRequestedInfo,
sendPaymentForm,
setPaymentStep,
@ -92,10 +92,10 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
const lang = useLang();
useEffect(() => {
if (step || error || globalErrors) {
if (step || error || globalDialogs) {
setIsLoading(false);
}
}, [step, error, globalErrors]);
}, [step, error, globalDialogs]);
useEffect(() => {
if (error && error.field) {
@ -107,8 +107,8 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
});
return;
}
if (globalErrors && globalErrors.length) {
const errors = getShippingError(globalErrors);
if (globalDialogs && globalDialogs.length) {
const errors = getShippingErrors(globalDialogs);
paymentDispatch({
type: 'setFormErrors',
payload: {
@ -116,7 +116,7 @@ const Invoice: FC<OwnProps & StateProps & GlobalStateProps & DispatchProps> = ({
},
});
}
}, [error, globalErrors, paymentDispatch]);
}, [error, globalDialogs, paymentDispatch]);
useEffect(() => {
if (savedInfo) {
@ -412,7 +412,7 @@ export default memo(withGlobal<OwnProps>(
needCountry,
needZip,
error,
globalErrors: global.errors,
globalDialogs: global.dialogs,
};
},
(setGlobal, actions): DispatchProps => {

View File

@ -99,7 +99,7 @@ export const INITIAL_STATE: GlobalState = {
notifications: [],
errors: [],
dialogs: [],
activeSessions: [],

View File

@ -18,6 +18,7 @@ import {
ApiPaymentSavedInfo,
ApiSession,
ApiNewPoll,
ApiInviteInfo,
} from '../api/types';
import {
FocusDirection,
@ -364,7 +365,7 @@ export type GlobalState = {
};
notifications: ApiNotification[];
errors: ApiError[];
dialogs: (ApiError | ApiInviteInfo)[];
// TODO Move to settings
activeSessions: ApiSession[];
@ -399,7 +400,7 @@ export type GlobalState = {
export type ActionTypes = (
// system
'init' | 'reset' | 'disconnect' | 'initApi' | 'apiUpdate' | 'sync' | 'saveSession' | 'afterSync' |
'showNotification' | 'dismissNotification' | 'showError' | 'dismissError' |
'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' |
// ui
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'addRecentSticker' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
@ -438,6 +439,7 @@ export type ActionTypes = (
'toggleManagement' | 'closeManagement' | 'checkPublicLink' | 'updatePublicLink' | 'updatePrivateLink' |
// groups
'togglePreHistoryHidden' | 'updateChatDefaultBannedRights' | 'updateChatMemberBannedRights' | 'updateChatAdmin' |
'acceptInviteConfirmation' |
// users
'loadFullUser' | 'openUserInfo' | 'loadNearestCountry' | 'loadTopUsers' | 'loadContactList' | 'loadCurrentUser' |
'updateProfile' | 'checkUsername' | 'updateContact' | 'deleteUser' | 'loadUser' |

View File

@ -84,7 +84,7 @@ async function answerCallbackButton(chat: ApiChat, messageId: number, data: stri
const { message, alert: isError } = result;
if (isError) {
getDispatch().showError({ error: { message } });
getDispatch().showDialog({ data: { message } });
} else {
getDispatch().showNotification({ message });
}

View File

@ -403,6 +403,18 @@ addReducer('openTelegramLink', (global, actions, payload) => {
}
});
addReducer('acceptInviteConfirmation', (global, actions, payload) => {
const { hash } = payload!;
(async () => {
const result = await callApi('importChatInvite', { hash });
if (!result) {
return;
}
actions.openChat({ id: result.id });
})();
});
addReducer('openChatByUsername', (global, actions, payload) => {
const { username } = payload!;

View File

@ -373,6 +373,14 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
setGlobal(global);
}
break;
}
case 'showInvite': {
const { data } = update;
actions.showDialog({ data });
break;
}
}
});

View File

@ -59,7 +59,7 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => {
actions.signOut();
}
actions.showError({ error: update.error });
actions.showDialog({ data: update.error });
break;
}

View File

@ -5,6 +5,7 @@ import { GlobalState } from '../../../global/types';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment';
import getReadableErrorText from '../../../util/getReadableErrorText';
import { selectCurrentMessageList } from '../../selectors';
import { ApiError } from '../../../api/types';
const MAX_STORED_EMOJIS = 18; // Represents two rows of recent emojis
@ -158,36 +159,38 @@ addReducer('dismissNotification', (global) => {
};
});
addReducer('showError', (global, actions, payload) => {
const { error } = payload!;
addReducer('showDialog', (global, actions, payload) => {
const { data } = payload!;
// Filter out errors that we don't want to show to the user
if (!getReadableErrorText(error)) {
if ('message' in data && !getReadableErrorText(data)) {
return global;
}
const newErrors = [...global.errors];
const existingErrorIndex = newErrors.findIndex((err) => err.message === error.message);
if (existingErrorIndex !== -1) {
newErrors.splice(existingErrorIndex, 1);
const newDialogs = [...global.dialogs];
if ('message' in data) {
const existingErrorIndex = newDialogs.findIndex((err) => (err as ApiError).message === data.message);
if (existingErrorIndex !== -1) {
newDialogs.splice(existingErrorIndex, 1);
}
}
newErrors.push(error);
newDialogs.push(data);
return {
...global,
errors: newErrors,
dialogs: newDialogs,
};
});
addReducer('dismissError', (global) => {
const newErrors = [...global.errors];
addReducer('dismissDialog', (global) => {
const newDialogs = [...global.dialogs];
newErrors.pop();
newDialogs.pop();
return {
...global,
errors: newErrors,
dialogs: newDialogs,
};
});

View File

@ -1,3 +1,5 @@
import { ApiError, ApiInviteInfo } from '../../api/types';
const STRIPE_ERRORS: Record<string, Record<string, string>> = {
missing_payment_information: {
field: 'cardNumber',
@ -91,8 +93,9 @@ const SHIPPING_ERRORS: Record<string, Record<string, string>> = {
};
export function getShippingError(errors: Record<number, { message: string }>) {
return Object.values(errors).reduce((acc, cur) => {
export function getShippingErrors(dialogs: (ApiError | ApiInviteInfo)[]) {
return Object.values(dialogs).reduce((acc, cur) => {
if (!('message' in cur)) return acc;
const error = SHIPPING_ERRORS[cur.message];
if (error) {
acc = {

View File

@ -52,7 +52,7 @@ if (IS_SERVICE_WORKER_SUPPORTED) {
// eslint-disable-next-line no-console
console.error('[SW] ServiceWorker not available');
}
getDispatch().showError({ error: { message: 'SERVICE_WORKER_DISABLED' } });
getDispatch().showDialog({ data: { message: 'SERVICE_WORKER_DISABLED' } });
}
} catch (err) {
if (DEBUG) {