From AngularJS to React at

ProtonMail

Matthieu Lux
@swiip

ProtonMail

Web developer at

ProtonMail

Bob writes to Alice

Message is encrypted with Alice public key

Server can't read the message

Alice use its private key to decrypt the message

Alice reads the message from Bob

.js

Maintainer and first contributor of

OpenPGP.js

v1

2013

v2

2015

v3

2016

v4

2019-20

Monolyth

syndrome

Perf

limitations

Team

Considerations

Developer

Considerations

Frontend is

visibility

Frontend voice

in decision

react-components

proton-shared

design-system

proton-mail

proton-mail-settings

proton-contacts

proton-calendar

proton-drive

proton-vpn-settings

proton-mail-old

still not enough splitting

hard to maintain global coherence

monorepo on its way

interface Props {
    element?: Element;
}

const ItemStar = ({ element = {} }: Props) => {
    const api = useApi();
    const isConversation = isConversationTest(element);
    const { call } = useEventManager();
    const [loading, withLoading] = useLoading();
    const isStarred = testIsStarred(element);
    const iconName = isStarred ? 'starfull' : 'star';

    const handleClick = async () => {
        const action = isConversation
            ? isStarred
                ? unlabelConversations
                : labelConversations
            : isStarred
            ? unlabelMessages
            : labelMessages;
        await api(action({ LabelID: MAILBOX_LABEL_IDS.STARRED, IDs: [element.ID] }));
        await call();
    };

    return (
        <button
            disabled={loading}
            type="button"
            className={classnames([
                'starbutton item-star inline-flex',
                isStarred && 'starbutton--is-starred'
            ])}
            onClick={() => withLoading(handleClick())}
        >
            <Icon name={iconName} />
        </button>
    );
};
interface Props {
    style?: CSSProperties;
    focus: boolean;
    messageID: string;
    addresses: Address[];
    onFocus: () => void;
    onClose: () => void;
}

