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