const Composer = ({
    style: inputStyle = {},
    focus, messageID,
    addresses, onFocus,
    onClose: inputOnClose
}: Props) => {
    const [mailSettings] = useMailSettings();
    const [width, height] = useWindowSize();
    const { createNotification } = useNotifications();

    // Minimized status of the composer
    const { state: minimized, toggle: toggleMinimized } = useToggle(false);

    // Maximized status of the composer
    const { state: maximized, toggle: toggleMaximized } = useToggle(false);

    // Indicates that the composer is in its initial opening
    // Needed to be able to force focus only at first time
    const [opening, setOpening] = useState(true);

    // Indicates that the composer is being closed
    // Needed to keep component alive while saving/deleting on close
    const [closing, setClosing] = useState(false);

    // Indicates that the composer is sending the message
    // Some behavior has to change, example, stop auto saving
    const [sending, setSending] = useState(false);

    // Indicates that the composer is open but the edited message is not yet ready
    // Needed to prevent edition while data is not ready
    const [editorReady, setEditorReady] = useState(false);

    // Flag representing the presence of an inner modal on the composer
    const [innerModal, setInnerModal] = useState(ComposerInnerModal.None);

    // Model value of the edited message in the composer
    const [modelMessage, setModelMessage] = useState<MessageExtended>({
        localID: messageID,
        data: {
            Subject: '' // Needed fot the subject input controlled field
        }
    });

    // Map of send preferences for each recipient
    const [mapSendPrefs, setMapSendPrefs] = useState<MapSendPreferences>({});

    // Synced with server version of the edited message
    const { message: syncedMessage, addAction } = useMessage(messageID);

    // All message actions
    const initialize = useInitializeMessage(syncedMessage.localID);
    const createDraft = useCreateDraft();
    const saveDraft = useSaveDraft();
    const sendMessage = useSendMessage();
    const deleteDraft = useDeleteDraft(syncedMessage);

    const syncLock = syncedMessage.actionStatus !== undefined;
    const syncActivity = syncedMessage.actionStatus || '';

    // Manage focus from the container yet keeping logic in each component
    const addressesBlurRef = useRef<() => void>(noop);
    const addressesFocusRef = useRef<() => void>(noop);
    const contentFocusRef = useRef<() => void>(noop);

    // Get a ref on the editor to trigger insertion of embedded images
    const editorActionsRef: EditorActionsRef = useRef();

    // onClose handler can be called in a async handler
    // Input onClose ref can change in the meantime
    const onClose = useHandler(inputOnClose);

    useEffect(() => {
        if (!syncLock && !syncedMessage.data?.ID && isNewDraft(syncedMessage.localID)) {
            addAction(() => createDraft(syncedMessage));
        }

        if (
            !syncLock &&
            (syncedMessage.data?.ID || (!syncedMessage.data?.ID && !isNewDraft(syncedMessage.localID))) &&
            syncedMessage.initialized === undefined
        ) {
            addAction(initialize);
        }

        if (modelMessage.document === undefined || modelMessage.data?.ID !== syncedMessage.data?.ID) {
            setModelMessage({
                ...syncedMessage,
                ...modelMessage,
                data: { ...syncedMessage.data, ...modelMessage.data },
                document: syncedMessage.document
            });
        }
    }, [syncLock, syncedMessage.document, syncedMessage.data?.ID]);

    useEffect(() => {
        if (!maximized && height - COMPOSER_VERTICAL_GUTTER - HEADER_HEIGHT < COMPOSER_HEIGHT - COMPOSER_SWITCH_MODE) {
            toggleMaximized();
        }
        if (maximized && height - COMPOSER_VERTICAL_GUTTER - HEADER_HEIGHT > COMPOSER_HEIGHT + COMPOSER_SWITCH_MODE) {
            toggleMaximized();
        }
    }, [height]);

    // Manage focus at opening
    useEffect(() => {
        if (!opening || !editorReady) {
            return;
        }
        setTimeout(() => {
            if (getRecipients(syncedMessage.data).length === 0) {
                addressesFocusRef.current();
            } else {
                contentFocusRef.current();
            }
        });
        setOpening(false);
    }, [editorReady]);

    const autoSave = useHandler(
        (message: MessageExtended) => {
            if (!sending) {
                addAction(() => saveDraft(message));
            }
        },
        { debounce: 2000 }
    );

    const handleChange: MessageChange = (message) => {
        if (message instanceof Function) {
            setModelMessage((modelMessage) => {
                const newModelMessage = mergeMessages(modelMessage, message(modelMessage));
                autoSave(newModelMessage);
                return newModelMessage;
            });
        } else {
            const newModelMessage = mergeMessages(modelMessage, message);
            setModelMessage(newModelMessage);
            autoSave(newModelMessage);
        }
    };

    const handleChangeContent = (content: string) => {
        setContent(modelMessage, content);
        const newModelMessage = { ...modelMessage };
        setModelMessage(newModelMessage);
        autoSave(newModelMessage);
    };

    const handleChangeFlag = (changes: Map<number, boolean>) => {
        let Flags = modelMessage.data?.Flags || 0;
        changes.forEach((isAdd, flag) => {
            const action = isAdd ? setBit : clearBit;
            Flags = action(Flags, flag);
        });
        handleChange({ data: { Flags } });
    };

    const {
        pendingFiles,
        pendingUploads,
        handleAddAttachmentsStart,
        handleAddEmbeddedImages,
        handleAddAttachmentsUpload,
        handleCancelAddAttachment,
        handleRemoveAttachment,
        handleRemoveUpload
    } = useAttachments(modelMessage, handleChange, editorActionsRef);

    const save = async (messageToSave = modelMessage) => {
        await addAction(() => saveDraft(messageToSave));
        createNotification({ text: c('Info').t`Message saved` });
    };
    const handlePassword = () => {
        setInnerModal(ComposerInnerModal.Password);
    };
    const handleExpiration = () => {
        setInnerModal(ComposerInnerModal.Expiration);
    };
    const handleCloseInnerModal = () => {
        setInnerModal(ComposerInnerModal.None);
    };
    const handleSave = async () => {
        await save();
    };
    const handleSend = async () => {
        setSending(true);
        await addAction(() => sendMessage(modelMessage));
        createNotification({ text: c('Success').t`Message sent` });
        onClose();
    };
    const handleDelete = async () => {
        setClosing(true);
        await addAction(deleteDraft);
        createNotification({ text: c('Info').t`Message discarded` });
        onClose();
    };
    const handleClick = async () => {
        if (minimized) {
            toggleMinimized();
        }
        onFocus();
    };
    const handleClose = async () => {
        setClosing(true);
        await save();
        onClose();
    };
    const handleContentFocus = () => {
        addressesBlurRef.current();
        onFocus(); // Events on the main div will not fire because/ the editor is in an iframe
    };

    if (closing) {
        return null;
    }

    const style = computeStyle(inputStyle, minimized, maximized, width, height);

    return (
        <div
            className={classnames([
                'composer flex flex-column p0-5',
                !focus && 'composer-blur',
                minimized && 'composer-minimized pb0'
            ])}
            style={style}
            onFocus={onFocus}
            onClick={handleClick}
        >
            <ComposerTitleBar
                message={modelMessage}
                minimized={minimized}
                maximized={maximized}
                toggleMinimized={toggleMinimized}
                toggleMaximized={toggleMaximized}
                onClose={handleClose}
            />
            {!minimized && (
                <div className="flex flex-column flex-item-fluid relative mw100">
                    {innerModal === ComposerInnerModal.Password && (
                        <ComposerPasswordModal
                            message={modelMessage.data}
                            onClose={handleCloseInnerModal}
                            onChange={handleChange}
                        />
                    )}
                    {innerModal === ComposerInnerModal.Expiration && (
                        <ComposerExpirationModal
                            message={modelMessage.data}
                            onClose={handleCloseInnerModal}
                            onChange={handleChange}
                        />
                    )}
                    <div
                        className={classnames([
                            'flex-column flex-item-fluid mw100',
                            // Only hide the editor not to unload it each time a modal is on top
                            innerModal === ComposerInnerModal.None ? 'flex' : 'hidden'
                        ])}
                    >
                        <ComposerMeta
                            message={modelMessage}
                            addresses={addresses}
                            mailSettings={mailSettings}
                            mapSendPrefs={mapSendPrefs}
                            setMapSendPrefs={setMapSendPrefs}
                            disabled={!editorReady}
                            onChange={handleChange}
                            addressesBlurRef={addressesBlurRef}
                            addressesFocusRef={addressesFocusRef}
                        />
                        <ComposerContent
                            message={modelMessage}
                            disabled={!editorReady}
                            onEditorReady={() => setEditorReady(true)}
                            onChange={handleChange}
                            onChangeContent={handleChangeContent}
                            onChangeFlag={handleChangeFlag}
                            onFocus={handleContentFocus}
                            onAddAttachments={handleAddAttachmentsStart}
                            onAddEmbeddedImages={handleAddEmbeddedImages}
                            onCancelAddAttachment={handleCancelAddAttachment}
                            onRemoveAttachment={handleRemoveAttachment}
                            onRemoveUpload={handleRemoveUpload}
                            pendingFiles={pendingFiles}
                            pendingUploads={pendingUploads}
                            onSelectEmbedded={handleAddAttachmentsUpload}
                            contentFocusRef={contentFocusRef}
                            editorActionsRef={editorActionsRef}
                        />
                        <ComposerActions
                            message={modelMessage}
                            lock={syncLock || !editorReady}
                            activity={syncActivity}
                            onAddAttachments={handleAddAttachmentsStart}
                            onExpiration={handleExpiration}
                            onPassword={handlePassword}
                            onSave={handleSave}
                            onSend={handleSend}
                            onDelete={handleDelete}
                            addressesBlurRef={addressesBlurRef}
                        />
                    </div>
                </div>
            )}
        </div>
    );
};
const ProtonApp = ({ config, children }) => {
    const authentication = useInstance(() =>
        createAuthentication(createSecureSessionStorage([MAILBOX_PASSWORD_KEY, UID_KEY]))
    );
    const cacheRef = useRef();
    const [UID, setUID] = useState(() => authentication.getUID());
    const tempDataRef = useRef({});

    if (!cacheRef.current) {
        cacheRef.current = createCache();
    }

    const handleLogin = useCallback(({ UID: newUID, EventID, keyPassword, User }) => {
        authentication.setUID(newUID);
        authentication.setPassword(keyPassword);

        cacheRef.current.reset();
        const cache = createCache();

        // If the user was received from the login call, pre-set it directly.
        User &&
            cache.set(UserModel.key, {
                value: formatUser(User),
                status: STATUS.RESOLVED
            });

        setTmpEventID(cache, EventID);

        cacheRef.current = cache;

        setUID(newUID);
    }, []);

    const handleLogout = useCallback(() => {
        authentication.setUID();
        authentication.setPassword();

        tempDataRef.current = {};
        clearKeyCache(cacheRef.current);
        cacheRef.current.reset();
        cacheRef.current = createCache();

        setUID();
    }, []);

    const authenticationValue = useMemo(() => {
        if (!UID) {
            return {
                login: handleLogin
            };
        }
        return {
            UID,
            ...authentication,
            login: handleLogin,
            logout: handleLogout
        };
    }, [UID]);

    return (
        <ConfigProvider config={config}>
            <CompatibilityCheck>
                <Icons />
                <RightToLeftProvider>
                    <Router>
                        <React.Fragment key={UID}>
                            <NotificationsProvider>
                                <ModalsProvider>
                                    <ApiProvider UID={UID} config={config} onLogout={handleLogout}>
                                        <AuthenticationProvider store={authenticationValue}>
                                            <CacheProvider cache={cacheRef.current}>{children}</CacheProvider>
                                        </AuthenticationProvider>
                                    </ApiProvider>
                                </ModalsProvider>
                            </NotificationsProvider>
                        </React.Fragment>
                    </Router>
                </RightToLeftProvider>
            </CompatibilityCheck>
        </ConfigProvider>
    );
};
interface Props {
    onLogout: () => void;
}

const PrivateApp = ({ onLogout }: Props) => {
    return (
        <StandardPrivateApp
            fallback={false}
            openpgpConfig={{}}
            onLogout={onLogout}
            locales={locales}
        >
            <MessageProvider>
                <ConversationProvider>
                    <AttachmentProvider>
                        <ComposerContainer>
                            {({ onCompose }) => (
                                <Route
                                    path="/:labelID?/:elementID?"
                                    render={(routeProps: RouteProps) => (
                                        <PageContainer
                                            {...routeProps}
                                            onCompose={onCompose}
                                        />
                                    )}
                                />
                            )}
                        </ComposerContainer>
                    </AttachmentProvider>
                </ConversationProvider>
            </MessageProvider>
        </StandardPrivateApp>
    );
};
export type MessageCache = Cache<MessageExtended>;

/**
 * Message context containing the Message cache
 */
const MessageContext = createContext<MessageCache>(null as any);

/**
 * Hook returning the Message cache
 */
export const useMessageCache = () => useContext(MessageContext);

/**
 * Event management logic for messages
 */
const messageEventListener = (cache: MessageCache) => ({ Messages }: Event) => {
    if (!Array.isArray(Messages)) {
        return;
    }

    for (const { ID, Action, Message } of Messages) {
        let localID = ID;

        // Ignore updates for non-fetched messages.
        if (!cache.has(localID)) {
            // Search in cache for new draft with this ID
            const newDraftLocalID = Object.keys(cache.toObject())
                .filter((key) => key.startsWith(DRAFT_ID_PREFIX))
                .find((key) => cache.get(key)?.data?.ID === ID);

            if (newDraftLocalID) {
                localID = newDraftLocalID;
            } else {
                continue;
            }
        }
        if (Action === EVENT_ACTIONS.DELETE) {
            cache.delete(localID);
        }
        if (Action === EVENT_ACTIONS.UPDATE_DRAFT) {
            console.warn('Event type UPDATE_DRAFT on Message not supported', Messages);
        }
        if (Action === EVENT_ACTIONS.UPDATE_FLAGS) {
            const currentValue = cache.get(localID) as MessageExtended;

            const MessageToUpdate = parseLabelIDsInEvent(currentValue.data, Message);

            cache.set(localID, {
                ...currentValue,
                data: {
                    ...currentValue.data,
                    ...MessageToUpdate
                }
            });
        }
    }
};

interface Props {
    children?: ReactNode;
}

/**
 * Provider for the message cache and listen to event manager for updates
 */
const MessageProvider = ({ children }: Props) => {
    const { subscribe } = useEventManager();

    const cache: MessageCache = useInstance(() => {
        return createCache(createLRU({ max: 50 } as any));
    });

    useEffect(() => subscribe(messageEventListener(cache)), []);

    return <MessageContext.Provider value={cache}>{children}</MessageContext.Provider>;
};
interface UseMessage {
    (localID: string): { message: MessageExtended };
}

export const useMessage: UseMessage = (localID: string) => {
    const cache = useMessageCache();

    // Main subject of the hook
    // Will be updated based on an effect listening on the event manager
    const [message, setMessage] = useState<MessageExtended>(cache.get(localID) || { localID });

    // Update message state and listen to cache for updates on the current message
    useEffect(() => {
        if (cache.has(localID)) {
            setMessage(cache.get(localID) as MessageExtended);
        } else {
            const newMessage = { localID };
            cache.set(localID, newMessage);
            setMessage(newMessage);
        }

        return cache.subscribe((changedMessageID) => {
            if (changedMessageID === localID && cache.has(localID)) {
                setMessage(cache.get(localID) as MessageExtended);
            }
        });
    }, [localID, cache]); // The hook can be re-used for a different message

    return { message };
};

ProtonMail

export const useInitializeMessage = (localID: string, labelID?: string) => {
    const api = useApi();
    const markAs = useMarkAs();
    const messageCache = useMessageCache();
    const getMessageKeys = useMessageKeys();
    const attachmentsCache = useAttachmentCache();
    const base64Cache = useBase64Cache();
    const [mailSettings] = useMailSettings();
    const loadEmbeddedImages = useLoadEmbeddedImages(localID);
    const verifyMessage = useVerifyMessage(localID);

    return useCallback(async () => {
        // Message can change during the whole initilization sequence
        // To have the most up to date version, best is to get back to the cache version each time
        const getData = () => (messageCache.get(localID) as MessageExtendedWithData).data;

        // Cache entry will be (at least) initialized by the queue system
        const messageFromCache = messageCache.get(localID) as MessageExtended;

        // If the message is not yet loaded at all, the localID is the message ID
        if (!messageFromCache || !messageFromCache.data) {
            messageFromCache.data = { ID: localID } as Message;
        }

        updateMessageCache(messageCache, localID, { initialized: false });

        const errors: MessageErrors = {};

        let userKeys;
        let decryption;
        let preparation;
        let dataChanges;

        try {
            // Ensure the message data is loaded
            const message = await loadMessage(messageFromCache, api);
            updateMessageCache(messageCache, localID, { data: message.data });

            dataChanges = {} as Partial<Message>;

            userKeys = await getMessageKeys(message);
            const messageWithKeys = {
                ...message,
                publicKeys: [], // Signature verification are done later for performance
                privateKeys: userKeys.privateKeys,
            };

            decryption = await decryptMessage(getData(), userKeys.privateKeys, attachmentsCache);

            if (decryption.mimetype) {
                dataChanges = { ...dataChanges, MIMEType: decryption.mimetype };
            }
            const mimeAttachments = decryption.Attachments || [];
            const allAttachments = [...getData().Attachments, ...mimeAttachments];
            dataChanges = {
              ...dataChanges,
              Attachments: allAttachments,
              NumAttachments: allAttachments.length
            };

            if (decryption.errors) {
                Object.assign(errors, decryption.errors);
            }

            // Trigger all public key and signature verification but we are not waiting for it
            void verifyMessage(decryption.decryptedBody, decryption.signature);

            if (isUnreadMessage(getData())) {
                markAs([getData()], labelID, MARK_AS_STATUS.READ);
                dataChanges = { ...dataChanges, Unread: 0 };
            }

            const MIMEType = dataChanges.MIMEType || getData().MIMEType;

            preparation = isPlainText({ MIMEType })
                ? ({ plainText: decryption.decryptedBody } as any)
                : await prepareMailDocument(
                      { ...messageWithKeys, decryptedBody: decryption.decryptedBody },
                      base64Cache,
                      attachmentsCache,
                      api,
                      mailSettings
                  );
        } catch (error) {
            if (isApiError(error)) {
                errors.network = error;
            } else {
                errors.common = error;
            }
        } finally {
            updateMessageCache(messageCache, localID, {
                data: dataChanges,
                document: preparation?.document,
                plainText: preparation?.plainText,
                publicKeys: userKeys?.publicKeys,
                privateKeys: userKeys?.privateKeys,
                decryptedBody: decryption?.decryptedBody,
                signature: decryption?.signature,
                decryptedSubject: decryption?.decryptedSubject,
                // Anticipate showEmbedded flag while triggering the load just after
                showEmbeddedImages: preparation?.showEmbeddedImages,
                showRemoteImages: preparation?.showRemoteImages,
                embeddeds: preparation?.embeddeds,
                errors,
                initialized: true,
            });
        }

        if (hasShowEmbedded(mailSettings)) {
            // Load embedded images as a second step not synchronized with the initialization
            // To prevent slowing the message body when there is heavy embedded attachments
            void loadEmbeddedImages();
        }
    }, [localID]);
};
export const useSendVerifications = () => {
    const { createModal } = useModals();
    const { createNotification } = useNotifications();
    const getEncryptionPreferences = useGetEncryptionPreferences();

    return useCallback(async (message: MessageExtendedWithData): Promise<{
        cleanMessage: MessageExtendedWithData;
        mapSendPrefs: SimpleMap<SendPreferences>;
        hasChanged: boolean;
    }> => {
        // Empty subject
        if (!message.data.Subject) {
            await new Promise((resolve, reject) => {
                createModal(
                    <ConfirmModal
                        onConfirm={resolve}
                        onClose={reject}
                        title={c('Title').t`Message without subject?`}
                        confirm={c('Action').t`Send anyway`}
                    >
                        <Alert>{c('Info')
                            .t`You have not given your email any subject. Do you want to send the message anyway?`}</Alert>
                    </ConfirmModal>
                );
            });
        }

        const uniqueMessage = {
            ...message,
            data: uniqueMessageRecipients(message.data),
        };
        const emails = unique(getRecipientsAddresses(uniqueMessage.data));

        // Invalid addresses
        const invalids = emails.filter((email) => !validateEmailAddress(email));
        if (invalids.length > 0) {
            const invalidAddresses = invalids.join(', ');
            createNotification({
                text: c('Send email with warnings').ngettext(
                    msgid`The following address is not valid: ${invalidAddresses}`,
                    `The following addresses are not valid: ${invalidAddresses}`,
                    invalids.length
                ),
                type: 'error',
            });
            throw new Error();
        }

        const emailWarnings: { [email: string]: string[] } = {};
        const mapSendPrefs: SimpleMap<SendPreferences> = {};
        const sendErrors: { [email: string]: Error } = {};
        const expiresNotEncrypted: string[] = [];

        await Promise.all(
            emails.map(async (email) => {
                const encryptionPreferences = await getEncryptionPreferences(email);
                if (encryptionPreferences.emailAddressWarnings?.length) {
                    emailWarnings[email] = encryptionPreferences.emailAddressWarnings as string[];
                }
                const sendPreferences = getSendPreferences(encryptionPreferences, message.data);
                mapSendPrefs[email] = sendPreferences;
                if (sendPreferences.failure) {
                    sendErrors[email] = sendPreferences.failure?.error;
                }
                if (message.expiresIn && !sendPreferences.encrypt) {
                    expiresNotEncrypted.push(email);
                }
            })
        );

        // Addresses with warnings
        const emailsWithWarnings = Object.keys(emailWarnings);
        if (emailsWithWarnings.length > 0) {
            await new Promise((resolve, reject) => {
                createModal(
                  <SendWithWarningsModal mapWarnings={emailWarnings} onSubmit={resolve} onClose={reject} />
                );
            });
        }

        // Addresses with errors
        const emailsWithErrors = Object.keys(sendErrors);
        if (emailsWithErrors.length > 0) {
            await new Promise((resolve, reject) => {
                const handleSendAnyway = () => {
                    for (const email of emailsWithErrors) {
                        const indexOfEmail = emails.findIndex((emailAddress) => emailAddress === email);
                        emails.splice(indexOfEmail, 1);
                        delete mapSendPrefs[email];
                    }
                    resolve();
                };
                createModal(
                    <SendWithErrorsModal
                        mapErrors={sendErrors}
                        cannotSend={emailsWithErrors.length === emails.length}
                        onSubmit={handleSendAnyway}
                        onClose={reject}
                    />
                );
            });
        }

        // Expiration and addresses with no encryptions
        if (expiresNotEncrypted.length > 0) {
            await new Promise((resolve, reject) => {
                createModal(<SendWithExpirationModal emails={expiresNotEncrypted} onSubmit={resolve} onClose={reject} />);
            });
        }

        // TODO
        // if (sendPreferences !== oldSendPreferences) {
        //     // check what is going on. Show modal if encryption downgrade
        // }

        // Prepare and save draft
        const cleanMessage = {
            ...message,
            data: removeMessageRecipients(uniqueMessage.data, emailsWithErrors),
        } as MessageExtendedWithData;

        return { cleanMessage, mapSendPrefs, hasChanged: emailsWithErrors.length > 0 };
    }, []);
};

// Reference: Angular/src/app/composer/services/sendMessage.js

export const useSendMessage = () => {
    const api = useApi();
    const getAddressKeys = useGetAddressKeys();
    const attachmentCache = useAttachmentCache();
    const { call } = useEventManager();
    const messageCache = useMessageCache();
    const auth = useAuthentication();
    const saveDraft = useSaveDraft();
    const history = useHistory();
    const delaySendSeconds = useDelaySendSeconds();
    const { createNotification, hideNotification } = useNotifications();
    const [loading, withLoading] = useLoading();
    const sendingMessageText = c('Info').t`Sending message...`;

    return useCallback(
        async (
            inputMessage: MessageExtendedWithData,
            mapSendPrefs: SimpleMap<SendPreferences>,
            onCompose: OnCompose,
            alreadySaved = false
        ) => {
            const { localID, data } = inputMessage;
            let notificationID = 0;

            const handleUndo = async () => {
                await api(cancelSend(localID));
                // Re-open draft
                onCompose({
                    existingDraft: {
                        localID,
                        data,
                    },
                });
            };

            try {
                if (delaySendSeconds) {
                    notificationID = createNotification({
                        text: (
                            <>
                                <span className="mr1">{sendingMessageText}</span>
                                <UndoButton onUndo={() => withLoading(handleUndo())} loading={loading} />
                            </>
                        ),
                        expiration: -1,
                    });
                } else {
                    notificationID = createNotification({
                        text: sendingMessageText,
                        expiration: -1,
                    });
                }

                if (!alreadySaved) {
                    await saveDraft(inputMessage);
                }

                // Add public key if selected
                if (isAttachPublicKey(inputMessage.data)) {
                    const savedMessage = messageCache.get(localID) as MessageExtendedWithData;
                    const Attachments: Attachment[] = await attachPublicKey(savedMessage, auth.UID);
                    await saveDraft({
                        ...savedMessage,
                        data: { ...savedMessage.data, Attachments },
                    });
                }

                const message = messageCache.get(localID) as MessageExtendedWithData;
                const messageWithGoodFlags = {
                    ...message,
                    data: {
                        ...message.data,
                        Flags: inputMessage.data.Flags,
                    },
                };

                // TODO: handleAttachmentSigs ?

                const emails = unique(getRecipientsAddresses(inputMessage.data));

                let packages = await generateTopPackages(messageWithGoodFlags, mapSendPrefs, attachmentCache, api);
                packages = await attachSubPackages(packages, messageWithGoodFlags, emails, mapSendPrefs, api);
                packages = await encryptPackages(messageWithGoodFlags, packages, getAddressKeys);

                // TODO: Implement retry system
                // const suppress = retry ? [API_CUSTOM_ERROR_CODES.MESSAGE_VALIDATE_KEY_ID_NOT_ASSOCIATED] : [];
                // try {

                // expiresIn is not saved on the API and then empty in `message`, we need to refer to `inputMessage`
                const { expiresIn } = inputMessage;
                const deliveryTime = delaySendSeconds ? Math.floor(Date.now() / 1000) + delaySendSeconds : 0;
                const { Sent, DeliveryTime } = await api(
                    sendMessage(message.data?.ID, {
                        Packages: packages,
                        ExpiresIn: expiresIn === 0 ? undefined : expiresIn,
                        DeliveryTime: deliveryTime,
                    } as any)
                );

                const delta = DeliveryTime * 1000 - Date.now();
                const timeout = delta > 0 ? delta : 0;
                setTimeout(() => {
                    if (notificationID) {
                        hideNotification(notificationID);
                    }
                    createNotification({ text: c('Success').t`Message sent` });
                }, timeout);

                updateMessageCache(messageCache, localID, { data: Sent, initialized: undefined });

                call();

                // Navigation to the sent message
                const {
                    params: { labelID, elementID },
                } = matchPath(history.location.pathname, { path: MAIN_ROUTE_PATH }) as match<{
                    labelID: string;
                    elementID?: string;
                }>;

                if (elementID === Sent.ConversationID) {
                    history.push(`/${labelID}/${Sent.ConversationID}/${Sent.ID}`);
                }

                // } catch (e) {
                //     if (retry && e.data.Code === API_CUSTOM_ERROR_CODES.MESSAGE_VALIDATE_KEY_ID_NOT_ASSOCIATED) {
                //         sendPreferences.clearCache();
                //         keyCache.clearCache();
                //         // retry if we used the wrong keys
                //         return send(message, parameters, false);
                //     }
                //     throw e;
                // }
            } catch (error) {
                if (notificationID) {
                    hideNotification(notificationID);
                }
                onCompose({
                    existingDraft: {
                        localID,
                        data,
                    },
                });
                throw error;
            }
        },
        []
    );
};
/* @ngInject */
function prepareContent($injector, transformAttachements, transformRemote, transformEmbedded, cacheBase64) {
    const filters = [
      'transformEmbedded',
      'transformWelcome', 
      'transformBlockquotes',
      'transformStylesheet'
    ].map(
        (name) => ({
            name,
            action: $injector.get(name)
        })
    );

    filters.unshift({
        name: 'transformLinks',
        action: transformLinks
    });

    /**
     * Get the list of transoformation to perform
     *     => Blacklist everything via *
     * @param  {Array}  blacklist
     * @param  {Array}  whitelist
     * @return {Array}
     */
    const getTransformers = (blacklist = [], whitelist = []) => {
        // --force
        if (whitelist.length) {
            return filters.filter(({ name }) => whitelist.includes(name));
        }

        if (blacklist.includes('*')) {
            return [];
        }
        return filters.filter(({ name }) => !blacklist.includes(name));
    };

    function createParser(content, { isBlacklisted = false, action }) {
        const div = document.createElement('div');

        if (isBlacklisted) {
            div.innerHTML = getInput(content);
            return div;
        }

        // Escape All the things !
        return transformEscape(content, {
            action,
            cache: cacheBase64,
            isDocument: typeof content !== 'string'
        });
    }

    function getInput(input) {
        if (typeof input === 'string') {
            return input;
        }
        return input.querySelector('body').innerHTML;
    }

    return (content, message, { blacklist = [], whitelist = [], action, countEmbedded } = {}) => {
        const transformers = getTransformers(blacklist, whitelist);
        const div = createParser(content, {
            action,
            isBlacklisted: _.includes(blacklist, 'transformRemote')
        });

        countEmbedded && (message.NumEmbedded = message.countEmbedded(div));

        const body = transformers.reduceRight(
            (html, transformer) => transformer.action(html, message, { action }),
            div
        );

        if (!blacklist.includes('*') && !_.includes(blacklist, 'transformAttachements')) {
            transformAttachements(body, message, { action });
        }

        // For a draft we try to load embedded content if we can
        if (/^reply|forward/.test(action)) {
            transformEmbedded(body, message, { action });
        }

        return transformRemote(body, message, { action }).innerHTML;
    };
}
export const useInitializeMessage = (localID: string, labelID?: string) => {
    const api = useApi();
    const markAs = useMarkAs();
    const messageCache = useMessageCache();
    const getMessageKeys = useMessageKeys();
    const attachmentsCache = useAttachmentCache();
    const base64Cache = useBase64Cache();
    const [mailSettings] = useMailSettings();
    const loadEmbeddedImages = useLoadEmbeddedImages(localID);
    const verifyMessage = useVerifyMessage(localID);

    return useCallback(async () => {
        // Message can change during the whole initilization sequence
        // To have the most up to date version, best is to get back to the cache version each time
        const getData = () => (messageCache.get(localID) as MessageExtendedWithData).data;

        // Cache entry will be (at least) initialized by the queue system
        const messageFromCache = messageCache.get(localID) as MessageExtended;

        // If the message is not yet loaded at all, the localID is the message ID
        if (!messageFromCache || !messageFromCache.data) {
            messageFromCache.data = { ID: localID } as Message;
        }

        updateMessageCache(messageCache, localID, { initialized: false });

        const errors: MessageErrors = {};

        let userKeys;
        let decryption;
        let preparation;
        let dataChanges;

        try {
            // Ensure the message data is loaded
            const message = await loadMessage(messageFromCache, api);
            updateMessageCache(messageCache, localID, { data: message.data });

            dataChanges = {} as Partial<Message>;

            userKeys = await getMessageKeys(message);
            const messageWithKeys = {
                ...message,
                publicKeys: [], // Signature verification are done later for performance
                privateKeys: userKeys.privateKeys,
            };

            decryption = await decryptMessage(getData(), userKeys.privateKeys, attachmentsCache);

            if (decryption.mimetype) {
                dataChanges = { ...dataChanges, MIMEType: decryption.mimetype };
            }
            const mimeAttachments = decryption.Attachments || [];
            const allAttachments = [...getData().Attachments, ...mimeAttachments];
            dataChanges = {
              ...dataChanges,
              Attachments: allAttachments,
              NumAttachments: allAttachments.length
            };

            if (decryption.errors) {
                Object.assign(errors, decryption.errors);
            }

            // Trigger all public key and signature verification but we are not waiting for it
            void verifyMessage(decryption.decryptedBody, decryption.signature);

            if (isUnreadMessage(getData())) {
                markAs([getData()], labelID, MARK_AS_STATUS.READ);
                dataChanges = { ...dataChanges, Unread: 0 };
            }

            const MIMEType = dataChanges.MIMEType || getData().MIMEType;

            preparation = isPlainText({ MIMEType })
                ? ({ plainText: decryption.decryptedBody } as any)
                : await prepareMailDocument(
                      { ...messageWithKeys, decryptedBody: decryption.decryptedBody },
                      base64Cache,
                      attachmentsCache,
                      api,
                      mailSettings
                  );
        } catch (error) {
            if (isApiError(error)) {
                errors.network = error;
            } else {
                errors.common = error;
            }
        } finally {
            updateMessageCache(messageCache, localID, {
                data: dataChanges,
                document: preparation?.document,
                plainText: preparation?.plainText,
                publicKeys: userKeys?.publicKeys,
                privateKeys: userKeys?.privateKeys,
                decryptedBody: decryption?.decryptedBody,
                signature: decryption?.signature,
                decryptedSubject: decryption?.decryptedSubject,
                // Anticipate showEmbedded flag while triggering the load just after
                showEmbeddedImages: preparation?.showEmbeddedImages,
                showRemoteImages: preparation?.showRemoteImages,
                embeddeds: preparation?.embeddeds,
                errors,
                initialized: true,
            });
        }

        if (hasShowEmbedded(mailSettings)) {
            // Load embedded images as a second step not synchronized with the initialization
            // To prevent slowing the message body when there is heavy embedded attachments
            void loadEmbeddedImages();
        }
    }, [localID]);
};

Sorry, didn't find time to make cute drawings...

  • Hooks are great at scale
  • React components makes the app so much clearer
  • Global performance improvements took just a few days
  • Hooks can create really complex race conditions
  • Still some legacy code
  • There is already some technical dept
  • Hard time creating documentation
  • Hard time creating tests
  • Less bugs than in Angular
  • Issues are quicker to fix
  • New features are quicker to create
  • New UX features
  • New modular architecture allows to
    • Have different lifecycle per apps
    • Benefits from core updates
  • They almost didn't see it!!!!!!!
  • Great for a smooth transition and not too much bugs
  • Bad as "seriously!! 2x lighter, 2x faster, a ton of UX improvements!!"

Powered by https://excalidraw.com/ of course!

From AngularJS to React at ProtonMail

By Matthieu Lux

From AngularJS to React at ProtonMail

Ce sont les slides pour la conférence "D'AngularJS à React chez ProtonMail" du Mixit 2020

  • 355