diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js
index 4bc6624f8..ba0f3dea3 100644
--- a/web/account/siwe-login-form.react.js
+++ b/web/account/siwe-login-form.react.js
@@ -1,265 +1,268 @@
// @flow
import '@rainbow-me/rainbowkit/styles.css';
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import { useAccount, useWalletClient } from 'wagmi';
import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js';
import {
getSIWENonce,
getSIWENonceActionTypes,
siweAuth,
siweAuthActionTypes,
} from 'lib/actions/siwe-actions.js';
import ConnectedWalletInfo from 'lib/components/connected-wallet-info.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import stores from 'lib/facts/stores.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
-import type { LogInStartingPayload } from 'lib/types/account-types.js';
+import type {
+ LogInStartingPayload,
+ LogInExtraInfo,
+} from 'lib/types/account-types.js';
import type { OLMIdentityKeys } from 'lib/types/crypto-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import { ServerError } from 'lib/utils/errors.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import {
createSIWEMessage,
getSIWEStatementForPublicKey,
siweMessageSigningExplanationStatements,
} from 'lib/utils/siwe-utils.js';
import { useGetSignedIdentityKeysBlob } from './account-hooks.js';
import HeaderSeparator from './header-separator.react.js';
import css from './siwe.css';
import Button from '../components/button.react.js';
import OrBreak from '../components/or-break.react.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { webLogInExtraInfoSelector } from '../selectors/account-selectors.js';
type SIWELogInError = 'account_does_not_exist';
type SIWELoginFormProps = {
+cancelSIWEAuthFlow: () => void,
};
const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector(
getSIWENonceActionTypes,
);
const siweAuthLoadingStatusSelector =
createLoadingStatusSelector(siweAuthActionTypes);
function SIWELoginForm(props: SIWELoginFormProps): React.Node {
const { address } = useAccount();
const { data: signer } = useWalletClient();
const dispatchActionPromise = useDispatchActionPromise();
const getSIWENonceCall = useServerCall(getSIWENonce);
const getSIWENonceCallLoadingStatus = useSelector(
getSIWENonceLoadingStatusSelector,
);
const siweAuthLoadingStatus = useSelector(siweAuthLoadingStatusSelector);
const siweAuthCall = useServerCall(siweAuth);
const logInExtraInfo = useSelector(webLogInExtraInfoSelector);
const [siweNonce, setSIWENonce] = React.useState(null);
const siweNonceShouldBeFetched =
!siweNonce && getSIWENonceCallLoadingStatus !== 'loading';
React.useEffect(() => {
if (!siweNonceShouldBeFetched) {
return;
}
dispatchActionPromise(
getSIWENonceActionTypes,
(async () => {
const response = await getSIWENonceCall();
setSIWENonce(response);
})(),
);
}, [dispatchActionPromise, getSIWENonceCall, siweNonceShouldBeFetched]);
const primaryIdentityPublicKeys: ?OLMIdentityKeys = useSelector(
state => state.cryptoStore?.primaryIdentityKeys,
);
const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob();
const callSIWEAuthEndpoint = React.useCallback(
- async (message: string, signature: string, extraInfo) => {
+ async (message: string, signature: string, extraInfo: LogInExtraInfo) => {
const signedIdentityKeysBlob = await getSignedIdentityKeysBlob();
invariant(
signedIdentityKeysBlob,
'signedIdentityKeysBlob must be set in attemptSIWEAuth',
);
try {
return await siweAuthCall({
message,
signature,
signedIdentityKeysBlob,
doNotRegister: true,
...extraInfo,
});
} catch (e) {
if (
e instanceof ServerError &&
e.message === 'account_does_not_exist'
) {
setError('account_does_not_exist');
}
throw e;
}
},
[getSignedIdentityKeysBlob, siweAuthCall],
);
const attemptSIWEAuth = React.useCallback(
(message: string, signature: string) => {
const extraInfo = logInExtraInfo();
return dispatchActionPromise(
siweAuthActionTypes,
callSIWEAuthEndpoint(message, signature, extraInfo),
undefined,
({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload),
);
},
[callSIWEAuthEndpoint, dispatchActionPromise, logInExtraInfo],
);
const dispatch = useDispatch();
const onSignInButtonClick = React.useCallback(async () => {
invariant(signer, 'signer must be present during SIWE attempt');
invariant(siweNonce, 'nonce must be present during SIWE attempt');
invariant(
primaryIdentityPublicKeys,
'primaryIdentityPublicKeys must be present during SIWE attempt',
);
const statement = getSIWEStatementForPublicKey(
primaryIdentityPublicKeys.ed25519,
);
const message = createSIWEMessage(address, statement, siweNonce);
const signature = await signer.signMessage({ message });
await attemptSIWEAuth(message, signature);
dispatch({
type: setDataLoadedActionType,
payload: {
dataLoaded: true,
},
});
}, [
address,
attemptSIWEAuth,
primaryIdentityPublicKeys,
signer,
siweNonce,
dispatch,
]);
const { cancelSIWEAuthFlow } = props;
const backButtonColor = React.useMemo(
() => ({ backgroundColor: '#211E2D' }),
[],
);
const signInButtonColor = React.useMemo(
() => ({ backgroundColor: '#6A20E3' }),
[],
);
const [error, setError] = React.useState();
const mainMiddleAreaClassName = classNames({
[css.mainMiddleArea]: true,
[css.hidden]: !!error,
});
const errorOverlayClassNames = classNames({
[css.errorOverlay]: true,
[css.hidden]: !error,
});
if (
siweAuthLoadingStatus === 'loading' ||
!siweNonce ||
!primaryIdentityPublicKeys
) {
return (
);
}
let errorText;
if (error === 'account_does_not_exist') {
errorText = (
<>
No Comm account found for that Ethereum wallet!
We require that users register on their mobile devices. Comm relies on
a primary device capable of scanning QR codes in order to authorize
secondary devices.
You can install our iOS app
here
, or our Android app
here
.
>
);
}
return (
Sign in with Ethereum
Sign in using this wallet
{errorText}
Back to sign in with username
);
}
export default SIWELoginForm;
diff --git a/web/account/traditional-login-form.react.js b/web/account/traditional-login-form.react.js
index a189ac51b..7bd7c8f9e 100644
--- a/web/account/traditional-login-form.react.js
+++ b/web/account/traditional-login-form.react.js
@@ -1,185 +1,191 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useLogIn, logInActionTypes } from 'lib/actions/user-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import {
oldValidUsernameRegex,
validEmailRegex,
} from 'lib/shared/account-utils.js';
import type {
LogInExtraInfo,
LogInStartingPayload,
} from 'lib/types/account-types.js';
import { logInActionSources } from 'lib/types/account-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { useGetSignedIdentityKeysBlob } from './account-hooks.js';
import HeaderSeparator from './header-separator.react.js';
import css from './log-in-form.css';
import PasswordInput from './password-input.react.js';
import Button from '../components/button.react.js';
import LoadingIndicator from '../loading-indicator.react.js';
import Input from '../modals/input.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { webLogInExtraInfoSelector } from '../selectors/account-selectors.js';
const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes);
function TraditionalLoginForm(): React.Node {
const inputDisabled = useSelector(loadingStatusSelector) === 'loading';
const loginExtraInfo = useSelector(webLogInExtraInfoSelector);
const callLogIn = useLogIn();
const dispatchActionPromise = useDispatchActionPromise();
const modalContext = useModalContext();
const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob();
const usernameInputRef = React.useRef();
React.useEffect(() => {
usernameInputRef.current?.focus();
}, []);
const [username, setUsername] = React.useState('');
- const onUsernameChange = React.useCallback(e => {
- invariant(e.target instanceof HTMLInputElement, 'target not input');
- setUsername(e.target.value);
- }, []);
+ const onUsernameChange = React.useCallback(
+ (e: SyntheticEvent) => {
+ invariant(e.target instanceof HTMLInputElement, 'target not input');
+ setUsername(e.target.value);
+ },
+ [],
+ );
const onUsernameBlur = React.useCallback(() => {
setUsername(untrimmedUsername => untrimmedUsername.trim());
}, []);
const [password, setPassword] = React.useState('');
- const onPasswordChange = React.useCallback(e => {
- invariant(e.target instanceof HTMLInputElement, 'target not input');
- setPassword(e.target.value);
- }, []);
+ const onPasswordChange = React.useCallback(
+ (e: SyntheticEvent) => {
+ invariant(e.target instanceof HTMLInputElement, 'target not input');
+ setPassword(e.target.value);
+ },
+ [],
+ );
const [errorMessage, setErrorMessage] = React.useState('');
const logInAction = React.useCallback(
async (extraInfo: LogInExtraInfo) => {
const signedIdentityKeysBlob = await getSignedIdentityKeysBlob();
try {
invariant(
signedIdentityKeysBlob,
'signedIdentityKeysBlob must be set in logInAction',
);
const result = await callLogIn({
...extraInfo,
username,
password,
logInActionSource: logInActionSources.logInFromWebForm,
signedIdentityKeysBlob,
});
modalContext.popModal();
return result;
} catch (e) {
setUsername('');
setPassword('');
if (e.message === 'invalid_credentials') {
setErrorMessage('incorrect username or password');
} else {
setErrorMessage('unknown error');
}
usernameInputRef.current?.focus();
throw e;
}
},
[callLogIn, modalContext, password, getSignedIdentityKeysBlob, username],
);
const onSubmit = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
if (username.search(validEmailRegex) > -1) {
setUsername('');
setErrorMessage('usernames only, not emails');
usernameInputRef.current?.focus();
return;
} else if (username.search(oldValidUsernameRegex) === -1) {
setUsername('');
setErrorMessage('alphanumeric usernames only');
usernameInputRef.current?.focus();
return;
} else if (password === '') {
setErrorMessage('password is empty');
usernameInputRef.current?.focus();
return;
}
const extraInfo = loginExtraInfo();
dispatchActionPromise(
logInActionTypes,
logInAction(extraInfo),
undefined,
({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload),
);
},
[dispatchActionPromise, logInAction, loginExtraInfo, username, password],
);
const loginButtonContent = React.useMemo(() => {
if (inputDisabled) {
return ;
}
return 'Sign in';
}, [inputDisabled]);
const signInButtonColor = React.useMemo(
() => ({ backgroundColor: '#6A20E3' }),
[],
);
return (
);
}
export default TraditionalLoginForm;
diff --git a/web/app.react.js b/web/app.react.js
index 590d25d33..efab05d57 100644
--- a/web/app.react.js
+++ b/web/app.react.js
@@ -1,401 +1,402 @@
// @flow
import 'basscss/css/basscss.min.css';
import './theme.css';
import { config as faConfig } from '@fortawesome/fontawesome-svg-core';
import classnames from 'classnames';
import _isEqual from 'lodash/fp/isEqual.js';
import * as React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { WagmiConfig } from 'wagmi';
import {
fetchEntriesActionTypes,
updateCalendarQueryActionTypes,
} from 'lib/actions/entry-actions.js';
import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js';
import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js';
import {
ModalProvider,
useModalContext,
} from 'lib/components/modal-provider.react.js';
import {
createLoadingStatusSelector,
combineLoadingStatuses,
} from 'lib/selectors/loading-selectors.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { registerConfig } from 'lib/utils/config.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { infoFromURL } from 'lib/utils/url-utils.js';
import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js';
import QrCodeLogin from './account/qr-code-login.react.js';
import AppThemeWrapper from './app-theme-wrapper.react.js';
import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js';
import Calendar from './calendar/calendar.react.js';
import Chat from './chat/chat.react.js';
import { EditModalProvider } from './chat/edit-message-provider.js';
import { TooltipProvider } from './chat/tooltip-provider.js';
import NavigationArrows from './components/navigation-arrows.react.js';
import { initOpaque } from './crypto/opaque-utils.js';
import electron from './electron.js';
import InputStateContainer from './input/input-state-container.react.js';
import InviteLinkHandler from './invite-links/invite-link-handler.react.js';
import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js';
import LoadingIndicator from './loading-indicator.react.js';
import { MenuProvider } from './menu-provider.react.js';
import UpdateModalHandler from './modals/update-modal.react.js';
import SettingsSwitcher from './navigation-panels/settings-switcher.react.js';
import Topbar from './navigation-panels/topbar.react.js';
import useBadgeHandler from './push-notif/badge-handler.react.js';
import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js';
import { updateNavInfoActionType } from './redux/action-types.js';
import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler.js';
import DisconnectedBar from './redux/disconnected-bar.js';
import FocusHandler from './redux/focus-handler.react.js';
import { persistConfig } from './redux/persist.js';
import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js';
import { useSelector } from './redux/redux-utils.js';
import VisibilityHandler from './redux/visibility-handler.react.js';
import history from './router-history.js';
import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js';
import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js';
import AccountSettings from './settings/account-settings.react.js';
import DangerZone from './settings/danger-zone.react.js';
import KeyserverSelectionList from './settings/keyserver-selection-list.react.js';
import CommunityPicker from './sidebar/community-picker.react.js';
import Splash from './splash/splash.react.js';
import './typography.css';
import css from './style.css';
import { type NavInfo } from './types/nav-types.js';
import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js';
initOpaque();
// We want Webpack's css-loader and style-loader to handle the Fontawesome CSS,
// so we disable the autoAddCss logic and import the CSS file. Otherwise every
// icon flashes huge for a second before the CSS is loaded.
import '@fortawesome/fontawesome-svg-core/styles.css';
faConfig.autoAddCss = false;
registerConfig({
// We can't securely cache credentials on web, so we have no way to recover
// from a cookie invalidation
resolveInvalidatedCookie: null,
setSessionIDOnRequest: true,
// Never reset the calendar range
calendarRangeInactivityLimit: null,
platformDetails: {
platform: electron?.platform ?? 'web',
codeVersion: 43,
stateVersion: persistConfig.version,
},
});
type BaseProps = {
+location: {
+pathname: string,
...
},
};
type Props = {
...BaseProps,
// Redux state
+navInfo: NavInfo,
+entriesLoadingStatus: LoadingStatus,
+loggedIn: boolean,
+activeThreadCurrentlyUnread: boolean,
// Redux dispatch functions
+dispatch: Dispatch,
+modals: $ReadOnlyArray,
};
class App extends React.PureComponent {
componentDidMount() {
const {
navInfo,
location: { pathname },
loggedIn,
} = this.props;
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (pathname !== newURL) {
history.replace(newURL);
}
}
componentDidUpdate(prevProps: Props) {
const {
navInfo,
location: { pathname },
loggedIn,
} = this.props;
if (!_isEqual(navInfo)(prevProps.navInfo)) {
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (newURL !== pathname) {
history.push(newURL);
}
} else if (pathname !== prevProps.location.pathname) {
const urlInfo = infoFromURL(pathname);
const newNavInfo = navInfoFromURL(urlInfo, { navInfo });
if (!_isEqual(newNavInfo)(navInfo)) {
this.props.dispatch({
type: updateNavInfoActionType,
payload: newNavInfo,
});
}
} else if (loggedIn !== prevProps.loggedIn) {
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (newURL !== pathname) {
history.replace(newURL);
}
}
if (loggedIn !== prevProps.loggedIn) {
electron?.clearHistory();
}
}
onWordmarkClicked = () => {
this.props.dispatch({
type: updateNavInfoActionType,
payload: { tab: 'chat' },
});
};
- render() {
+ render(): React.Node {
let content;
if (this.props.loggedIn) {
content = (
<>
{this.renderMainContent()}
{this.props.modals}
>
);
} else {
content = (
<>
{this.renderLoginPage()}
{this.props.modals}
>
);
}
return (
{content}
);
}
- onHeaderDoubleClick = () => electron?.doubleClickTopBar();
- stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null;
+ onHeaderDoubleClick = (): void => electron?.doubleClickTopBar();
+ stopDoubleClickPropagation: ?(SyntheticEvent) => void =
+ electron ? e => e.stopPropagation() : null;
- renderLoginPage() {
+ renderLoginPage(): React.Node {
const { loginMethod } = this.props.navInfo;
if (loginMethod === 'qr-code') {
return ;
}
return ;
}
- renderMainContent() {
+ renderMainContent(): React.Node {
const mainContent = this.getMainContentWithSwitcher();
let navigationArrows = null;
if (electron) {
navigationArrows = ;
}
const headerClasses = classnames({
[css.header]: true,
[css['electron-draggable']]: electron,
});
const wordmarkClasses = classnames({
[css.wordmark]: true,
[css['electron-non-draggable']]: electron,
[css['wordmark-macos']]: electron?.platform === 'macos',
});
return (
);
}
- getMainContentWithSwitcher() {
+ getMainContentWithSwitcher(): React.Node {
const { tab, settingsSection } = this.props.navInfo;
let mainContent;
if (tab === 'settings') {
if (settingsSection === 'account') {
mainContent = ;
} else if (settingsSection === 'keyservers') {
mainContent = ;
} else if (settingsSection === 'danger-zone') {
mainContent = ;
}
return (
);
}
if (tab === 'calendar') {
mainContent = ;
} else if (tab === 'chat') {
mainContent = ;
}
const mainContentClass = classnames(
css['main-content-container'],
css['main-content-container-column'],
);
return (
);
}
}
const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector(
fetchEntriesActionTypes,
);
const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector(
updateCalendarQueryActionTypes,
);
const ConnectedApp: React.ComponentType = React.memo(
function ConnectedApp(props) {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const navInfo = useSelector(state => state.navInfo);
const fetchEntriesLoadingStatus = useSelector(
fetchEntriesLoadingStatusSelector,
);
const updateCalendarQueryLoadingStatus = useSelector(
updateCalendarQueryLoadingStatusSelector,
);
const entriesLoadingStatus = combineLoadingStatuses(
fetchEntriesLoadingStatus,
updateCalendarQueryLoadingStatus,
);
const loggedIn = useSelector(isLoggedIn);
const activeThreadCurrentlyUnread = useSelector(
state =>
!activeChatThreadID ||
!!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread,
);
useBadgeHandler();
const dispatch = useDispatch();
const modalContext = useModalContext();
const modals = React.useMemo(
() =>
modalContext.modals.map(([modal, key]) => (
{modal}
)),
[modalContext.modals],
);
const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage);
return (
);
},
);
function AppWithProvider(props: BaseProps): React.Node {
return (
);
}
export default AppWithProvider;
diff --git a/web/avatars/avatar-hooks.react.js b/web/avatars/avatar-hooks.react.js
index f7eb2f0a1..1617bb9ce 100644
--- a/web/avatars/avatar-hooks.react.js
+++ b/web/avatars/avatar-hooks.react.js
@@ -1,82 +1,82 @@
// @flow
import * as React from 'react';
import {
uploadMultimedia,
useBlobServiceUpload,
} from 'lib/actions/upload-actions.js';
import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js';
import { useServerCall } from 'lib/utils/action-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import { encryptFile } from '../media/encryption-utils.js';
import { generateThumbHash } from '../media/image-utils.js';
import { validateFile } from '../media/media-utils.js';
// TODO: flip the switch
const useBlobServiceUploads = false;
function useUploadAvatarMedia(): File => Promise {
const callUploadMultimedia = useServerCall(uploadMultimedia);
const callBlobServiceUpload = useBlobServiceUpload();
const uploadAvatarMedia = React.useCallback(
- async file => {
+ async (file: File): Promise => {
const validatedFile = await validateFile(file);
const { result } = validatedFile;
if (!result.success) {
throw new Error('Avatar media validation failed.');
}
const { file: fixedFile, dimensions } = result;
const uploadExtras = {
...dimensions,
loop: false,
};
if (!useBlobServiceUploads) {
const { id } = await callUploadMultimedia(fixedFile, uploadExtras);
return { type: 'image', uploadID: id };
}
const encryptionResponse = await encryptFile(fixedFile);
const { result: encryptionResult } = encryptionResponse;
if (!encryptionResult.success) {
throw new Error('Avatar media encryption failed.');
}
const {
file: encryptedFile,
sha256Hash: blobHash,
encryptionKey,
} = encryptionResult;
const { result: thumbHashResult } = await generateThumbHash(
fixedFile,
encryptionKey,
);
const thumbHash = thumbHashResult.success
? thumbHashResult.thumbHash
: null;
const { id } = await callBlobServiceUpload({
uploadInput: {
blobInput: {
type: 'file',
file: encryptedFile,
},
blobHash,
encryptionKey,
dimensions,
loop: false,
thumbHash,
},
keyserverOrThreadID: ashoatKeyserverID,
callbacks: {},
});
return { type: 'encrypted_image', uploadID: id };
},
[callBlobServiceUpload, callUploadMultimedia],
);
return uploadAvatarMedia;
}
export { useUploadAvatarMedia };
diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js
index 780c02788..fafc0a456 100644
--- a/web/avatars/edit-thread-avatar-menu.react.js
+++ b/web/avatars/edit-thread-avatar-menu.react.js
@@ -1,127 +1,129 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import type {
MinimallyEncodedRawThreadInfo,
MinimallyEncodedThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { RawThreadInfo, ThreadInfo } from 'lib/types/thread-types.js';
import { useUploadAvatarMedia } from './avatar-hooks.react.js';
import css from './edit-avatar-menu.css';
import ThreadEmojiAvatarSelectionModal from './thread-emoji-avatar-selection-modal.react.js';
import MenuItem from '../components/menu-item.react.js';
import Menu from '../components/menu.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
const editIcon = (
);
type Props = {
+threadInfo:
| RawThreadInfo
| ThreadInfo
| MinimallyEncodedThreadInfo
| MinimallyEncodedRawThreadInfo,
};
function EditThreadAvatarMenu(props: Props): React.Node {
const { threadInfo } = props;
const editThreadAvatarContext = React.useContext(EditThreadAvatarContext);
invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set');
const { baseSetThreadAvatar } = editThreadAvatarContext;
const removeThreadAvatar = React.useCallback(
() => baseSetThreadAvatar(threadInfo.id, { type: 'remove' }),
[baseSetThreadAvatar, threadInfo.id],
);
const removeMenuItem = React.useMemo(
() => (
),
[removeThreadAvatar],
);
const imageInputRef = React.useRef();
const onImageMenuItemClicked = React.useCallback(
() => imageInputRef.current?.click(),
[],
);
const uploadAvatarMedia = useUploadAvatarMedia();
const onImageSelected = React.useCallback(
- async event => {
- const uploadResult = await uploadAvatarMedia(event.target.files[0]);
+ async (event: SyntheticEvent) => {
+ const { target } = event;
+ invariant(target instanceof HTMLInputElement, 'target not input');
+ const uploadResult = await uploadAvatarMedia(target.files[0]);
baseSetThreadAvatar(threadInfo.id, uploadResult);
},
[baseSetThreadAvatar, threadInfo.id, uploadAvatarMedia],
);
const imageMenuItem = React.useMemo(
() => (
),
[onImageMenuItemClicked],
);
const { pushModal } = useModalContext();
const openEmojiSelectionModal = React.useCallback(
() =>
pushModal( ),
[pushModal, threadInfo],
);
const emojiMenuItem = React.useMemo(
() => (
),
[openEmojiSelectionModal],
);
const menuItems = React.useMemo(() => {
const items = [emojiMenuItem, imageMenuItem];
if (threadInfo.avatar) {
items.push(removeMenuItem);
}
return items;
}, [emojiMenuItem, imageMenuItem, removeMenuItem, threadInfo.avatar]);
return (
{menuItems}
);
}
export default EditThreadAvatarMenu;
diff --git a/web/avatars/edit-user-avatar-menu.react.js b/web/avatars/edit-user-avatar-menu.react.js
index ec7761071..f8d619300 100644
--- a/web/avatars/edit-user-avatar-menu.react.js
+++ b/web/avatars/edit-user-avatar-menu.react.js
@@ -1,156 +1,158 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useENSAvatar } from 'lib/hooks/ens-cache.js';
import { getETHAddressForUserInfo } from 'lib/shared/account-utils.js';
import { useUploadAvatarMedia } from './avatar-hooks.react.js';
import css from './edit-avatar-menu.css';
import UserEmojiAvatarSelectionModal from './user-emoji-avatar-selection-modal.react.js';
import CommIcon from '../CommIcon.react.js';
import MenuItem from '../components/menu-item.react.js';
import Menu from '../components/menu.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
import { useSelector } from '../redux/redux-utils.js';
const editIcon = (
);
function EditUserAvatarMenu(): React.Node {
const currentUserInfo = useSelector(state => state.currentUserInfo);
const ethAddress: ?string = React.useMemo(
() => getETHAddressForUserInfo(currentUserInfo),
[currentUserInfo],
);
const ensAvatarURI: ?string = useENSAvatar(ethAddress);
const editUserAvatarContext = React.useContext(EditUserAvatarContext);
invariant(editUserAvatarContext, 'editUserAvatarContext should be set');
const { baseSetUserAvatar } = editUserAvatarContext;
const removeUserAvatar = React.useCallback(
() => baseSetUserAvatar({ type: 'remove' }),
[baseSetUserAvatar],
);
const { pushModal } = useModalContext();
const openEmojiSelectionModal = React.useCallback(
() => pushModal( ),
[pushModal],
);
const emojiMenuItem = React.useMemo(
() => (
),
[openEmojiSelectionModal],
);
const imageInputRef = React.useRef();
const onImageMenuItemClicked = React.useCallback(
() => imageInputRef.current?.click(),
[],
);
const uploadAvatarMedia = useUploadAvatarMedia();
const onImageSelected = React.useCallback(
- async event => {
- const uploadResult = await uploadAvatarMedia(event.target.files[0]);
+ async (event: SyntheticEvent) => {
+ const { target } = event;
+ invariant(target instanceof HTMLInputElement, 'target not input');
+ const uploadResult = await uploadAvatarMedia(target.files[0]);
baseSetUserAvatar(uploadResult);
},
[baseSetUserAvatar, uploadAvatarMedia],
);
const imageMenuItem = React.useMemo(
() => (
),
[onImageMenuItemClicked],
);
const setENSUserAvatar = React.useCallback(
() => baseSetUserAvatar({ type: 'ens' }),
[baseSetUserAvatar],
);
const ethereumIcon = React.useMemo(
() => ,
[],
);
const ensMenuItem = React.useMemo(
() => (
),
[ethereumIcon, setENSUserAvatar],
);
const removeMenuItem = React.useMemo(
() => (
),
[removeUserAvatar],
);
const menuItems = React.useMemo(() => {
const items = [emojiMenuItem, imageMenuItem];
if (ensAvatarURI) {
items.push(ensMenuItem);
}
if (currentUserInfo?.avatar) {
items.push(removeMenuItem);
}
return items;
}, [
currentUserInfo?.avatar,
emojiMenuItem,
ensAvatarURI,
ensMenuItem,
imageMenuItem,
removeMenuItem,
]);
return (
{menuItems}
);
}
export default EditUserAvatarMenu;
diff --git a/web/avatars/emoji-avatar-selection-modal.react.js b/web/avatars/emoji-avatar-selection-modal.react.js
index 2d6f86164..17f77e4e7 100644
--- a/web/avatars/emoji-avatar-selection-modal.react.js
+++ b/web/avatars/emoji-avatar-selection-modal.react.js
@@ -1,155 +1,158 @@
// @flow
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import type {
ClientAvatar,
ClientEmojiAvatar,
} from 'lib/types/avatar-types.js';
import Avatar from './avatar.react.js';
import css from './emoji-avatar-selection-modal.css';
import Button, { buttonThemes } from '../components/button.react.js';
import Tabs from '../components/tabs.react.js';
import LoadingIndicator from '../loading-indicator.react.js';
import Modal from '../modals/modal.react.js';
import ColorSelector from '../modals/threads/color-selector.react.js';
type TabType = 'emoji' | 'color';
type Props = {
+currentAvatar: ClientAvatar,
+defaultAvatar: ClientEmojiAvatar,
+setEmojiAvatar: (pendingEmojiAvatar: ClientEmojiAvatar) => Promise,
+avatarSaveInProgress: boolean,
};
function EmojiAvatarSelectionModal(props: Props): React.Node {
const { popModal } = useModalContext();
const { currentAvatar, defaultAvatar, setEmojiAvatar, avatarSaveInProgress } =
props;
const [updateAvatarStatus, setUpdateAvatarStatus] =
React.useState('success' | 'failure')>();
const [pendingAvatarEmoji, setPendingAvatarEmoji] = React.useState(
currentAvatar.type === 'emoji' ? currentAvatar.emoji : defaultAvatar.emoji,
);
const [pendingAvatarColor, setPendingAvatarColor] = React.useState(
currentAvatar.type === 'emoji' ? currentAvatar.color : defaultAvatar.color,
);
const pendingEmojiAvatar: ClientEmojiAvatar = React.useMemo(
() => ({
type: 'emoji',
emoji: pendingAvatarEmoji,
color: pendingAvatarColor,
}),
[pendingAvatarColor, pendingAvatarEmoji],
);
- const onEmojiSelect = React.useCallback(selection => {
- setUpdateAvatarStatus();
- setPendingAvatarEmoji(selection.native);
- }, []);
+ const onEmojiSelect = React.useCallback(
+ (selection: { +native: string, ... }) => {
+ setUpdateAvatarStatus();
+ setPendingAvatarEmoji(selection.native);
+ },
+ [],
+ );
const onColorSelection = React.useCallback((hex: string) => {
setUpdateAvatarStatus();
setPendingAvatarColor(hex);
}, []);
const onSaveAvatar = React.useCallback(async () => {
try {
await setEmojiAvatar(pendingEmojiAvatar);
setUpdateAvatarStatus('success');
} catch {
setUpdateAvatarStatus('failure');
}
}, [setEmojiAvatar, pendingEmojiAvatar]);
let saveButtonContent;
let buttonColor;
if (avatarSaveInProgress) {
buttonColor = buttonThemes.standard;
saveButtonContent = ;
} else if (updateAvatarStatus === 'success') {
buttonColor = buttonThemes.success;
saveButtonContent = (
<>
{'Avatar update succeeded.'}
>
);
} else if (updateAvatarStatus === 'failure') {
buttonColor = buttonThemes.danger;
saveButtonContent = (
<>
{'Avatar update failed. Please try again.'}
>
);
} else {
buttonColor = buttonThemes.standard;
saveButtonContent = 'Save Avatar';
}
const [currentTabType, setCurrentTabType] = React.useState('emoji');
return (
);
}
export default EmojiAvatarSelectionModal;
diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js
index 10e475312..7c0f7b6c4 100644
--- a/web/calendar/calendar.react.js
+++ b/web/calendar/calendar.react.js
@@ -1,287 +1,292 @@
// @flow
import dateFormat from 'dateformat';
import invariant from 'invariant';
import * as React from 'react';
import {
updateCalendarQueryActionTypes,
useUpdateCalendarQuery,
} from 'lib/actions/entry-actions.js';
import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { currentDaysToEntries } from 'lib/selectors/thread-selectors.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import {
type EntryInfo,
type CalendarQuery,
type CalendarQueryUpdateResult,
type CalendarQueryUpdateStartingPayload,
} from 'lib/types/entry-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import {
getDate,
dateString,
startDateForYearAndMonth,
endDateForYearAndMonth,
} from 'lib/utils/date-utils.js';
import css from './calendar.css';
import Day from './day.react.js';
import FilterPanel from './filter-panel.react.js';
import { useSelector } from '../redux/redux-utils.js';
import {
yearAssertingSelector,
monthAssertingSelector,
webCalendarQuery,
} from '../selectors/nav-selectors.js';
import type { NavInfo } from '../types/nav-types.js';
import { canonicalURLFromReduxState } from '../url-utils.js';
+type StartAndEndDates = {
+ +startDate: string,
+ +endDate: string,
+};
+
type BaseProps = {
+url: string,
};
type Props = {
...BaseProps,
+year: number,
+month: number,
+daysToEntries: { +[dayString: string]: EntryInfo[] },
+navInfo: NavInfo,
+currentCalendarQuery: () => CalendarQuery,
+loggedIn: boolean,
+dispatchActionPromise: DispatchActionPromise,
+updateCalendarQuery: (
input: UpdateCalendarQueryInput,
) => Promise,
};
type State = {
+filterPanelOpen: boolean,
};
class Calendar extends React.PureComponent {
state: State = {
filterPanelOpen: false,
};
getDate(
dayOfMonth: number,
monthInput: ?number = undefined,
yearInput: ?number = undefined,
- ) {
+ ): Date {
return getDate(
yearInput ? yearInput : this.props.year,
monthInput ? monthInput : this.props.month,
dayOfMonth,
);
}
- prevMonthDates() {
+ prevMonthDates(): StartAndEndDates {
const { year, month } = this.props;
const lastMonthDate = getDate(year, month - 1, 1);
const prevYear = lastMonthDate.getFullYear();
const prevMonth = lastMonthDate.getMonth() + 1;
return {
startDate: startDateForYearAndMonth(prevYear, prevMonth),
endDate: endDateForYearAndMonth(prevYear, prevMonth),
};
}
- nextMonthDates() {
+ nextMonthDates(): StartAndEndDates {
const { year, month } = this.props;
const nextMonthDate = getDate(year, month + 1, 1);
const nextYear = nextMonthDate.getFullYear();
const nextMonth = nextMonthDate.getMonth() + 1;
return {
startDate: startDateForYearAndMonth(nextYear, nextMonth),
endDate: endDateForYearAndMonth(nextYear, nextMonth),
};
}
- render() {
+ render(): React.Node {
const { year, month } = this.props;
const monthName = dateFormat(getDate(year, month, 1), 'mmmm');
const prevURL = canonicalURLFromReduxState(
{ ...this.props.navInfo, ...this.prevMonthDates() },
this.props.url,
this.props.loggedIn,
);
const nextURL = canonicalURLFromReduxState(
{ ...this.props.navInfo, ...this.nextMonthDates() },
this.props.url,
this.props.loggedIn,
);
const lastDayOfMonth = this.getDate(0, this.props.month + 1);
const totalDaysInMonth = lastDayOfMonth.getDate();
const firstDayToPrint = 1 - this.getDate(1).getDay();
const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay();
const rows = [];
let columns = [];
let week = 1;
let tabIndex = 1;
for (
let curDayOfMonth = firstDayToPrint;
curDayOfMonth <= lastDayToPrint;
curDayOfMonth++
) {
if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) {
columns.push( );
} else {
const dayString = dateString(
this.props.year,
this.props.month,
curDayOfMonth,
);
const entries = this.props.daysToEntries[dayString];
invariant(
entries,
'the currentDaysToEntries selector should make sure all dayStrings ' +
`in the current range have entries, but ${dayString} did not`,
);
columns.push(
,
);
tabIndex += entries.length;
}
if (columns.length === 7) {
rows.push({columns} );
columns = [];
}
}
let filterPanel = null;
let filterButton = null;
if (this.state.filterPanelOpen) {
filterPanel = ;
} else {
filterButton = (
);
}
return (
{filterPanel}
{filterButton}
{' '}
{monthName} {year}{' '}
Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
{rows}
);
}
toggleFilters = (event: SyntheticEvent) => {
event.preventDefault();
this.setState({ filterPanelOpen: !this.state.filterPanelOpen });
};
onClickPrevURL = (event: SyntheticEvent) => {
event.preventDefault();
const currentCalendarQuery = this.props.currentCalendarQuery();
const newCalendarQuery = {
...currentCalendarQuery,
...this.prevMonthDates(),
};
this.props.dispatchActionPromise(
updateCalendarQueryActionTypes,
this.props.updateCalendarQuery({
calendarQuery: newCalendarQuery,
reduxAlreadyUpdated: true,
}),
undefined,
({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload),
);
};
onClickNextURL = (event: SyntheticEvent) => {
event.preventDefault();
const currentCalendarQuery = this.props.currentCalendarQuery();
const newCalendarQuery = {
...currentCalendarQuery,
...this.nextMonthDates(),
};
this.props.dispatchActionPromise(
updateCalendarQueryActionTypes,
this.props.updateCalendarQuery({
calendarQuery: newCalendarQuery,
reduxAlreadyUpdated: true,
}),
undefined,
({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload),
);
};
}
const ConnectedCalendar: React.ComponentType = React.memo(
function ConnectedCalendar(props) {
const year = useSelector(yearAssertingSelector);
const month = useSelector(monthAssertingSelector);
const daysToEntries = useSelector(currentDaysToEntries);
const navInfo = useSelector(state => state.navInfo);
const currentCalendarQuery = useSelector(webCalendarQuery);
const loggedIn = useSelector(isLoggedIn);
const callUpdateCalendarQuery = useUpdateCalendarQuery();
const dispatchActionPromise = useDispatchActionPromise();
return (
);
},
);
export default ConnectedCalendar;
diff --git a/web/calendar/day.react.js b/web/calendar/day.react.js
index 111c7a1bf..21da15729 100644
--- a/web/calendar/day.react.js
+++ b/web/calendar/day.react.js
@@ -1,259 +1,259 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import _some from 'lodash/fp/some.js';
import * as React from 'react';
import {
createLocalEntry,
createLocalEntryActionType,
} from 'lib/actions/entry-actions.js';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import { onScreenThreadInfos as onScreenThreadInfosSelector } from 'lib/selectors/thread-selectors.js';
import { entryKey } from 'lib/shared/entry-utils.js';
import type { EntryInfo } from 'lib/types/entry-types.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { dateString, dateFromString } from 'lib/utils/date-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import css from './calendar.css';
import type { InnerEntry } from './entry.react.js';
import Entry from './entry.react.js';
import LogInFirstModal from '../modals/account/log-in-first-modal.react.js';
import HistoryModal from '../modals/history/history-modal.react.js';
import ThreadPickerModal from '../modals/threads/thread-picker-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { htmlTargetFromEvent } from '../vector-utils.js';
import { AddVector, HistoryVector } from '../vectors.react.js';
type BaseProps = {
+dayString: string,
+entryInfos: $ReadOnlyArray,
+startingTabIndex: number,
};
type Props = {
...BaseProps,
+onScreenThreadInfos: $ReadOnlyArray,
+viewerID: ?string,
+loggedIn: boolean,
+nextLocalID: number,
+dispatch: Dispatch,
+pushModal: PushModal,
+popModal: () => void,
};
type State = {
+hovered: boolean,
};
class Day extends React.PureComponent {
state: State = {
hovered: false,
};
entryContainer: ?HTMLDivElement;
entryContainerSpacer: ?HTMLDivElement;
actionLinks: ?HTMLDivElement;
entries: Map = new Map();
componentDidUpdate(prevProps: Props) {
if (this.props.entryInfos.length > prevProps.entryInfos.length) {
invariant(this.entryContainer, 'entryContainer ref not set');
this.entryContainer.scrollTop = this.entryContainer.scrollHeight;
}
}
- render() {
+ render(): React.Node {
const now = new Date();
const isToday = dateString(now) === this.props.dayString;
const tdClasses = classNames(css.day, { [css.currentDay]: isToday });
let actionLinks = null;
const hovered = this.state.hovered;
if (hovered) {
const actionLinksClassName = `${css.actionLinks} ${css.dayActionLinks}`;
actionLinks = (
);
}
const entries = this.props.entryInfos
.filter(entryInfo =>
_some(['id', entryInfo.threadID])(this.props.onScreenThreadInfos),
)
.map((entryInfo, i) => {
const key = entryKey(entryInfo);
return (
);
});
const entryContainerClasses = classNames(css.entryContainer, {
[css.focusedEntryContainer]: hovered,
});
const date = dateFromString(this.props.dayString);
return (
{date.getDate()}
{actionLinks}
);
}
actionLinksRef = (actionLinks: ?HTMLDivElement) => {
this.actionLinks = actionLinks;
};
entryContainerRef = (entryContainer: ?HTMLDivElement) => {
this.entryContainer = entryContainer;
};
entryContainerSpacerRef = (entryContainerSpacer: ?HTMLDivElement) => {
this.entryContainerSpacer = entryContainerSpacer;
};
entryRef = (key: string, entry: InnerEntry) => {
this.entries.set(key, entry);
};
onMouseEnter = () => {
this.setState({ hovered: true });
};
onMouseLeave = () => {
this.setState({ hovered: false });
};
onClick = (event: SyntheticEvent) => {
const target = htmlTargetFromEvent(event);
invariant(
this.entryContainer instanceof HTMLDivElement,
"entryContainer isn't div",
);
invariant(
this.entryContainerSpacer instanceof HTMLDivElement,
"entryContainerSpacer isn't div",
);
if (
target === this.entryContainer ||
target === this.entryContainerSpacer ||
(this.actionLinks && target === this.actionLinks)
) {
this.onAddEntry(event);
}
};
onAddEntry = (event: SyntheticEvent<*>) => {
event.preventDefault();
invariant(
this.props.onScreenThreadInfos.length > 0,
"onAddEntry shouldn't be clicked if no onScreenThreadInfos",
);
if (this.props.onScreenThreadInfos.length === 1) {
this.createNewEntry(this.props.onScreenThreadInfos[0].id);
} else if (this.props.onScreenThreadInfos.length > 1) {
this.props.pushModal(
,
);
}
};
createNewEntry = (threadID: string) => {
if (!this.props.loggedIn) {
this.props.pushModal( );
return;
}
const viewerID = this.props.viewerID;
invariant(viewerID, 'should have viewerID in order to create thread');
this.props.dispatch({
type: createLocalEntryActionType,
payload: createLocalEntry(
threadID,
this.props.nextLocalID,
this.props.dayString,
viewerID,
),
});
};
onHistory = (event: SyntheticEvent) => {
event.preventDefault();
this.props.pushModal(
,
);
};
focusOnFirstEntryNewerThan = (time: number) => {
const entryInfo = this.props.entryInfos.find(
candidate => candidate.creationTime > time,
);
if (entryInfo) {
const entry = this.entries.get(entryKey(entryInfo));
invariant(entry, 'entry for entryinfo should be defined');
entry.focus();
}
};
}
const ConnectedDay: React.ComponentType = React.memo(
function ConnectedDay(props) {
const onScreenThreadInfos = useSelector(onScreenThreadInfosSelector);
const viewerID = useSelector(state => state.currentUserInfo?.id);
const loggedIn = useSelector(
state =>
!!(state.currentUserInfo && !state.currentUserInfo.anonymous && true),
);
const nextLocalID = useSelector(state => state.nextLocalID);
const dispatch = useDispatch();
const { pushModal, popModal } = useModalContext();
return (
);
},
);
export default ConnectedDay;
diff --git a/web/calendar/filter-panel.react.js b/web/calendar/filter-panel.react.js
index 285b32e32..71ee4850d 100644
--- a/web/calendar/filter-panel.react.js
+++ b/web/calendar/filter-panel.react.js
@@ -1,425 +1,425 @@
// @flow
import {
faCog,
faTimesCircle,
faChevronUp,
faChevronDown,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import * as React from 'react';
import { ChevronsLeft } from 'react-feather';
import Switch from 'react-switch';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import {
filteredThreadIDsSelector,
includeDeletedSelector,
} from 'lib/selectors/calendar-filter-selectors.js';
import SearchIndex from 'lib/shared/search-index.js';
import {
calendarThreadFilterTypes,
type FilterThreadInfo,
updateCalendarThreadFilter,
clearCalendarThreadFilter,
setCalendarDeletedFilter,
} from 'lib/types/filter-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import css from './filter-panel.css';
import ThreadSettingsModal from '../modals/threads/settings/thread-settings-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
import {
useFilterThreadInfos,
useFilterThreadSearchIndex,
filterThreadIDsBelongingToCommunitySelector,
} from '../selectors/calendar-selectors.js';
import { MagnifyingGlass } from '../vectors.react.js';
type Props = {
+filterThreadInfos: $ReadOnlyArray,
+filterThreadSearchIndex: SearchIndex,
+filteredThreadIDs: ?$ReadOnlySet,
+filteredCommunityThreadIDs: ?$ReadOnlySet,
+includeDeleted: boolean,
+dispatch: Dispatch,
+pushModal: PushModal,
+toggleFilters: (event: SyntheticEvent) => void,
};
type State = {
+query: string,
+searchResults: $ReadOnlyArray,
+collapsed: boolean,
};
class FilterPanel extends React.PureComponent {
state: State = {
query: '',
searchResults: [],
collapsed: false,
};
currentlySelected(threadID: string): boolean {
if (!this.props.filteredThreadIDs) {
return true;
}
return this.props.filteredThreadIDs.has(threadID);
}
inCurrentCommunity(threadID: string): boolean {
if (!this.props.filteredCommunityThreadIDs) {
return true;
}
return this.props.filteredCommunityThreadIDs.has(threadID);
}
- render() {
+ render(): React.Node {
const filterThreadInfos = this.state.query
? this.state.searchResults
: this.props.filterThreadInfos;
const filterThreadInfosInCurrentCommunity = filterThreadInfos.filter(item =>
this.inCurrentCommunity(item.threadInfo.id),
);
let filters = [];
if (!this.state.query || filterThreadInfosInCurrentCommunity.length > 0) {
filters.push(
,
);
} else {
filters.push(
No results
,
);
}
if (!this.state.collapsed) {
const options = filterThreadInfosInCurrentCommunity.map(
filterThreadInfo => (
),
);
filters = [...filters, ...options];
}
let clearQueryButton = null;
if (this.state.query) {
clearQueryButton = (
);
}
return (
{filters}
Include deleted entries
);
}
onToggle = (threadID: string, value: boolean) => {
let newThreadIDs;
const selectedThreadIDs = this.props.filteredThreadIDs;
if (!selectedThreadIDs && value) {
// No thread filter exists and thread is being added
return;
} else if (!selectedThreadIDs) {
// No thread filter exists and thread is being removed
newThreadIDs = this.props.filterThreadInfos
.map(filterThreadInfo => filterThreadInfo.threadInfo.id)
.filter(id => id !== threadID);
} else if (selectedThreadIDs.has(threadID) && value) {
// Thread filter already includes thread being added
return;
} else if (selectedThreadIDs.has(threadID)) {
// Thread being removed from current thread filter
newThreadIDs = [...selectedThreadIDs].filter(id => id !== threadID);
} else if (!value) {
// Thread filter doesn't include thread being removed
return;
} else if (
selectedThreadIDs.size + 1 ===
this.props.filterThreadInfos.length
) {
// Thread filter exists and thread being added is the only one missing
newThreadIDs = null;
} else {
// Thread filter exists and thread is being added
newThreadIDs = [...selectedThreadIDs, threadID];
}
this.setFilterThreads(newThreadIDs);
};
onToggleAll = (value: boolean) => {
if (!value) {
this.setFilterThreads([]);
return;
}
const allChats = this.props.filteredCommunityThreadIDs
? Array.from(this.props.filteredCommunityThreadIDs)
: null;
this.setFilterThreads(allChats);
};
onClickOnly = (threadID: string) => {
this.setFilterThreads([threadID]);
};
setFilterThreads(threadIDs: ?$ReadOnlyArray) {
if (!threadIDs) {
this.props.dispatch({
type: clearCalendarThreadFilter,
});
} else {
this.props.dispatch({
type: updateCalendarThreadFilter,
payload: {
type: calendarThreadFilterTypes.THREAD_LIST,
threadIDs,
},
});
}
}
onClickSettings = (threadID: string) => {
this.props.pushModal( );
};
onChangeQuery = (event: SyntheticEvent) => {
const query = event.currentTarget.value;
const searchIndex = this.props.filterThreadSearchIndex;
const resultIDs = new Set(searchIndex.getSearchResults(query));
const results = this.props.filterThreadInfos.filter(filterThreadInfo =>
resultIDs.has(filterThreadInfo.threadInfo.id),
);
this.setState({ query, searchResults: results, collapsed: false });
};
clearQuery = (event: SyntheticEvent) => {
event.preventDefault();
this.setState({ query: '', searchResults: [], collapsed: false });
};
onCollapse = (value: boolean) => {
this.setState({ collapsed: value });
};
onChangeIncludeDeleted = (includeDeleted: boolean) => {
this.props.dispatch({
type: setCalendarDeletedFilter,
payload: {
includeDeleted,
},
});
};
}
type ItemProps = {
+filterThreadInfo: FilterThreadInfo,
+onToggle: (threadID: string, value: boolean) => void,
+onClickOnly: (threadID: string) => void,
+onClickSettings: (threadID: string) => void,
+selected: boolean,
};
class Item extends React.PureComponent {
- render() {
+ render(): React.Node {
const threadInfo = this.props.filterThreadInfo.threadInfo;
const beforeCheckStyles = { borderColor: `#${threadInfo.color}` };
let afterCheck = null;
if (this.props.selected) {
const afterCheckStyles = { backgroundColor: `#${threadInfo.color}` };
afterCheck = (
);
}
const details =
this.props.filterThreadInfo.numVisibleEntries === 1
? '1 entry'
: `${this.props.filterThreadInfo.numVisibleEntries} entries`;
return (
);
}
onChange = (event: SyntheticEvent) => {
this.props.onToggle(
this.props.filterThreadInfo.threadInfo.id,
event.currentTarget.checked,
);
};
onClickOnly = (event: SyntheticEvent) => {
event.preventDefault();
this.props.onClickOnly(this.props.filterThreadInfo.threadInfo.id);
};
onClickSettings = (event: SyntheticEvent) => {
event.preventDefault();
this.props.onClickSettings(this.props.filterThreadInfo.threadInfo.id);
};
}
type CategoryProps = {
+numThreads: number,
+onToggle: (value: boolean) => void,
+collapsed: boolean,
+onCollapse: (value: boolean) => void,
+selected: boolean,
};
class Category extends React.PureComponent {
- render() {
+ render(): React.Node {
const beforeCheckStyles = { borderColor: 'white' };
let afterCheck = null;
if (this.props.selected) {
const afterCheckStyles = { backgroundColor: 'white' };
afterCheck = (
);
}
const icon = this.props.collapsed ? faChevronUp : faChevronDown;
const details =
this.props.numThreads === 1 ? '1 chat' : `${this.props.numThreads} chats`;
return (
);
}
onChange = (event: SyntheticEvent) => {
this.props.onToggle(event.currentTarget.checked);
};
onCollapse = (event: SyntheticEvent) => {
event.preventDefault();
this.props.onCollapse(!this.props.collapsed);
};
}
type ConnectedFilterPanelProps = {
+toggleFilters: (event: SyntheticEvent) => void,
};
const ConnectedFilterPanel: React.ComponentType =
React.memo(function ConnectedFilterPanel(
props: ConnectedFilterPanelProps,
): React.Node {
const filteredThreadIDs = useSelector(filteredThreadIDsSelector);
const filteredCommunityThreadIDs = useSelector(
filterThreadIDsBelongingToCommunitySelector,
);
const filterThreadInfos = useFilterThreadInfos();
const filterThreadSearchIndex = useFilterThreadSearchIndex();
const includeDeleted = useSelector(includeDeletedSelector);
const dispatch = useDispatch();
const modalContext = useModalContext();
return (
);
});
export default ConnectedFilterPanel;
diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js
index 4893031a0..b7283a5ab 100644
--- a/web/chat/chat-input-bar.react.js
+++ b/web/chat/chat-input-bar.react.js
@@ -1,688 +1,688 @@
// @flow
import invariant from 'invariant';
import _difference from 'lodash/fp/difference.js';
import * as React from 'react';
import {
joinThreadActionTypes,
useJoinThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
useChatMentionContext,
useThreadChatMentionCandidates,
} from 'lib/hooks/chat-mention-hooks.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userStoreMentionSearchIndex } from 'lib/selectors/user-selectors.js';
import {
getMentionTypeaheadUserSuggestions,
getTypeaheadRegexMatches,
getUserMentionsCandidates,
getMentionTypeaheadChatSuggestions,
type MentionTypeaheadSuggestionItem,
type TypeaheadMatchedStrings,
} from 'lib/shared/mention-utils.js';
import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
checkIfDefaultMembersAreVoiced,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type ThreadInfo,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
} from 'lib/types/thread-types.js';
import { type UserInfos } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import css from './chat-input-bar.css';
import TypeaheadTooltip from './typeahead-tooltip.react.js';
import Button from '../components/button.react.js';
import {
type InputState,
type PendingMultimediaUpload,
} from '../input/input-state.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
import Multimedia from '../media/multimedia.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
import {
webMentionTypeaheadRegex,
getMentionTypeaheadTooltipActions,
getMentionTypeaheadTooltipButtons,
} from '../utils/typeahead-utils.js';
type BaseProps = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+inputState: InputState,
};
type Props = {
...BaseProps,
+viewerID: ?string,
+joinThreadLoadingStatus: LoadingStatus,
+threadCreationInProgress: boolean,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+isThreadActive: boolean,
+userInfos: UserInfos,
+dispatchActionPromise: DispatchActionPromise,
+joinThread: (request: ClientThreadJoinRequest) => Promise,
+typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
+suggestions: $ReadOnlyArray,
+parentThreadInfo: ?ThreadInfo,
};
class ChatInputBar extends React.PureComponent {
textarea: ?HTMLTextAreaElement;
multimediaInput: ?HTMLInputElement;
componentDidMount() {
this.updateHeight();
if (this.props.isThreadActive) {
this.addReplyListener();
}
}
componentWillUnmount() {
if (this.props.isThreadActive) {
this.removeReplyListener();
}
}
componentDidUpdate(prevProps: Props) {
if (this.props.isThreadActive && !prevProps.isThreadActive) {
this.addReplyListener();
} else if (!this.props.isThreadActive && prevProps.isThreadActive) {
this.removeReplyListener();
}
const { inputState } = this.props;
const prevInputState = prevProps.inputState;
if (inputState.draft !== prevInputState.draft) {
this.updateHeight();
}
if (
inputState.draft !== prevInputState.draft ||
inputState.textCursorPosition !== prevInputState.textCursorPosition
) {
inputState.setTypeaheadState({
canBeVisible: true,
});
}
const curUploadIDs = ChatInputBar.unassignedUploadIDs(
inputState.pendingUploads,
);
const prevUploadIDs = ChatInputBar.unassignedUploadIDs(
prevInputState.pendingUploads,
);
if (
this.multimediaInput &&
_difference(prevUploadIDs)(curUploadIDs).length > 0
) {
// Whenever a pending upload is removed, we reset the file
// HTMLInputElement's value field, so that if the same upload occurs again
// the onChange call doesn't get filtered
this.multimediaInput.value = '';
} else if (
this.textarea &&
_difference(curUploadIDs)(prevUploadIDs).length > 0
) {
// Whenever a pending upload is added, we focus the textarea
this.textarea.focus();
return;
}
if (
(this.props.threadInfo.id !== prevProps.threadInfo.id ||
(inputState.textCursorPosition !== prevInputState.textCursorPosition &&
this.textarea?.selectionStart === this.textarea?.selectionEnd)) &&
this.textarea
) {
this.textarea.focus();
this.textarea?.setSelectionRange(
inputState.textCursorPosition,
inputState.textCursorPosition,
'none',
);
}
}
static unassignedUploadIDs(
pendingUploads: $ReadOnlyArray,
- ) {
+ ): Array {
return pendingUploads
.filter(
(pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID,
)
.map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID);
}
updateHeight() {
const textarea = this.textarea;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 150);
textarea.style.height = `${newHeight}px`;
}
}
addReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in addReplyListener',
);
this.props.inputState.addReplyListener(this.focusAndUpdateText);
}
removeReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in removeReplyListener',
);
this.props.inputState.removeReplyListener(this.focusAndUpdateText);
}
- render() {
+ render(): React.Node {
const isMember = viewerIsMember(this.props.threadInfo);
const canJoin = threadHasPermission(
this.props.threadInfo,
threadPermissions.JOIN_THREAD,
);
let joinButton = null;
if (!isMember && canJoin && !this.props.threadCreationInProgress) {
let buttonContent;
if (this.props.joinThreadLoadingStatus === 'loading') {
buttonContent = (
);
} else {
buttonContent = (
<>
Join Chat
>
);
}
joinButton = (
{buttonContent}
);
}
const { pendingUploads, cancelPendingUpload } = this.props.inputState;
const multimediaPreviews = pendingUploads.map(pendingUpload => {
const { uri, mediaType, thumbHash, dimensions } = pendingUpload;
let mediaSource = { thumbHash, dimensions };
if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') {
mediaSource = {
...mediaSource,
type: mediaType,
uri,
thumbnailURI: null,
};
} else {
const { encryptionKey } = pendingUpload;
invariant(
encryptionKey,
'encryptionKey should be set for encrypted media',
);
mediaSource = {
...mediaSource,
type: mediaType,
blobURI: uri,
encryptionKey,
thumbnailBlobURI: null,
thumbnailEncryptionKey: null,
};
}
return (
);
});
const previews =
multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null;
let content;
// If the thread is created by somebody else while the viewer is attempting
// to create it, the threadInfo might be modified in-place and won't
// list the viewer as a member, which will end up hiding the input. In
// this case, we will assume that our creation action will get translated,
// into a join and as long as members are voiced, we can show the input.
const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced(
this.props.threadInfo,
);
let sendButton;
if (this.props.inputState.draft.length) {
sendButton = (
);
}
if (
threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) ||
(this.props.threadCreationInProgress && defaultMembersAreVoiced)
) {
content = (
);
} else if (
threadFrozenDueToViewerBlock(
this.props.threadInfo,
this.props.viewerID,
this.props.userInfos,
) &&
threadActualMembers(this.props.threadInfo.members).length === 2
) {
content = (
You can’t send messages to a user that you’ve blocked.
);
} else if (isMember) {
content = (
You don’t have permission to send messages.
);
} else if (defaultMembersAreVoiced && canJoin) {
content = null;
} else {
content = (
You don’t have permission to send messages.
);
}
let typeaheadTooltip;
if (
this.props.inputState.typeaheadState.canBeVisible &&
this.props.suggestions.length > 0 &&
this.props.typeaheadMatchedStrings &&
this.textarea
) {
typeaheadTooltip = (
);
}
return (
{joinButton}
{previews}
{content}
{typeaheadTooltip}
);
}
textareaRef = (textarea: ?HTMLTextAreaElement) => {
this.textarea = textarea;
if (textarea) {
textarea.focus();
}
};
onChangeMessageText = (event: SyntheticEvent) => {
this.props.inputState.setDraft(event.currentTarget.value);
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onClickTextarea = (event: SyntheticEvent) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onSelectTextarea = (event: SyntheticEvent) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
focusAndUpdateText = (text: string) => {
// We need to call focus() first on Safari, otherwise the cursor
// ends up at the start instead of the end for some reason
const { textarea } = this;
invariant(textarea, 'textarea should be set');
textarea.focus();
// We reset the textarea to an empty string at the start so that the cursor
// always ends up at the end, even if the text doesn't actually change
textarea.value = '';
const currentText = this.props.inputState.draft;
if (!currentText.startsWith(text)) {
const prependedText = text.concat(currentText);
this.props.inputState.setDraft(prependedText);
textarea.value = prependedText;
} else {
textarea.value = currentText;
}
// The above strategies make sure the cursor is at the end,
// but we also need to make sure that we're scrolled to the bottom
textarea.scrollTop = textarea.scrollHeight;
};
onKeyDown = (event: SyntheticKeyboardEvent) => {
const { accept, close, moveChoiceUp, moveChoiceDown } =
this.props.inputState.typeaheadState;
const actions = {
Enter: accept,
Tab: accept,
ArrowDown: moveChoiceDown,
ArrowUp: moveChoiceUp,
Escape: close,
};
if (
this.props.inputState.typeaheadState.canBeVisible &&
actions[event.key]
) {
event.preventDefault();
actions[event.key]();
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.send();
}
};
onSend = (event: SyntheticEvent) => {
event.preventDefault();
this.send();
};
send() {
let { nextLocalID } = this.props;
const text = trimMessage(this.props.inputState.draft);
if (text) {
this.dispatchTextMessageAction(text, nextLocalID);
nextLocalID++;
}
if (this.props.inputState.pendingUploads.length > 0) {
this.props.inputState.createMultimediaMessage(
nextLocalID,
this.props.threadInfo,
);
}
}
dispatchTextMessageAction(text: string, nextLocalID: number) {
this.props.inputState.setDraft('');
const localID = `${localIDPrefix}${nextLocalID}`;
const creatorID = this.props.viewerID;
invariant(creatorID, 'should have viewer ID in order to send a message');
this.props.inputState.sendTextMessage(
{
type: messageTypes.TEXT,
localID,
threadID: this.props.threadInfo.id,
text,
creatorID,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
}
multimediaInputRef = (multimediaInput: ?HTMLInputElement) => {
this.multimediaInput = multimediaInput;
};
onMultimediaClick = () => {
if (this.multimediaInput) {
this.multimediaInput.click();
}
};
onMultimediaFileChange = async (
event: SyntheticInputEvent,
) => {
const result = await this.props.inputState.appendFiles(
this.props.threadInfo,
[...event.target.files],
);
if (!result && this.multimediaInput) {
this.multimediaInput.value = '';
}
};
onClickJoin = () => {
this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction());
};
- async joinAction() {
+ async joinAction(): Promise {
const query = this.props.calendarQuery();
return await this.props.joinThread({
threadID: this.props.threadInfo.id,
calendarQuery: {
startDate: query.startDate,
endDate: query.endDate,
filters: [
...query.filters,
{ type: 'threads', threadIDs: [this.props.threadInfo.id] },
],
},
});
}
}
const joinThreadLoadingStatusSelector = createLoadingStatusSelector(
joinThreadActionTypes,
);
const createThreadLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
const ConnectedChatInputBar: React.ComponentType =
React.memo(function ConnectedChatInputBar(props) {
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const nextLocalID = useSelector(state => state.nextLocalID);
const isThreadActive = useSelector(
state => props.threadInfo.id === state.navInfo.activeChatThreadID,
);
const userInfos = useSelector(state => state.userStore.userInfos);
const joinThreadLoadingStatus = useSelector(
joinThreadLoadingStatusSelector,
);
const createThreadLoadingStatus = useSelector(
createThreadLoadingStatusSelector,
);
const threadCreationInProgress = createThreadLoadingStatus === 'loading';
const calendarQuery = useSelector(nonThreadCalendarQuery);
const dispatchActionPromise = useDispatchActionPromise();
const callJoinThread = useJoinThread();
const userSearchIndex = useSelector(userStoreMentionSearchIndex);
const { getChatMentionSearchIndex } = useChatMentionContext();
const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const userMentionsCandidates = getUserMentionsCandidates(
props.threadInfo,
parentThreadInfo,
);
const chatMentionCandidates = useThreadChatMentionCandidates(
props.threadInfo,
);
const typeaheadRegexMatches = React.useMemo(
() =>
getTypeaheadRegexMatches(
props.inputState.draft,
{
start: props.inputState.textCursorPosition,
end: props.inputState.textCursorPosition,
},
webMentionTypeaheadRegex,
),
[props.inputState.textCursorPosition, props.inputState.draft],
);
const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo(
() =>
typeaheadRegexMatches !== null
? {
textBeforeAtSymbol:
typeaheadRegexMatches.groups?.textPrefix ?? '',
query: typeaheadRegexMatches.groups?.mentionText ?? '',
}
: null,
[typeaheadRegexMatches],
);
React.useEffect(() => {
if (props.inputState.typeaheadState.keepUpdatingThreadMembers) {
const setter = props.inputState.setTypeaheadState;
setter({
frozenUserMentionsCandidates: userMentionsCandidates,
frozenChatMentionsCandidates: chatMentionCandidates,
});
}
}, [
userMentionsCandidates,
props.inputState.setTypeaheadState,
props.inputState.typeaheadState.keepUpdatingThreadMembers,
chatMentionCandidates,
]);
const suggestions = React.useMemo(() => {
if (!typeaheadMatchedStrings) {
return [];
}
const suggestedUsers = getMentionTypeaheadUserSuggestions(
userSearchIndex,
props.inputState.typeaheadState.frozenUserMentionsCandidates,
viewerID,
typeaheadMatchedStrings.query,
);
const suggestedChats = getMentionTypeaheadChatSuggestions(
chatMentionSearchIndex,
props.inputState.typeaheadState.frozenChatMentionsCandidates,
typeaheadMatchedStrings.query,
);
return [...suggestedUsers, ...suggestedChats];
}, [
typeaheadMatchedStrings,
userSearchIndex,
props.inputState.typeaheadState.frozenUserMentionsCandidates,
props.inputState.typeaheadState.frozenChatMentionsCandidates,
viewerID,
chatMentionSearchIndex,
]);
return (
);
});
export default ConnectedChatInputBar;
diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js
index 2a4d93ab1..7b490cacb 100644
--- a/web/chat/chat-message-list.react.js
+++ b/web/chat/chat-message-list.react.js
@@ -1,487 +1,487 @@
// @flow
import classNames from 'classnames';
import { detect as detectBrowser } from 'detect-browser';
import invariant from 'invariant';
import _debounce from 'lodash/debounce.js';
import * as React from 'react';
import {
fetchMessagesBeforeCursorActionTypes,
useFetchMessagesBeforeCursor,
fetchMostRecentMessagesActionTypes,
useFetchMostRecentMessages,
} from 'lib/actions/message-actions.js';
import type {
FetchMostRecentMessagesInput,
FetchMessagesBeforeCursorInput,
} from 'lib/actions/message-actions.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js';
import { registerFetchKey } from 'lib/reducers/loading-reducer.js';
import {
type ChatMessageItem,
useMessageListData,
} from 'lib/selectors/chat-selectors.js';
import { messageKey } from 'lib/shared/message-utils.js';
import { threadIsPending } from 'lib/shared/thread-utils.js';
import type { FetchMessageInfosPayload } from 'lib/types/message-types.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js';
import css from './chat-message-list.css';
import type { ScrollToMessageCallback } from './edit-message-provider.js';
import { useEditModalContext } from './edit-message-provider.js';
import { MessageListContext } from './message-list-types.js';
import Message from './message.react.js';
import RelationshipPrompt from './relationship-prompt/relationship-prompt.js';
import { useTooltipContext } from './tooltip-provider.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { useTextMessageRulesFunc } from '../markdown/rules.react.js';
import { useSelector } from '../redux/redux-utils.js';
const browser = detectBrowser();
const supportsReverseFlex =
!browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81;
// Margin between the top of the maximum height edit box
// and the top of the container
const editBoxTopMargin = 10;
type BaseProps = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
type Props = {
...BaseProps,
+activeChatThreadID: ?string,
+messageListData: ?$ReadOnlyArray,
+startReached: boolean,
+dispatchActionPromise: DispatchActionPromise,
+fetchMessagesBeforeCursor: (
input: FetchMessagesBeforeCursorInput,
) => Promise,
+fetchMostRecentMessages: (
input: FetchMostRecentMessagesInput,
) => Promise,
+inputState: ?InputState,
+clearTooltip: () => mixed,
+oldestMessageServerID: ?string,
+isEditState: boolean,
+addScrollToMessageListener: ScrollToMessageCallback => mixed,
+removeScrollToMessageListener: ScrollToMessageCallback => mixed,
};
type Snapshot = {
+scrollTop: number,
+scrollHeight: number,
};
type State = {
+scrollingEndCallback: ?() => mixed,
};
class ChatMessageList extends React.PureComponent {
container: ?HTMLDivElement;
messageContainer: ?HTMLDivElement;
loadingFromScroll = false;
constructor(props: Props) {
super(props);
this.state = {
scrollingEndCallback: null,
};
}
componentDidMount() {
this.scrollToBottom();
this.props.addScrollToMessageListener(this.scrollToMessage);
}
componentWillUnmount() {
this.props.removeScrollToMessageListener(this.scrollToMessage);
}
- getSnapshotBeforeUpdate(prevProps: Props) {
+ getSnapshotBeforeUpdate(prevProps: Props): ?Snapshot {
if (
ChatMessageList.hasNewMessage(this.props, prevProps) &&
this.messageContainer
) {
const { scrollTop, scrollHeight } = this.messageContainer;
return { scrollTop, scrollHeight };
}
return null;
}
- static hasNewMessage(props: Props, prevProps: Props) {
+ static hasNewMessage(props: Props, prevProps: Props): boolean {
const { messageListData } = props;
if (!messageListData || messageListData.length === 0) {
return false;
}
const prevMessageListData = prevProps.messageListData;
if (!prevMessageListData || prevMessageListData.length === 0) {
return true;
}
return (
ChatMessageList.keyExtractor(prevMessageListData[0]) !==
ChatMessageList.keyExtractor(messageListData[0])
);
}
- componentDidUpdate(prevProps: Props, prevState, snapshot: ?Snapshot) {
+ componentDidUpdate(prevProps: Props, prevState: State, snapshot: ?Snapshot) {
const { messageListData } = this.props;
const prevMessageListData = prevProps.messageListData;
const { messageContainer } = this;
if (messageContainer && prevMessageListData !== messageListData) {
this.onScroll();
}
// We'll scroll to the bottom if the user was already scrolled to the bottom
// before the new message, or if the new message was composed locally
const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps);
if (
this.props.activeChatThreadID !== prevProps.activeChatThreadID ||
(hasNewMessage &&
messageListData &&
messageListData[0].itemType === 'message' &&
messageListData[0].messageInfo.localID) ||
(hasNewMessage &&
snapshot &&
Math.abs(snapshot.scrollTop) <= 1 &&
!this.props.isEditState)
) {
this.scrollToBottom();
} else if (hasNewMessage && messageContainer && snapshot) {
const { scrollTop, scrollHeight } = messageContainer;
if (
scrollHeight > snapshot.scrollHeight &&
scrollTop === snapshot.scrollTop
) {
const newHeight = scrollHeight - snapshot.scrollHeight;
const newScrollTop = Math.abs(scrollTop) + newHeight;
if (supportsReverseFlex) {
messageContainer.scrollTop = -1 * newScrollTop;
} else {
messageContainer.scrollTop = newScrollTop;
}
}
}
}
scrollToBottom() {
if (this.messageContainer) {
this.messageContainer.scrollTop = 0;
}
}
- static keyExtractor(item: ChatMessageItem) {
+ static keyExtractor(item: ChatMessageItem): string {
if (item.itemType === 'loader') {
return 'loader';
}
return messageKey(item.messageInfo);
}
- renderItem = item => {
+ renderItem = (item: ChatMessageItem): React.Node => {
if (item.itemType === 'loader') {
return (
);
}
const { threadInfo } = this.props;
invariant(threadInfo, 'ThreadInfo should be set if messageListData is');
return (
);
};
scrollingEndCallbackWrapper = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
): (() => mixed) => {
return () => {
const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID);
callback(maxHeight);
};
};
scrollToMessage = (
composedMessageID: string,
callback: (maxHeight: number) => mixed,
) => {
const element = document.getElementById(composedMessageID);
if (!element) {
return;
}
const scrollingEndCallback = this.scrollingEndCallbackWrapper(
composedMessageID,
callback,
);
if (!this.willMessageEditWindowOverflow(composedMessageID)) {
scrollingEndCallback();
return;
}
this.setState(
{
scrollingEndCallback,
},
() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// It covers the case when browser decide not to scroll to the message
// because it's already in the view.
// In this case, the 'scroll' event won't be triggered,
// so we need to call the callback manually.
this.debounceEditModeAfterScrollToMessage();
},
);
};
getMaxEditTextAreaHeight = (composedMessageID: string): number => {
const { messageContainer } = this;
if (!messageContainer) {
return defaultMaxTextAreaHeight;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return defaultMaxTextAreaHeight;
}
const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();
const messageBottom = msgPos.bottom;
const containerTop = containerPos.top;
const maxHeight =
messageBottom - containerTop - editBoxHeight - editBoxTopMargin;
return maxHeight;
};
- willMessageEditWindowOverflow(composedMessageID: string) {
+ willMessageEditWindowOverflow(composedMessageID: string): boolean {
const { messageContainer } = this;
if (!messageContainer) {
return false;
}
const messageElement = document.getElementById(composedMessageID);
if (!messageElement) {
console.log(`couldn't find the message element`);
return false;
}
const msgPos = messageElement.getBoundingClientRect();
const containerPos = messageContainer.getBoundingClientRect();
const containerTop = containerPos.top;
const containerBottom = containerPos.bottom;
const availableTextAreaHeight =
(containerBottom - containerTop) / 2 - editBoxHeight;
const messageHeight = msgPos.height;
const expectedMinimumHeight = Math.min(
defaultMaxTextAreaHeight,
availableTextAreaHeight,
);
const offset = Math.max(
0,
expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight,
);
const messageTop = msgPos.top - offset;
const messageBottom = msgPos.bottom;
return messageBottom > containerBottom || messageTop < containerTop;
}
- render() {
+ render(): React.Node {
const { messageListData, threadInfo, inputState, isEditState } = this.props;
if (!messageListData) {
return
;
}
invariant(inputState, 'InputState should be set');
const messages = messageListData.map(this.renderItem);
let relationshipPrompt = null;
if (threadInfo.type === threadTypes.PERSONAL) {
relationshipPrompt = ;
}
const messageContainerStyle = classNames({
[css.disableAnchor]:
this.state.scrollingEndCallback !== null || isEditState,
[css.messageContainer]: true,
[css.mirroredMessageContainer]: !supportsReverseFlex,
});
return (
{relationshipPrompt}
{messages}
);
}
messageContainerRef = (messageContainer: ?HTMLDivElement) => {
this.messageContainer = messageContainer;
// In case we already have all the most recent messages,
// but they're not enough
this.possiblyLoadMoreMessages();
if (messageContainer) {
messageContainer.addEventListener('scroll', this.onScroll);
}
};
onScroll = () => {
if (!this.messageContainer) {
return;
}
this.props.clearTooltip();
this.possiblyLoadMoreMessages();
this.debounceEditModeAfterScrollToMessage();
};
- debounceEditModeAfterScrollToMessage = _debounce(() => {
+ debounceEditModeAfterScrollToMessage: () => void = _debounce(() => {
if (this.state.scrollingEndCallback) {
this.state.scrollingEndCallback();
}
this.setState({ scrollingEndCallback: null });
}, 100);
async possiblyLoadMoreMessages() {
if (!this.messageContainer) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = this.messageContainer;
if (
this.props.startReached ||
Math.abs(scrollTop) + clientHeight + 55 < scrollHeight
) {
return;
}
if (this.loadingFromScroll) {
return;
}
this.loadingFromScroll = true;
const threadID = this.props.activeChatThreadID;
invariant(threadID, 'should be set');
try {
const { oldestMessageServerID } = this.props;
if (oldestMessageServerID) {
await this.props.dispatchActionPromise(
fetchMessagesBeforeCursorActionTypes,
this.props.fetchMessagesBeforeCursor({
threadID,
beforeMessageID: oldestMessageServerID,
}),
);
} else {
await this.props.dispatchActionPromise(
fetchMostRecentMessagesActionTypes,
this.props.fetchMostRecentMessages({ threadID }),
);
}
} finally {
this.loadingFromScroll = false;
}
}
}
registerFetchKey(fetchMessagesBeforeCursorActionTypes);
registerFetchKey(fetchMostRecentMessagesActionTypes);
const ConnectedChatMessageList: React.ComponentType =
React.memo(function ConnectedChatMessageList(
props: BaseProps,
): React.Node {
const { threadInfo } = props;
const messageListData = useMessageListData({
threadInfo,
searching: false,
userInfoInputArray: [],
});
const startReached = !!useSelector(state => {
const activeID = threadInfo.id;
if (!activeID) {
return null;
}
if (threadIsPending(activeID)) {
return true;
}
const threadMessageInfo = state.messageStore.threads[activeID];
if (!threadMessageInfo) {
return null;
}
return threadMessageInfo.startReached;
});
const dispatchActionPromise = useDispatchActionPromise();
const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor();
const callFetchMostRecentMessages = useFetchMostRecentMessages();
const inputState = React.useContext(InputStateContext);
const { clearTooltip } = useTooltipContext();
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const getTextMessageMarkdownRules = useTextMessageRulesFunc(
threadInfo,
chatMentionCandidates,
);
const messageListContext = React.useMemo(() => {
if (!getTextMessageMarkdownRules) {
return undefined;
}
return { getTextMessageMarkdownRules };
}, [getTextMessageMarkdownRules]);
const oldestMessageServerID = useOldestMessageServerID(threadInfo.id);
const {
editState,
addScrollToMessageListener,
removeScrollToMessageListener,
} = useEditModalContext();
const isEditState = editState !== null;
return (
);
});
export default ConnectedChatMessageList;
diff --git a/web/chat/chat-thread-list-item-menu.react.js b/web/chat/chat-thread-list-item-menu.react.js
index 4d60d98c9..4914e751e 100644
--- a/web/chat/chat-thread-list-item-menu.react.js
+++ b/web/chat/chat-thread-list-item-menu.react.js
@@ -1,77 +1,77 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-thread-list-item-menu.css';
import Button from '../components/button.react.js';
import { useThreadIsActive } from '../selectors/thread-selectors.js';
type Props = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+mostRecentNonLocalMessage: ?string,
+renderStyle?: 'chat' | 'thread',
};
function ChatThreadListItemMenu(props: Props): React.Node {
const { renderStyle = 'chat', threadInfo, mostRecentNonLocalMessage } = props;
const active = useThreadIsActive(threadInfo.id);
const [menuVisible, setMenuVisible] = React.useState(false);
const toggleMenu = React.useCallback(
- event => {
+ (event: SyntheticEvent) => {
event.stopPropagation();
setMenuVisible(!menuVisible);
},
[menuVisible],
);
const hideMenu = React.useCallback(() => {
setMenuVisible(false);
}, []);
const toggleUnreadStatus = useToggleUnreadStatus(
threadInfo,
mostRecentNonLocalMessage,
hideMenu,
);
const onToggleUnreadStatusClicked = React.useCallback(
- event => {
+ (event: SyntheticEvent) => {
event.stopPropagation();
toggleUnreadStatus();
},
[toggleUnreadStatus],
);
const toggleUnreadStatusButtonText = `Mark as ${
threadInfo.currentUser.unread ? 'read' : 'unread'
}`;
const menuIconSize = renderStyle === 'chat' ? 24 : 20;
const menuCls = classNames(css.menu, {
[css.menuSidebar]: renderStyle === 'thread',
});
const btnCls = classNames(css.menuContent, {
[css.menuContentVisible]: menuVisible,
[css.active]: active,
});
return (
{toggleUnreadStatusButtonText}
);
}
export default ChatThreadListItemMenu;
diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js
index c6733d7c3..f5f255a4d 100644
--- a/web/chat/chat-thread-list.react.js
+++ b/web/chat/chat-thread-list.react.js
@@ -1,188 +1,193 @@
// @flow
import invariant from 'invariant';
import _sum from 'lodash/fp/sum.js';
import * as React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { VariableSizeList } from 'react-window';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import genesis from 'lib/facts/genesis.js';
import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { emptyItemText } from 'lib/shared/thread-utils.js';
import ChatThreadListItem from './chat-thread-list-item.react.js';
import ChatThreadListSearch from './chat-thread-list-search.react.js';
import css from './chat-thread-list.css';
import { ThreadListContext } from './thread-list-provider.js';
import BackgroundIllustration from '../assets/background-illustration.react.js';
import Button from '../components/button.react.js';
import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { useOnClickNewThread } from '../selectors/thread-selectors.js';
type Item = ChatThreadItem | { +type: 'search' } | { +type: 'empty' };
const sizes = {
search: 68,
empty: 249,
thread: 81,
sidebars: { sidebar: 32, seeMore: 22, spacer: 6 },
};
const itemKey = (index: number, data: $ReadOnlyArray- ) => {
if (data[index].type === 'search') {
return 'search';
} else if (data[index].type === 'empty') {
return 'empty';
} else {
return data[index].threadInfo.id;
}
};
-const renderItem = ({ index, data, style }) => {
+type RenderItemInput = {
+ +index: number,
+ +data: $ReadOnlyArray
- ,
+ +style: CSSStyleDeclaration,
+};
+const renderItem: RenderItemInput => React.Node = ({ index, data, style }) => {
let item;
if (data[index].type === 'search') {
item =
;
} else if (data[index].type === 'empty') {
item = ;
} else {
item = ;
}
return {item}
;
};
function ChatThreadList(): React.Node {
const threadListContext = React.useContext(ThreadListContext);
invariant(
threadListContext,
'threadListContext should be set in ChatThreadList',
);
const { activeTab, threadList } = threadListContext;
const onClickNewThread = useOnClickNewThread();
const isThreadCreation = useSelector(
state => state.navInfo.chatMode === 'create',
);
const isBackground = activeTab === 'Background';
const communityID = useSelector(state => state.communityPickerStore.chat);
const communityThreadInfo = useSelector(state => {
if (!communityID) {
return null;
}
return threadInfoSelector(state)[communityID];
});
const { pushModal, popModal } = useModalContext();
const onClickCreateSubchannel = React.useCallback(() => {
if (!communityThreadInfo) {
return null;
}
return pushModal(
,
);
}, [popModal, pushModal, communityThreadInfo]);
const isChatCreation = !communityID || communityID === genesis.id;
const onClickCreate = isChatCreation
? onClickNewThread
: onClickCreateSubchannel;
const createButtonText = isChatCreation
? 'Create new chat'
: 'Create new channel';
const threadListContainerRef = React.useRef();
const threads = React.useMemo(
() =>
threadList.filter(
item =>
!communityID ||
item.threadInfo.community === communityID ||
item.threadInfo.id === communityID,
),
[communityID, threadList],
);
React.useEffect(() => {
if (threadListContainerRef.current) {
threadListContainerRef.current.resetAfterIndex(0, false);
}
}, [threads]);
const threadListContainer = React.useMemo(() => {
const items: Item[] = [{ type: 'search' }, ...threads];
if (isBackground && threads.length === 0) {
items.push({ type: 'empty' });
}
- const itemSize = index => {
+ const itemSize = (index: number) => {
if (items[index].type === 'search') {
return sizes.search;
} else if (items[index].type === 'empty') {
return sizes.empty;
}
const sidebarHeight = _sum(
items[index].sidebars.map(s => sizes.sidebars[s.type]),
);
return sizes.thread + sidebarHeight;
};
return (
{({ height }) => (
{renderItem}
)}
);
}, [isBackground, threads]);
return (
<>
{threadListContainer}
{createButtonText}
>
);
}
function EmptyItem() {
return (
);
}
export default ChatThreadList;
diff --git a/web/chat/edit-text-message.react.js b/web/chat/edit-text-message.react.js
index be368d430..212dca52a 100644
--- a/web/chat/edit-text-message.react.js
+++ b/web/chat/edit-text-message.react.js
@@ -1,197 +1,197 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useCallback } from 'react';
import { XCircle as XCircleIcon } from 'react-feather';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { useEditMessage } from 'lib/shared/edit-messages-utils.js';
import { trimMessage } from 'lib/shared/message-utils.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import { editBoxBottomRowHeight } from './chat-constants.js';
import ChatInputTextArea from './chat-input-text-area.react.js';
import ComposedMessage from './composed-message.react.js';
import { useEditModalContext } from './edit-message-provider.js';
import css from './edit-text-message.css';
import type { ButtonColor } from '../components/button.react.js';
import Button from '../components/button.react.js';
type Props = {
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+background: boolean,
};
const cancelButtonColor: ButtonColor = {
backgroundColor: 'transparent',
};
const bottomRowStyle = { height: editBoxBottomRowHeight };
function EditTextMessage(props: Props): React.Node {
const { background, threadInfo, item } = props;
const { editState, clearEditModal, setDraft, setError, updatePosition } =
useEditModalContext();
const editMessage = useEditMessage();
const myRef = React.useRef(null);
const editedMessageDraft = editState?.editedMessageDraft ?? '';
const threadColor = threadInfo.color;
const saveButtonColor: ButtonColor = React.useMemo(
() => ({
backgroundColor: `#${threadColor}`,
}),
[threadColor],
);
const isMessageEmpty = React.useMemo(
() => trimMessage(editedMessageDraft) === '',
[editedMessageDraft],
);
const isMessageEdited = React.useMemo(() => {
const { messageInfo } = item;
if (!messageInfo || !messageInfo.text || !editState) {
return false;
}
if (!editedMessageDraft) {
return false;
}
const trimmedDraft = trimMessage(editedMessageDraft);
return trimmedDraft !== messageInfo.text;
}, [editState, editedMessageDraft, item]);
const checkAndEdit = async () => {
const { id: messageInfoID } = item.messageInfo;
if (isMessageEmpty) {
return;
}
if (!isMessageEdited) {
clearEditModal();
return;
}
if (!messageInfoID || !editState?.editedMessageDraft) {
return;
}
try {
await editMessage(messageInfoID, editState.editedMessageDraft);
clearEditModal();
} catch (e) {
setError(true);
}
};
const updateDimensions = useCallback(() => {
if (!myRef.current || !background) {
return;
}
const { left, top, width, height } = myRef.current.getBoundingClientRect();
updatePosition({
left,
top,
width,
height,
});
}, [background, updatePosition]);
const preventCloseTab = React.useCallback(
- event => {
+ (event: BeforeUnloadEvent) => {
if (!isMessageEdited) {
return null;
}
event.preventDefault();
return (event.returnValue = '');
},
[isMessageEdited],
);
React.useEffect(() => {
if (!background) {
return undefined;
}
window.addEventListener('resize', updateDimensions);
window.addEventListener('beforeunload', preventCloseTab);
return () => {
window.removeEventListener('resize', updateDimensions);
window.removeEventListener('beforeunload', preventCloseTab);
};
}, [background, preventCloseTab, updateDimensions]);
React.useEffect(() => {
updateDimensions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let editFailed;
if (editState?.isError) {
editFailed = (
Edit failed.
Please try again.
);
}
const containerStyle = classNames(css.editMessage, {
[css.backgroundEditMessage]: background,
});
const maxTextAreaHeight = editState?.maxHeight;
return (
{editFailed}
Save (enter)
Cancel (esc)
);
}
const ComposedEditTextMessage: React.ComponentType = React.memo(
function ComposedEditTextMessage(props) {
const { background, ...restProps } = props;
return (
);
},
);
export { EditTextMessage, ComposedEditTextMessage };
diff --git a/web/chat/failed-send.react.js b/web/chat/failed-send.react.js
index 722408775..500d3f126 100644
--- a/web/chat/failed-send.react.js
+++ b/web/chat/failed-send.react.js
@@ -1,163 +1,163 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { messageID } from 'lib/shared/message-utils.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import {
type RawComposableMessageInfo,
assertComposableMessageType,
} from 'lib/types/message-types.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import multimediaMessageSendFailed from './multimedia-message-send-failed.js';
import textMessageSendFailed from './text-message-send-failed.js';
import Button from '../components/button.react.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import { useSelector } from '../redux/redux-utils.js';
type BaseProps = {
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
type Props = {
...BaseProps,
+rawMessageInfo: RawComposableMessageInfo,
+inputState: ?InputState,
+parentThreadInfo: ?ThreadInfo,
};
class FailedSend extends React.PureComponent {
retryingText = false;
retryingMedia = false;
componentDidUpdate(prevProps: Props) {
if (
(this.props.rawMessageInfo.type === messageTypes.IMAGES ||
this.props.rawMessageInfo.type === messageTypes.MULTIMEDIA) &&
(prevProps.rawMessageInfo.type === messageTypes.IMAGES ||
prevProps.rawMessageInfo.type === messageTypes.MULTIMEDIA)
) {
const { inputState } = this.props;
const prevInputState = prevProps.inputState;
invariant(
inputState && prevInputState,
'inputState should be set in FailedSend',
);
const isFailed = multimediaMessageSendFailed(this.props.item, inputState);
const wasFailed = multimediaMessageSendFailed(
prevProps.item,
prevInputState,
);
const isDone =
this.props.item.messageInfo.id !== null &&
this.props.item.messageInfo.id !== undefined;
const wasDone =
prevProps.item.messageInfo.id !== null &&
prevProps.item.messageInfo.id !== undefined;
if ((isFailed && !wasFailed) || (isDone && !wasDone)) {
this.retryingMedia = false;
}
} else if (
this.props.rawMessageInfo.type === messageTypes.TEXT &&
prevProps.rawMessageInfo.type === messageTypes.TEXT
) {
const isFailed = textMessageSendFailed(this.props.item);
const wasFailed = textMessageSendFailed(prevProps.item);
const isDone =
this.props.item.messageInfo.id !== null &&
this.props.item.messageInfo.id !== undefined;
const wasDone =
prevProps.item.messageInfo.id !== null &&
prevProps.item.messageInfo.id !== undefined;
if ((isFailed && !wasFailed) || (isDone && !wasDone)) {
this.retryingText = false;
}
}
}
- render() {
+ render(): React.Node {
return (
Delivery failed.
Retry?
);
}
retrySend = () => {
const { inputState } = this.props;
invariant(inputState, 'inputState should be set in FailedSend');
const { rawMessageInfo } = this.props;
if (rawMessageInfo.type === messageTypes.TEXT) {
if (this.retryingText) {
return;
}
this.retryingText = true;
inputState.sendTextMessage(
{
...rawMessageInfo,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
} else if (
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA
) {
const { localID } = rawMessageInfo;
invariant(localID, 'failed RawMessageInfo should have localID');
if (this.retryingMedia) {
return;
}
this.retryingMedia = true;
inputState.retryMultimediaMessage(localID, this.props.threadInfo);
}
};
}
const ConnectedFailedSend: React.ComponentType =
React.memo(function ConnectedFailedSend(props) {
const { messageInfo } = props.item;
assertComposableMessageType(messageInfo.type);
const id = messageID(messageInfo);
const rawMessageInfo = useSelector(
state => state.messageStore.messages[id],
);
assertComposableMessageType(rawMessageInfo.type);
invariant(
rawMessageInfo.type === messageTypes.TEXT ||
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
'FailedSend should only be used for composable message types',
);
const inputState = React.useContext(InputStateContext);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
return (
);
});
export default ConnectedFailedSend;
diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js
index fcd2ee769..9002205cd 100644
--- a/web/chat/message-tooltip.react.js
+++ b/web/chat/message-tooltip.react.js
@@ -1,232 +1,232 @@
// @flow
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import classNames from 'classnames';
import * as React from 'react';
import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { useNextLocalID } from 'lib/shared/message-utils.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import {
tooltipButtonStyle,
tooltipLabelStyle,
tooltipStyle,
} from './chat-constants.js';
import css from './message-tooltip.css';
import {
useSendReaction,
getEmojiKeyboardPosition,
} from './reaction-message-utils.js';
import { useTooltipContext } from './tooltip-provider.js';
import type {
MessageTooltipAction,
TooltipSize,
TooltipPositionStyle,
} from '../utils/tooltip-utils.js';
type MessageTooltipProps = {
+actions: $ReadOnlyArray,
+messageTimestamp: string,
+tooltipPositionStyle: TooltipPositionStyle,
+tooltipSize: TooltipSize,
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
function MessageTooltip(props: MessageTooltipProps): React.Node {
const {
actions,
messageTimestamp,
tooltipPositionStyle,
tooltipSize,
item,
threadInfo,
} = props;
const { messageInfo, reactions } = item;
const { alignment = 'left' } = tooltipPositionStyle;
const [activeTooltipLabel, setActiveTooltipLabel] = React.useState();
const { shouldRenderEmojiKeyboard } = useTooltipContext();
// emoji-mart actually doesn't render its contents until a useEffect runs:
// https://github.com/missive/emoji-mart/blob/d29728f7b4e295e46f9b64aa80335aa4a3c15b8e/packages/emoji-mart-react/react.tsx#L13-L19
// We need to measure the width/height of the picker, but because of this we
// need to do the measurement in our own useEffect, in order to guarantee it
// runs after emoji-mart's useEffect. To do this, we have to define two pieces
// of React state:
// - emojiKeyboardNode, which will get set by the emoji keyboard's ref and
// will trigger our useEffect
// - emojiKeyboardRenderedNode, which will get set in that useEffect and will
// trigger the rerendering of this component with the correct height/width
const [emojiKeyboardNode, setEmojiKeyboardNode] = React.useState(null);
const [emojiKeyboardRenderedNode, setEmojiKeyboardRenderedNode] =
React.useState(null);
React.useEffect(() => {
if (emojiKeyboardNode) {
// It would be more simple to just call getEmojiKeyboardPosition
// immediately here, but some quirk of emoji-mart causes the width of the
// node to be 0 here. If instead we wait until the next render of this
// component to check the width, it ends up being set correctly.
setEmojiKeyboardRenderedNode(emojiKeyboardNode);
}
}, [emojiKeyboardNode]);
const messageActionButtonsContainerClassName = classNames(
css.messageActionContainer,
css.messageActionButtons,
);
const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []);
const tooltipButtons = React.useMemo(() => {
if (!actions || actions.length === 0) {
return null;
}
const buttons = actions.map(({ label, onClick, actionButtonContent }) => {
const onMouseEnter = () => {
setActiveTooltipLabel(label);
};
const onMouseLeave = () =>
setActiveTooltipLabel(oldLabel =>
label === oldLabel ? null : oldLabel,
);
return (
{actionButtonContent}
);
});
return (
{buttons}
);
}, [
actions,
messageActionButtonsContainerClassName,
messageTooltipButtonStyle,
]);
const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []);
const messageTooltipTopLabelStyle = React.useMemo(
() => ({
height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`,
}),
[],
);
const tooltipLabel = React.useMemo(() => {
if (!activeTooltipLabel) {
return null;
}
return (
{activeTooltipLabel}
);
}, [activeTooltipLabel, messageTooltipLabelStyle]);
const tooltipTimestamp = React.useMemo(() => {
if (!messageTimestamp) {
return null;
}
return (
{messageTimestamp}
);
}, [messageTimestamp, messageTooltipLabelStyle]);
const emojiKeyboardPosition = React.useMemo(
() =>
getEmojiKeyboardPosition(
emojiKeyboardRenderedNode,
tooltipPositionStyle,
tooltipSize,
),
[emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize],
);
const emojiKeyboardPositionStyle = React.useMemo(() => {
if (!emojiKeyboardPosition) {
return null;
}
return {
bottom: emojiKeyboardPosition.bottom,
left: emojiKeyboardPosition.left,
};
}, [emojiKeyboardPosition]);
const localID = useNextLocalID();
const sendReaction = useSendReaction(
messageInfo.id,
localID,
threadInfo.id,
reactions,
);
const onEmojiSelect = React.useCallback(
- emoji => {
+ (emoji: { +native: string, ... }) => {
const reactionInput = emoji.native;
sendReaction(reactionInput);
},
[sendReaction],
);
const emojiKeyboard = React.useMemo(() => {
if (!shouldRenderEmojiKeyboard) {
return null;
}
return (
);
}, [emojiKeyboardPositionStyle, onEmojiSelect, shouldRenderEmojiKeyboard]);
const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []);
const containerClassName = classNames({
[css.messageTooltipContainer]: true,
[css.leftTooltipAlign]: alignment === 'left',
[css.centerTooltipAlign]: alignment === 'center',
[css.rightTooltipAlign]: alignment === 'right',
});
return (
<>
{emojiKeyboard}
{tooltipLabel}
{tooltipButtons}
{tooltipTimestamp}
>
);
}
export default MessageTooltip;
diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js
index edefaacad..cfb251eaa 100644
--- a/web/chat/multimedia-message.react.js
+++ b/web/chat/multimedia-message.react.js
@@ -1,111 +1,111 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
encryptedMediaBlobURI,
encryptedVideoThumbnailBlobURI,
} from 'lib/media/media-utils.js';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import ComposedMessage from './composed-message.react.js';
import sendFailed from './multimedia-message-send-failed.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import Multimedia from '../media/multimedia.react.js';
type BaseProps = {
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+shouldDisplayPinIndicator: boolean,
};
type Props = {
...BaseProps,
// withInputState
+inputState: ?InputState,
};
class MultimediaMessage extends React.PureComponent {
- render() {
+ render(): React.Node {
const { item, inputState } = this.props;
invariant(
item.messageInfo.type === messageTypes.IMAGES ||
item.messageInfo.type === messageTypes.MULTIMEDIA,
'MultimediaMessage should only be used for multimedia messages',
);
const { localID, media } = item.messageInfo;
invariant(inputState, 'inputState should be set in MultimediaMessage');
const pendingUploads = localID ? inputState.assignedUploads[localID] : null;
const multimedia = [];
for (const singleMedia of media) {
const pendingUpload = pendingUploads
? pendingUploads.find(upload => upload.localID === singleMedia.id)
: null;
const thumbHash = singleMedia.thumbHash ?? singleMedia.thumbnailThumbHash;
let mediaSource;
if (singleMedia.type === 'photo' || singleMedia.type === 'video') {
const { type, uri, thumbnailURI, dimensions } = singleMedia;
mediaSource = { type, uri, thumbHash, thumbnailURI, dimensions };
} else {
const { type, encryptionKey, thumbnailEncryptionKey, dimensions } =
singleMedia;
const blobURI = encryptedMediaBlobURI(singleMedia);
const thumbnailBlobURI =
singleMedia.type === 'encrypted_video'
? encryptedVideoThumbnailBlobURI(singleMedia)
: null;
mediaSource = {
type,
blobURI,
encryptionKey,
thumbnailBlobURI,
thumbnailEncryptionKey,
dimensions,
thumbHash,
};
}
multimedia.push(
,
);
}
invariant(multimedia.length > 0, 'should be at least one multimedia...');
const content =
multimedia.length > 1 ? (
{multimedia}
) : (
multimedia
);
return (
1}
borderRadius={16}
>
{content}
);
}
}
const ConnectedMultimediaMessage: React.ComponentType =
React.memo(function ConnectedMultimediaMessage(props) {
const inputState = React.useContext(InputStateContext);
return ;
});
export default ConnectedMultimediaMessage;
diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js
index 7f43e33d9..d665baf8c 100644
--- a/web/chat/robotext-message.react.js
+++ b/web/chat/robotext-message.react.js
@@ -1,164 +1,164 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import {
entityTextToReact,
useENSNamesForEntityText,
} from 'lib/utils/entity-text.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import InlineEngagement from './inline-engagement.react.js';
import css from './robotext-message.css';
import Markdown from '../markdown/markdown.react.js';
import { linkRules } from '../markdown/rules.react.js';
import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { useMessageTooltip } from '../utils/tooltip-action-utils.js';
import { tooltipPositions } from '../utils/tooltip-utils.js';
const availableTooltipPositionsForRobotext = [
tooltipPositions.LEFT,
tooltipPositions.LEFT_TOP,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_TOP,
tooltipPositions.RIGHT_BOTTOM,
];
type Props = {
+item: RobotextChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
function RobotextMessage(props: Props): React.Node {
let inlineEngagement;
const { item, threadInfo } = props;
const { threadCreatedFromMessage, reactions } = item;
if (threadCreatedFromMessage || Object.keys(reactions).length > 0) {
inlineEngagement = (
);
}
const { messageInfo, robotext } = item;
const { threadID } = messageInfo;
const robotextWithENSNames = useENSNamesForEntityText(robotext);
invariant(
robotextWithENSNames,
'useENSNamesForEntityText only returns falsey when passed falsey',
);
const textParts = React.useMemo(() => {
return entityTextToReact(robotextWithENSNames, threadID, {
// eslint-disable-next-line react/display-name
renderText: ({ text }) => (
{text}
),
// eslint-disable-next-line react/display-name
renderThread: ({ id, name }) => ,
// eslint-disable-next-line react/display-name
renderUser: ({ userID, usernameText }) => (
),
// eslint-disable-next-line react/display-name
renderColor: ({ hex }) => ,
});
}, [robotextWithENSNames, threadID]);
const { onMouseEnter, onMouseLeave } = useMessageTooltip({
item,
threadInfo,
availablePositions: availableTooltipPositionsForRobotext,
});
return (
{textParts}
{inlineEngagement}
);
}
type BaseInnerThreadEntityProps = {
+id: string,
+name: string,
};
type InnerThreadEntityProps = {
...BaseInnerThreadEntityProps,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+dispatch: Dispatch,
};
class InnerThreadEntity extends React.PureComponent {
- render() {
+ render(): React.Node {
return {this.props.name} ;
}
onClickThread = (event: SyntheticEvent) => {
event.preventDefault();
const id = this.props.id;
this.props.dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: id,
},
});
};
}
const ThreadEntity = React.memo(
function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) {
const { id } = props;
const threadInfo = useSelector(state => threadInfoSelector(state)[id]);
const dispatch = useDispatch();
return (
);
},
);
type UserEntityProps = {
+userID: string,
+usernameText: string,
};
function UserEntity(props: UserEntityProps) {
const { userID, usernameText } = props;
const pushUserProfileModal = usePushUserProfileModal(userID);
return {usernameText} ;
}
function ColorEntity(props: { color: string }) {
const colorStyle = { color: props.color };
return {props.color} ;
}
const MemoizedRobotextMessage: React.ComponentType =
React.memo(RobotextMessage);
export default MemoizedRobotextMessage;
diff --git a/web/components/button.react.js b/web/components/button.react.js
index bf8554d30..66d1fca27 100644
--- a/web/components/button.react.js
+++ b/web/components/button.react.js
@@ -1,78 +1,78 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import css from './button.css';
export type ButtonVariant = 'plain' | 'filled' | 'outline' | 'text';
export type ButtonColor = {
+backgroundColor?: string,
+color?: string,
+borderColor?: string,
};
export const buttonThemes: { [string]: ButtonColor } = {
standard: {
backgroundColor: 'var(--button-background-primary-default)',
},
danger: {
backgroundColor: 'var(--btn-bg-danger)',
},
success: {
backgroundColor: 'var(--btn-bg-success)',
},
outline: {
backgroundColor: 'var(--btn-bg-outline)',
},
};
export type ButtonProps = {
...React.ElementConfig<'button'>,
+variant?: ButtonVariant,
+buttonColor?: ButtonColor,
};
function Button(props: ButtonProps): React.Node {
const {
variant = 'plain',
buttonColor,
children,
type = 'button',
className,
...buttonProps
} = props;
const btnCls = classnames(
{
[css.plain]: true,
[css.btn]: variant === 'filled' || variant === 'outline',
[css[variant]]: true,
},
className,
);
let style = {};
if (buttonColor) {
style = buttonColor;
} else if (variant === 'outline') {
style = buttonThemes.outline;
} else if (variant === 'filled') {
style = buttonThemes.standard;
}
- const wrappedChildren = React.Children.map(children, child => {
+ const wrappedChildren = React.Children.map(children, (child: React.Node) => {
if (typeof child === 'string' || typeof child === 'number') {
return {child} ;
}
return child;
});
return (
{wrappedChildren}
);
}
export default Button;
diff --git a/web/components/dropdown.react.js b/web/components/dropdown.react.js
index b01ee6c3d..761a71d2e 100644
--- a/web/components/dropdown.react.js
+++ b/web/components/dropdown.react.js
@@ -1,110 +1,110 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import css from './dropdown.css';
type DropdownOption = {
+id: string,
+name: string,
};
type DropdownProps = {
+options: $ReadOnlyArray,
+activeSelection: string,
+setActiveSelection: string => mixed,
+disabled?: boolean,
};
function Dropdown(props: DropdownProps): React.Node {
const { options, activeSelection, setActiveSelection, disabled } = props;
const [isOpen, setIsOpen] = React.useState(false);
const dropdownMenuClassNames = classNames({
[css.dropdownMenu]: true,
[css.dropdownDisabled]: !!disabled,
});
const dropdownTextClassNames = classNames({
[css.dropdownDisplayText]: true,
[css.dropdownDisabled]: !!disabled,
});
const dropdownIconClassNames = classNames({
[css.dropdownIcon]: true,
[css.dropdownDisabled]: !!disabled,
});
const toggleMenu = React.useCallback(() => {
if (disabled) {
return;
}
setIsOpen(!isOpen);
}, [disabled, isOpen]);
const handleSelection = React.useCallback(
- selection => {
+ (selection: DropdownOption) => {
setActiveSelection(selection.id);
setIsOpen(false);
},
[setActiveSelection],
);
const activeDisplayedOption = React.useMemo(() => {
const activeOption = options.find(option => option.id === activeSelection);
invariant(activeOption, 'Active option must be in options list');
return activeOption.name;
}, [activeSelection, options]);
const dropdownList = React.useMemo(() => {
if (!isOpen) {
return null;
}
const dropdownOptions = options.map(option => {
const checkIcon =
option.id === activeSelection ? (
) : null;
return (
handleSelection(option)}
>
{option.name}
{checkIcon}
);
});
return ;
}, [activeSelection, handleSelection, isOpen, options]);
return (
<>
{dropdownList}
>
);
}
export default Dropdown;
diff --git a/web/components/menu.react.js b/web/components/menu.react.js
index 90ff25b8f..dfc29fc1e 100644
--- a/web/components/menu.react.js
+++ b/web/components/menu.react.js
@@ -1,128 +1,128 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import css from './menu.css';
import { useRenderMenu } from '../menu-provider.react.js';
type MenuVariant =
| 'thread-actions'
| 'member-actions'
| 'community-actions'
| 'role-actions'
| 'user-profile';
type MenuProps = {
+icon: React.Node,
+children?: React.Node,
+variant?: MenuVariant,
+onChange?: boolean => void,
};
function Menu(props: MenuProps): React.Node {
const buttonRef = React.useRef();
const {
renderMenu,
setMenuPosition,
closeMenu,
setCurrentOpenMenu,
currentOpenMenu,
} = useRenderMenu();
const { icon, children, variant = 'thread-actions', onChange } = props;
const ourSymbol = React.useRef(Symbol());
const menuActionListClasses = classnames(css.menuActionList, {
[css.menuActionListThreadActions]: variant === 'thread-actions',
[css.menuActionListMemberActions]: variant === 'member-actions',
[css.menuActionListCommunityActions]: variant === 'community-actions',
[css.menuActionListRoleActions]: variant === 'role-actions',
[css.userProfileActions]: variant === 'user-profile',
});
const menuActionList = React.useMemo(
() => {children}
,
[children, menuActionListClasses],
);
const isOurMenuOpen = currentOpenMenu === ourSymbol.current;
const updatePosition = React.useCallback(() => {
if (buttonRef.current && isOurMenuOpen) {
const { top, left } = buttonRef.current.getBoundingClientRect();
setMenuPosition({ top, left });
}
}, [isOurMenuOpen, setMenuPosition]);
React.useEffect(() => {
if (!window) {
return undefined;
}
window.addEventListener('resize', updatePosition);
return () => window.removeEventListener('resize', updatePosition);
}, [updatePosition]);
React.useEffect(updatePosition, [updatePosition]);
const closeMenuCallback = React.useCallback(() => {
closeMenu(ourSymbol.current);
}, [closeMenu]);
React.useEffect(() => {
onChange?.(isOurMenuOpen);
}, [isOurMenuOpen, onChange]);
React.useEffect(() => {
if (!isOurMenuOpen) {
return undefined;
}
document.addEventListener('click', closeMenuCallback);
return () => {
document.removeEventListener('click', closeMenuCallback);
};
}, [closeMenuCallback, isOurMenuOpen]);
const prevActionListRef = React.useRef(null);
React.useEffect(() => {
if (!isOurMenuOpen) {
prevActionListRef.current = null;
return;
}
if (prevActionListRef.current === menuActionList) {
return;
}
renderMenu(menuActionList);
prevActionListRef.current = menuActionList;
}, [isOurMenuOpen, menuActionList, renderMenu]);
React.useEffect(() => {
const ourSymbolValue = ourSymbol.current;
return () => closeMenu(ourSymbolValue);
}, [closeMenu]);
const onClickMenuCallback = React.useCallback(
- e => {
+ (e: SyntheticEvent) => {
e.stopPropagation();
setCurrentOpenMenu(ourSymbol.current);
},
[setCurrentOpenMenu],
);
if (React.Children.count(children) === 0) {
return null;
}
return (
{icon}
);
}
export default Menu;
diff --git a/web/components/navigation-arrows.react.js b/web/components/navigation-arrows.react.js
index 4049a2b22..14205e6c4 100644
--- a/web/components/navigation-arrows.react.js
+++ b/web/components/navigation-arrows.react.js
@@ -1,61 +1,62 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import css from './navigation-arrows.css';
import electron from '../electron.js';
import history from '../router-history.js';
-const stopDoubleClickPropagation = e => e.stopPropagation();
+const stopDoubleClickPropagation = (e: SyntheticEvent) =>
+ e.stopPropagation();
function NavigationArrows(): React.Node {
const goBack = React.useCallback(
() => history.getHistoryObject().goBack(),
[],
);
const goForward = React.useCallback(
() => history.getHistoryObject().goForward(),
[],
);
const [disableBack, setDisableBack] = React.useState(false);
const [disableFoward, setDisableForward] = React.useState(false);
React.useEffect(
() =>
electron?.onNavigate(({ canGoBack, canGoForward }) => {
setDisableBack(!canGoBack);
setDisableForward(!canGoForward);
}),
[],
);
const goBackClasses = classnames(css.button, { [css.disabled]: disableBack });
const goForwardClasses = classnames(css.button, {
[css.disabled]: disableFoward,
});
return (
);
}
export default NavigationArrows;
diff --git a/web/components/search.react.js b/web/components/search.react.js
index 27107ada1..e4c287d9f 100644
--- a/web/components/search.react.js
+++ b/web/components/search.react.js
@@ -1,62 +1,65 @@
// @flow
+import invariant from 'invariant';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import type { ReactRefSetter } from 'lib/types/react-types.js';
import ClearSearchButton from './clear-search-button.react.js';
import css from './search.css';
type Props = {
...React.ElementConfig<'input'>,
+searchText: string,
+onChangeText: (searchText: string) => mixed,
+placeholder?: string,
+onClearText?: () => mixed,
};
function Search(
props: Props,
ref: ReactRefSetter,
): React.Node {
const { searchText, onChangeText, placeholder, onClearText, ...rest } = props;
const showClearButton = !!searchText;
const onClear = React.useCallback(() => {
onChangeText('');
onClearText?.();
}, [onChangeText, onClearText]);
const onChange = React.useCallback(
- event => {
- onChangeText(event.target.value);
+ (event: SyntheticEvent) => {
+ const { target } = event;
+ invariant(target instanceof HTMLInputElement, 'target not input');
+ onChangeText(target.value);
},
[onChangeText],
);
return (
);
}
const ForwardedSearch: React.AbstractComponent =
React.forwardRef(Search);
export default ForwardedSearch;
diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
index 8e9bba1d3..9adec603f 100644
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -1,1676 +1,1697 @@
// @flow
import invariant from 'invariant';
import _groupBy from 'lodash/fp/groupBy.js';
import _keyBy from 'lodash/fp/keyBy.js';
import _omit from 'lodash/fp/omit.js';
import _partition from 'lodash/fp/partition.js';
import _sortBy from 'lodash/fp/sortBy.js';
import _memoize from 'lodash/memoize.js';
import * as React from 'react';
import { createSelector } from 'reselect';
import {
createLocalMessageActionType,
sendMultimediaMessageActionTypes,
useLegacySendMultimediaMessage,
sendTextMessageActionTypes,
useSendTextMessage,
} from 'lib/actions/message-actions.js';
import type {
LegacySendMultimediaMessageInput,
SendTextMessageInput,
} from 'lib/actions/message-actions.js';
import { queueReportsActionType } from 'lib/actions/report-actions.js';
import { useNewThread } from 'lib/actions/thread-actions.js';
import {
uploadMultimedia,
updateMultimediaMessageMediaActionType,
useDeleteUpload,
useBlobServiceUpload,
type MultimediaUploadCallbacks,
type MultimediaUploadExtras,
type BlobServiceUploadAction,
type DeleteUploadInput,
} from 'lib/actions/upload-actions.js';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import blobService from 'lib/facts/blob-service.js';
import commStaffCommunity from 'lib/facts/comm-staff-community.js';
import { getNextLocalUploadID } from 'lib/media/media-utils.js';
import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js';
import {
createMediaMessageInfo,
localIDPrefix,
useMessageCreationSideEffectsFunc,
} from 'lib/shared/message-utils.js';
import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js';
import {
createRealThreadFromPendingThread,
draftKeyFromThreadID,
threadIsPending,
threadIsPendingSidebar,
patchThreadInfoToIncludeMentionedMembersOfParent,
threadInfoInsideCommunity,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type {
UploadMultimediaResult,
MediaMissionStep,
MediaMissionFailure,
MediaMissionResult,
MediaMission,
} from 'lib/types/media-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import {
type RawMessageInfo,
type RawMultimediaMessageInfo,
type SendMessageResult,
type SendMessagePayload,
} from 'lib/types/message-types.js';
import type { RawImagesMessageInfo } from 'lib/types/messages/images.js';
import type { RawMediaMessageInfo } from 'lib/types/messages/media.js';
import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { reportTypes } from 'lib/types/report-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import {
type ClientNewThreadRequest,
type NewThreadResult,
type ThreadInfo,
} from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import {
makeBlobServiceEndpointURL,
isBlobServiceURI,
blobHashFromBlobServiceURI,
} from 'lib/utils/blob-service.js';
import { getConfig } from 'lib/utils/config.js';
import { getMessageForException, cloneError } from 'lib/utils/errors.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { generateReportID } from 'lib/utils/report-utils.js';
import {
type PendingMultimediaUpload,
type TypeaheadState,
InputStateContext,
+ type BaseInputState,
+ type TypeaheadInputState,
+ type InputState,
} from './input-state.js';
import { encryptFile } from '../media/encryption-utils.js';
import { generateThumbHash } from '../media/image-utils.js';
import {
preloadMediaResource,
validateFile,
preloadImage,
} from '../media/media-utils.js';
import InvalidUploadModal from '../modals/chat/invalid-upload.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
type BaseProps = {
+children: React.Node,
};
type Props = {
...BaseProps,
+activeChatThreadID: ?string,
+drafts: { +[key: string]: string },
+viewerID: ?string,
+messageStoreMessages: { +[id: string]: RawMessageInfo },
+pendingRealizedThreadIDs: $ReadOnlyMap,
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
+calendarQuery: () => CalendarQuery,
+uploadMultimedia: (
multimedia: Object,
extras: MultimediaUploadExtras,
callbacks: MultimediaUploadCallbacks,
) => Promise,
+blobServiceUpload: BlobServiceUploadAction,
+deleteUpload: (input: DeleteUploadInput) => Promise,
+sendMultimediaMessage: (
input: LegacySendMultimediaMessageInput,
) => Promise,
+sendTextMessage: (input: SendTextMessageInput) => Promise,
+newThread: (request: ClientNewThreadRequest) => Promise,
+pushModal: PushModal,
+sendCallbacks: $ReadOnlyArray<() => mixed>,
+registerSendCallback: (() => mixed) => void,
+unregisterSendCallback: (() => mixed) => void,
+textMessageCreationSideEffectsFunc: CreationSideEffectsFunc,
};
type State = {
+pendingUploads: {
[threadID: string]: { [localUploadID: string]: PendingMultimediaUpload },
},
+textCursorPositions: { [threadID: string]: number },
+typeaheadState: TypeaheadState,
};
type PropsAndState = {
...Props,
...State,
};
class InputStateContainer extends React.PureComponent {
state: State = {
pendingUploads: {},
textCursorPositions: {},
typeaheadState: {
canBeVisible: false,
keepUpdatingThreadMembers: true,
frozenUserMentionsCandidates: [],
frozenChatMentionsCandidates: {},
moveChoiceUp: null,
moveChoiceDown: null,
close: null,
accept: null,
},
};
replyCallbacks: Array<(message: string) => void> = [];
- pendingThreadCreations = new Map>();
+ pendingThreadCreations: Map> = new Map<
+ string,
+ Promise,
+ >();
// TODO: flip the switch
// Note that this enables Blob service for encrypted media only
useBlobServiceUploads = false;
// When the user sends a multimedia message that triggers the creation of a
// sidebar, the sidebar gets created right away, but the message needs to wait
// for the uploads to complete before sending. We use this Set to track the
// message localIDs that need sidebarCreation: true.
- pendingSidebarCreationMessageLocalIDs = new Set();
+ pendingSidebarCreationMessageLocalIDs: Set = new Set();
static reassignToRealizedThreads(
state: { +[threadID: string]: T },
props: Props,
): ?{ [threadID: string]: T } {
const newState = {};
let updated = false;
for (const threadID in state) {
const newThreadID =
props.pendingRealizedThreadIDs.get(threadID) ?? threadID;
if (newThreadID !== threadID) {
updated = true;
}
newState[newThreadID] = state[threadID];
}
return updated ? newState : null;
}
- static getDerivedStateFromProps(props: Props, state: State) {
+ static getDerivedStateFromProps(props: Props, state: State): ?Partial {
const pendingUploads = InputStateContainer.reassignToRealizedThreads(
state.pendingUploads,
props,
);
const textCursorPositions = InputStateContainer.reassignToRealizedThreads(
state.textCursorPositions,
props,
);
if (!pendingUploads && !textCursorPositions) {
return null;
}
const stateUpdate = {};
if (pendingUploads) {
stateUpdate.pendingUploads = pendingUploads;
}
if (textCursorPositions) {
stateUpdate.textCursorPositions = textCursorPositions;
}
return stateUpdate;
}
- static completedMessageIDs(state: State) {
+ static completedMessageIDs(state: State): Set {
const completed = new Map();
for (const threadID in state.pendingUploads) {
const pendingUploads = state.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const upload = pendingUploads[localUploadID];
const { messageID, serverID, failed } = upload;
if (!messageID || !messageID.startsWith(localIDPrefix)) {
continue;
}
if (!serverID || failed) {
completed.set(messageID, false);
continue;
}
if (completed.get(messageID) === undefined) {
completed.set(messageID, true);
}
}
}
const messageIDs = new Set();
for (const [messageID, isCompleted] of completed) {
if (isCompleted) {
messageIDs.add(messageID);
}
}
return messageIDs;
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.props.viewerID !== prevProps.viewerID) {
this.setState({ pendingUploads: {} });
return;
}
const previouslyAssignedMessageIDs = new Set();
for (const threadID in prevState.pendingUploads) {
const pendingUploads = prevState.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const { messageID } = pendingUploads[localUploadID];
if (messageID) {
previouslyAssignedMessageIDs.add(messageID);
}
}
}
const newlyAssignedUploads = new Map();
for (const threadID in this.state.pendingUploads) {
const pendingUploads = this.state.pendingUploads[threadID];
for (const localUploadID in pendingUploads) {
const upload = pendingUploads[localUploadID];
const { messageID } = upload;
if (
!messageID ||
!messageID.startsWith(localIDPrefix) ||
previouslyAssignedMessageIDs.has(messageID)
) {
continue;
}
const { shouldEncrypt } = upload;
let assignedUploads = newlyAssignedUploads.get(messageID);
if (!assignedUploads) {
assignedUploads = { threadID, shouldEncrypt, uploads: [] };
newlyAssignedUploads.set(messageID, assignedUploads);
}
if (shouldEncrypt !== assignedUploads.shouldEncrypt) {
console.warn(
`skipping upload ${localUploadID} ` +
"because shouldEncrypt doesn't match",
);
continue;
}
assignedUploads.uploads.push(upload);
}
}
const newMessageInfos = new Map();
for (const [messageID, assignedUploads] of newlyAssignedUploads) {
const { uploads, threadID, shouldEncrypt } = assignedUploads;
const creatorID = this.props.viewerID;
invariant(creatorID, 'need viewer ID in order to send a message');
const media = uploads.map(
({
localID,
serverID,
uri,
mediaType,
dimensions,
encryptionKey,
thumbHash,
}) => {
// We can get into this state where dimensions are null if the user is
// uploading a file type that the browser can't render. In that case
// we fake the dimensions here while we wait for the server to tell us
// the true dimensions.
const shimmedDimensions = dimensions ?? { height: 0, width: 0 };
invariant(
mediaType === 'photo' || mediaType === 'encrypted_photo',
"web InputStateContainer can't handle video",
);
if (
mediaType !== 'encrypted_photo' &&
mediaType !== 'encrypted_video'
) {
return {
id: serverID ? serverID : localID,
uri,
type: 'photo',
dimensions: shimmedDimensions,
thumbHash,
};
}
invariant(
encryptionKey,
'encrypted media must have an encryption key',
);
return {
id: serverID ? serverID : localID,
blobURI: uri,
type: 'encrypted_photo',
encryptionKey,
dimensions: shimmedDimensions,
thumbHash,
};
},
);
const messageInfo = createMediaMessageInfo(
{
localID: messageID,
threadID,
creatorID,
media,
},
{ forceMultimediaMessageType: shouldEncrypt },
);
newMessageInfos.set(messageID, messageInfo);
}
const currentlyCompleted = InputStateContainer.completedMessageIDs(
this.state,
);
const previouslyCompleted =
InputStateContainer.completedMessageIDs(prevState);
for (const messageID of currentlyCompleted) {
if (previouslyCompleted.has(messageID)) {
continue;
}
let rawMessageInfo = newMessageInfos.get(messageID);
if (rawMessageInfo) {
newMessageInfos.delete(messageID);
} else {
rawMessageInfo = this.getRawMultimediaMessageInfo(messageID);
}
this.sendMultimediaMessage(rawMessageInfo);
}
for (const [, messageInfo] of newMessageInfos) {
this.props.dispatch({
type: createLocalMessageActionType,
payload: messageInfo,
});
}
}
getRawMultimediaMessageInfo(
localMessageID: string,
): RawMultimediaMessageInfo {
const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`);
invariant(
rawMessageInfo.type === messageTypes.IMAGES ||
rawMessageInfo.type === messageTypes.MULTIMEDIA,
`rawMessageInfo ${localMessageID} should be multimedia`,
);
return rawMessageInfo;
}
shouldEncryptMedia(threadInfo: ThreadInfo): boolean {
return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id);
}
- async sendMultimediaMessage(messageInfo: RawMultimediaMessageInfo) {
+ async sendMultimediaMessage(
+ messageInfo: RawMultimediaMessageInfo,
+ ): Promise {
if (!threadIsPending(messageInfo.threadID)) {
this.props.dispatchActionPromise(
sendMultimediaMessageActionTypes,
this.sendMultimediaMessageAction(messageInfo),
undefined,
messageInfo,
);
return;
}
this.props.dispatch({
type: sendMultimediaMessageActionTypes.started,
payload: messageInfo,
});
let newThreadID = null;
try {
const threadCreationPromise = this.pendingThreadCreations.get(
messageInfo.threadID,
);
if (!threadCreationPromise) {
// When we create or retry multimedia message, we add a promise to
// pendingThreadCreations map. This promise can be removed in
// sendMultimediaMessage and sendTextMessage methods. When any of these
// method remove the promise, it has to be settled. If the promise was
// fulfilled, this method would be called with realized thread, so we
// can conclude that the promise was rejected. We don't have enough info
// here to retry the thread creation, but we can mark the message as
// failed. Then the retry will be possible and promise will be created
// again.
throw new Error('Thread creation failed');
}
newThreadID = await threadCreationPromise;
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
this.props.dispatch({
type: sendMultimediaMessageActionTypes.failed,
payload: copy,
error: true,
});
return;
} finally {
this.pendingThreadCreations.delete(messageInfo.threadID);
}
// While the thread was being created, the image preload may have completed,
// and we might have a finalized URI now. So we fetch from Redux again
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should exist for locally-created RawMessageInfo',
);
const latestMessageInfo = this.getRawMultimediaMessageInfo(localID);
// Conditional is necessary for Flow
let newMessageInfo;
if (latestMessageInfo.type === messageTypes.MULTIMEDIA) {
newMessageInfo = {
...latestMessageInfo,
threadID: newThreadID,
time: Date.now(),
};
} else {
newMessageInfo = {
...latestMessageInfo,
threadID: newThreadID,
time: Date.now(),
};
}
this.props.dispatchActionPromise(
sendMultimediaMessageActionTypes,
this.sendMultimediaMessageAction(newMessageInfo),
undefined,
newMessageInfo,
);
}
async sendMultimediaMessageAction(
messageInfo: RawMultimediaMessageInfo,
): Promise {
const { localID, threadID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const sidebarCreation =
this.pendingSidebarCreationMessageLocalIDs.has(localID);
const mediaIDs = [];
for (const { id } of messageInfo.media) {
mediaIDs.push(id);
}
try {
const result = await this.props.sendMultimediaMessage({
threadID,
localID,
mediaIDs,
sidebarCreation,
});
this.pendingSidebarCreationMessageLocalIDs.delete(localID);
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const prevUploads = prevState.pendingUploads[newThreadID];
const newUploads = {};
for (const localUploadID in prevUploads) {
const upload = prevUploads[localUploadID];
if (upload.messageID !== localID) {
newUploads[localUploadID] = upload;
} else if (!upload.uriIsReal) {
newUploads[localUploadID] = {
...upload,
messageID: result.id,
};
}
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newUploads,
},
};
});
return {
localID,
serverID: result.id,
threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = localID;
copy.threadID = threadID;
throw copy;
}
}
startThreadCreation(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): Promise {
if (!threadIsPending(threadInfo.id)) {
return Promise.resolve(threadInfo.id);
}
let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id);
if (!threadCreationPromise) {
const calendarQuery = this.props.calendarQuery();
threadCreationPromise = createRealThreadFromPendingThread({
threadInfo,
dispatchActionPromise: this.props.dispatchActionPromise,
createNewThread: this.props.newThread,
sourceMessageID: threadInfo.sourceMessageID,
viewerID: this.props.viewerID,
calendarQuery,
});
this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise);
}
return threadCreationPromise;
}
- inputBaseStateSelector = _memoize((threadID: ?string) =>
- createSelector(
- (propsAndState: PropsAndState) =>
- threadID ? propsAndState.pendingUploads[threadID] : null,
- (propsAndState: PropsAndState) =>
- threadID ? propsAndState.drafts[draftKeyFromThreadID(threadID)] : null,
- (propsAndState: PropsAndState) =>
- threadID ? propsAndState.textCursorPositions[threadID] : null,
- (
- pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload },
- draft: ?string,
- textCursorPosition: ?number,
- ) => {
- let threadPendingUploads = [];
- const assignedUploads = {};
- if (pendingUploads) {
- const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] =
- _partition('messageID')(pendingUploads);
- threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs);
- const threadAssignedUploads = _groupBy('messageID')(
- uploadsWithMessageIDs,
- );
- for (const messageID in threadAssignedUploads) {
- // lodash libdefs don't return $ReadOnlyArray
- assignedUploads[messageID] = [...threadAssignedUploads[messageID]];
+ inputBaseStateSelector: (?string) => PropsAndState => BaseInputState =
+ _memoize((threadID: ?string) =>
+ createSelector(
+ (propsAndState: PropsAndState) =>
+ threadID ? propsAndState.pendingUploads[threadID] : null,
+ (propsAndState: PropsAndState) =>
+ threadID
+ ? propsAndState.drafts[draftKeyFromThreadID(threadID)]
+ : null,
+ (propsAndState: PropsAndState) =>
+ threadID ? propsAndState.textCursorPositions[threadID] : null,
+ (
+ pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload },
+ draft: ?string,
+ textCursorPosition: ?number,
+ ) => {
+ let threadPendingUploads = [];
+ const assignedUploads = {};
+ if (pendingUploads) {
+ const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] =
+ _partition('messageID')(pendingUploads);
+ threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs);
+ const threadAssignedUploads = _groupBy('messageID')(
+ uploadsWithMessageIDs,
+ );
+ for (const messageID in threadAssignedUploads) {
+ // lodash libdefs don't return $ReadOnlyArray
+ assignedUploads[messageID] = [
+ ...threadAssignedUploads[messageID],
+ ];
+ }
}
- }
- return {
- pendingUploads: threadPendingUploads,
- assignedUploads,
- draft: draft ?? '',
- textCursorPosition: textCursorPosition ?? 0,
- appendFiles: (threadInfo: ThreadInfo, files: $ReadOnlyArray) =>
- this.appendFiles(threadInfo, files),
- cancelPendingUpload: (localUploadID: string) =>
- this.cancelPendingUpload(threadID, localUploadID),
- sendTextMessage: (
- messageInfo: RawTextMessageInfo,
- threadInfo: ThreadInfo,
- parentThreadInfo: ?ThreadInfo,
- ) => this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo),
- createMultimediaMessage: (localID: number, threadInfo: ThreadInfo) =>
- this.createMultimediaMessage(localID, threadInfo),
- setDraft: (newDraft: string) => this.setDraft(threadID, newDraft),
- setTextCursorPosition: (newPosition: number) =>
- this.setTextCursorPosition(threadID, newPosition),
- messageHasUploadFailure: (localMessageID: string) =>
- this.messageHasUploadFailure(assignedUploads[localMessageID]),
- retryMultimediaMessage: (
- localMessageID: string,
- threadInfo: ThreadInfo,
- ) =>
- this.retryMultimediaMessage(
- localMessageID,
- threadInfo,
- assignedUploads[localMessageID],
- ),
- addReply: (message: string) => this.addReply(message),
- addReplyListener: this.addReplyListener,
- removeReplyListener: this.removeReplyListener,
- registerSendCallback: this.props.registerSendCallback,
- unregisterSendCallback: this.props.unregisterSendCallback,
- };
- },
- ),
- );
+ return {
+ pendingUploads: threadPendingUploads,
+ assignedUploads,
+ draft: draft ?? '',
+ textCursorPosition: textCursorPosition ?? 0,
+ appendFiles: (
+ threadInfo: ThreadInfo,
+ files: $ReadOnlyArray,
+ ) => this.appendFiles(threadInfo, files),
+ cancelPendingUpload: (localUploadID: string) =>
+ this.cancelPendingUpload(threadID, localUploadID),
+ sendTextMessage: (
+ messageInfo: RawTextMessageInfo,
+ threadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
+ ) =>
+ this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo),
+ createMultimediaMessage: (
+ localID: number,
+ threadInfo: ThreadInfo,
+ ) => this.createMultimediaMessage(localID, threadInfo),
+ setDraft: (newDraft: string) => this.setDraft(threadID, newDraft),
+ setTextCursorPosition: (newPosition: number) =>
+ this.setTextCursorPosition(threadID, newPosition),
+ messageHasUploadFailure: (localMessageID: string) =>
+ this.messageHasUploadFailure(assignedUploads[localMessageID]),
+ retryMultimediaMessage: (
+ localMessageID: string,
+ threadInfo: ThreadInfo,
+ ) =>
+ this.retryMultimediaMessage(
+ localMessageID,
+ threadInfo,
+ assignedUploads[localMessageID],
+ ),
+ addReply: (message: string) => this.addReply(message),
+ addReplyListener: this.addReplyListener,
+ removeReplyListener: this.removeReplyListener,
+ registerSendCallback: this.props.registerSendCallback,
+ unregisterSendCallback: this.props.unregisterSendCallback,
+ };
+ },
+ ),
+ );
- typeaheadStateSelector = createSelector(
+ typeaheadStateSelector: PropsAndState => TypeaheadInputState = createSelector(
(propsAndState: PropsAndState) => propsAndState.typeaheadState,
(typeaheadState: TypeaheadState) => ({
typeaheadState,
setTypeaheadState: this.setTypeaheadState,
}),
);
- inputStateSelector = createSelector(
+ inputStateSelector: ({
+ +inputBaseState: BaseInputState,
+ +typeaheadState: TypeaheadInputState,
+ }) => InputState = createSelector(
state => state.inputBaseState,
state => state.typeaheadState,
(inputBaseState, typeaheadState) => ({
...inputBaseState,
...typeaheadState,
}),
);
getRealizedOrPendingThreadID(threadID: string): string {
return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID;
}
async appendFiles(
threadInfo: ThreadInfo,
files: $ReadOnlyArray,
): Promise {
const selectionTime = Date.now();
const { pushModal } = this.props;
const appendResults = await Promise.all(
files.map(file => this.appendFile(threadInfo, file, selectionTime)),
);
if (appendResults.some(({ result }) => !result.success)) {
pushModal( );
const time = Date.now() - selectionTime;
const reports = [];
for (const appendResult of appendResults) {
const { steps } = appendResult;
let { result } = appendResult;
let uploadLocalID;
if (result.success) {
uploadLocalID = result.pendingUpload.localID;
result = { success: false, reason: 'web_sibling_validation_failed' };
}
const mediaMission = { steps, result, userTime: time, totalTime: time };
reports.push({ mediaMission, uploadLocalID });
}
this.queueMediaMissionReports(reports);
return false;
}
const newUploads = appendResults.map(({ result }) => {
invariant(result.success, 'any failed validation should be caught above');
return result.pendingUpload;
});
const newUploadsObject = _keyBy('localID')(newUploads);
this.setState(
prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const prevUploads = prevState.pendingUploads[newThreadID];
const mergedUploads = prevUploads
? { ...prevUploads, ...newUploadsObject }
: newUploadsObject;
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: mergedUploads,
},
};
},
() => this.uploadFiles(threadInfo.id, newUploads),
);
return true;
}
async appendFile(
threadInfo: ThreadInfo,
file: File,
selectTime: number,
): Promise<{
steps: $ReadOnlyArray,
result:
| MediaMissionFailure
| { success: true, pendingUpload: PendingMultimediaUpload },
}> {
const steps = [
{
step: 'web_selection',
filename: file.name,
size: file.size,
mime: file.type,
selectTime,
},
];
let response;
const validationStart = Date.now();
try {
response = await validateFile(file);
} catch (e) {
return {
steps,
result: {
success: false,
reason: 'processing_exception',
time: Date.now() - validationStart,
exceptionMessage: getMessageForException(e),
},
};
}
const { steps: validationSteps, result } = response;
steps.push(...validationSteps);
if (!result.success) {
return { steps, result };
}
const { uri, file: fixedFile, mediaType, dimensions } = result;
const shouldEncrypt = this.shouldEncryptMedia(threadInfo);
let encryptionResult;
if (shouldEncrypt) {
let encryptionResponse;
const encryptionStart = Date.now();
try {
encryptionResponse = await encryptFile(fixedFile);
} catch (e) {
return {
steps,
result: {
success: false,
reason: 'encryption_exception',
time: Date.now() - encryptionStart,
exceptionMessage: getMessageForException(e),
},
};
}
steps.push(...encryptionResponse.steps);
encryptionResult = encryptionResponse.result;
}
if (encryptionResult && !encryptionResult.success) {
return { steps, result: encryptionResult };
}
const { steps: thumbHashSteps, result: thumbHashResult } =
await generateThumbHash(fixedFile, encryptionResult?.encryptionKey);
const thumbHash = thumbHashResult.success
? thumbHashResult.thumbHash
: null;
steps.push(...thumbHashSteps);
return {
steps,
result: {
success: true,
pendingUpload: {
localID: getNextLocalUploadID(),
serverID: null,
messageID: null,
failed: false,
file: encryptionResult?.file ?? fixedFile,
mediaType: encryptionResult ? 'encrypted_photo' : mediaType,
dimensions,
uri: encryptionResult?.uri ?? uri,
loop: false,
uriIsReal: false,
blobHolder: null,
blobHash: encryptionResult?.sha256Hash,
encryptionKey: encryptionResult?.encryptionKey,
thumbHash,
progressPercent: 0,
abort: null,
steps,
selectTime,
shouldEncrypt,
},
},
};
}
uploadFiles(
threadID: string,
uploads: $ReadOnlyArray,
- ) {
+ ): Promise {
return Promise.all(
uploads.map(upload => this.uploadFile(threadID, upload)),
);
}
async uploadFile(threadID: string, upload: PendingMultimediaUpload) {
const { selectTime, localID, encryptionKey } = upload;
const isEncrypted =
!!encryptionKey &&
(upload.mediaType === 'encrypted_photo' ||
upload.mediaType === 'encrypted_video');
const steps = [...upload.steps];
let userTime;
const sendReport = (missionResult: MediaMissionResult) => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const latestUpload = this.state.pendingUploads[newThreadID][localID];
invariant(
latestUpload,
`pendingUpload ${localID} for ${newThreadID} missing in sendReport`,
);
const { serverID, messageID } = latestUpload;
const totalTime = Date.now() - selectTime;
userTime = userTime ? userTime : totalTime;
const mission = { steps, result: missionResult, totalTime, userTime };
this.queueMediaMissionReports([
{
mediaMission: mission,
uploadLocalID: localID,
uploadServerID: serverID,
messageLocalID: messageID,
},
]);
};
let uploadResult, uploadExceptionMessage;
const uploadStart = Date.now();
try {
const callbacks = {
onProgress: (percent: number) =>
this.setProgress(threadID, localID, percent),
abortHandler: (abort: () => void) =>
this.handleAbortCallback(threadID, localID, abort),
};
if (
this.useBlobServiceUploads &&
(upload.mediaType === 'encrypted_photo' ||
upload.mediaType === 'encrypted_video')
) {
const { blobHash, dimensions, thumbHash } = upload;
invariant(
encryptionKey && blobHash && dimensions,
'incomplete encrypted upload',
);
uploadResult = await this.props.blobServiceUpload({
uploadInput: {
blobInput: {
type: 'file',
file: upload.file,
},
blobHash,
encryptionKey,
dimensions,
loop: false,
thumbHash,
},
keyserverOrThreadID: threadID,
callbacks,
});
} else {
let uploadExtras = {
...upload.dimensions,
loop: false,
thumbHash: upload.thumbHash,
};
if (encryptionKey) {
uploadExtras = { ...uploadExtras, encryptionKey };
}
uploadResult = await this.props.uploadMultimedia(
upload.file,
uploadExtras,
callbacks,
);
}
} catch (e) {
uploadExceptionMessage = getMessageForException(e);
this.handleUploadFailure(threadID, localID);
}
userTime = Date.now() - selectTime;
steps.push({
step: 'upload',
success: !!uploadResult,
exceptionMessage: uploadExceptionMessage,
time: Date.now() - uploadStart,
inputFilename: upload.file.name,
outputMediaType: uploadResult && uploadResult.mediaType,
outputURI: uploadResult && uploadResult.uri,
outputDimensions: uploadResult && uploadResult.dimensions,
outputLoop: uploadResult && uploadResult.loop,
});
if (!uploadResult) {
sendReport({
success: false,
reason: 'http_upload_failed',
exceptionMessage: uploadExceptionMessage,
});
return;
}
const result = uploadResult;
const outputMediaType = isEncrypted ? 'encrypted_photo' : result.mediaType;
const successThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploadAfterSuccess =
this.state.pendingUploads[successThreadID][localID];
invariant(
uploadAfterSuccess,
`pendingUpload ${localID}/${result.id} for ${successThreadID} missing ` +
`after upload`,
);
if (uploadAfterSuccess.messageID) {
this.props.dispatch({
type: updateMultimediaMessageMediaActionType,
payload: {
messageID: uploadAfterSuccess.messageID,
currentMediaID: localID,
mediaUpdate: {
id: result.id,
},
},
});
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const currentUpload = uploads[localID];
invariant(
currentUpload,
`pendingUpload ${localID}/${result.id} for ${newThreadID} ` +
`missing while assigning serverID`,
);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localID]: {
...currentUpload,
serverID: result.id,
blobHolder: result.blobHolder,
abort: null,
},
},
},
};
});
if (encryptionKey) {
const { steps: preloadSteps } = await preloadMediaResource(result.uri);
steps.push(...preloadSteps);
} else {
const { steps: preloadSteps } = await preloadImage(result.uri);
steps.push(...preloadSteps);
}
sendReport({ success: true });
const preloadThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploadAfterPreload =
this.state.pendingUploads[preloadThreadID][localID];
invariant(
uploadAfterPreload,
`pendingUpload ${localID}/${result.id} for ${preloadThreadID} missing ` +
`after preload`,
);
if (uploadAfterPreload.messageID) {
const { mediaType, uri, dimensions, loop } = result;
const { thumbHash } = upload;
let mediaUpdate = {
loop,
dimensions,
...(thumbHash ? { thumbHash } : undefined),
};
if (!isEncrypted) {
mediaUpdate = {
...mediaUpdate,
type: mediaType,
uri,
};
} else {
mediaUpdate = {
...mediaUpdate,
type: outputMediaType,
blobURI: uri,
encryptionKey,
};
}
this.props.dispatch({
type: updateMultimediaMessageMediaActionType,
payload: {
messageID: uploadAfterPreload.messageID,
currentMediaID: result.id ?? uploadAfterPreload.localID,
mediaUpdate,
},
});
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const currentUpload = uploads[localID];
invariant(
currentUpload,
`pendingUpload ${localID}/${result.id} for ${newThreadID} ` +
`missing while assigning URI`,
);
const { messageID } = currentUpload;
if (messageID && !messageID.startsWith(localIDPrefix)) {
const newPendingUploads = _omit([localID])(uploads);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localID]: {
...currentUpload,
uri: result.uri,
mediaType: outputMediaType,
dimensions: result.dimensions,
uriIsReal: true,
loop: result.loop,
},
},
},
};
});
}
handleAbortCallback(
threadID: string,
localUploadID: string,
abort: () => void,
) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const upload = uploads[localUploadID];
if (!upload) {
// The upload has been cancelled before we were even handed the
// abort function. We should immediately abort.
abort();
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localUploadID]: {
...upload,
abort,
},
},
},
};
});
}
handleUploadFailure(threadID: string, localUploadID: string) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const uploads = prevState.pendingUploads[newThreadID];
const upload = uploads[localUploadID];
if (!upload || !upload.abort || upload.serverID) {
// The upload has been cancelled or completed before it failed
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: {
...uploads,
[localUploadID]: {
...upload,
failed: true,
progressPercent: 0,
abort: null,
},
},
},
};
});
}
queueMediaMissionReports(
partials: $ReadOnlyArray<{
mediaMission: MediaMission,
uploadLocalID?: ?string,
uploadServerID?: ?string,
messageLocalID?: ?string,
}>,
) {
const reports = partials.map(
({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({
type: reportTypes.MEDIA_MISSION,
time: Date.now(),
platformDetails: getConfig().platformDetails,
mediaMission,
uploadServerID,
uploadLocalID,
messageLocalID,
id: generateReportID(),
}),
);
this.props.dispatch({ type: queueReportsActionType, payload: { reports } });
}
cancelPendingUpload(threadID: ?string, localUploadID: string) {
invariant(threadID, 'threadID should be set in cancelPendingUpload');
let revokeURL, abortRequest;
this.setState(
prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const currentPendingUploads = prevState.pendingUploads[newThreadID];
if (!currentPendingUploads) {
return {};
}
const pendingUpload = currentPendingUploads[localUploadID];
if (!pendingUpload) {
return {};
}
if (!pendingUpload.uriIsReal) {
revokeURL = pendingUpload.uri;
}
if (pendingUpload.abort) {
abortRequest = pendingUpload.abort;
}
if (pendingUpload.serverID) {
this.props.deleteUpload({
id: pendingUpload.serverID,
keyserverOrThreadID: threadID,
});
if (isBlobServiceURI(pendingUpload.uri)) {
invariant(
pendingUpload.blobHolder,
'blob service upload has no holder',
);
const endpoint = blobService.httpEndpoints.DELETE_BLOB;
const holder = pendingUpload.blobHolder;
const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri);
fetch(makeBlobServiceEndpointURL(endpoint), {
method: endpoint.method,
body: JSON.stringify({
holder,
blob_hash: blobHash,
}),
headers: {
'content-type': 'application/json',
},
});
}
}
const newPendingUploads = _omit([localUploadID])(currentPendingUploads);
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
},
() => {
if (revokeURL) {
URL.revokeObjectURL(revokeURL);
}
if (abortRequest) {
abortRequest();
}
},
);
}
async sendTextMessage(
messageInfo: RawTextMessageInfo,
inputThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
) {
this.props.sendCallbacks.forEach(callback => callback());
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
if (threadIsPendingSidebar(inputThreadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localID);
}
if (!threadIsPending(inputThreadInfo.id)) {
this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(
messageInfo,
inputThreadInfo,
parentThreadInfo,
),
undefined,
messageInfo,
);
return;
}
this.props.dispatch({
type: sendTextMessageActionTypes.started,
payload: messageInfo,
});
let threadInfo = inputThreadInfo;
const { viewerID } = this.props;
if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) {
invariant(parentThreadInfo, 'sidebar should have parent');
threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent(
inputThreadInfo,
parentThreadInfo,
messageInfo.text,
viewerID,
);
if (threadInfo !== inputThreadInfo) {
this.props.dispatch({
type: updateNavInfoActionType,
payload: { pendingThread: threadInfo },
});
}
}
let newThreadID = null;
try {
newThreadID = await this.startThreadCreation(threadInfo);
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
this.props.dispatch({
type: sendTextMessageActionTypes.failed,
payload: copy,
error: true,
});
return;
} finally {
this.pendingThreadCreations.delete(threadInfo.id);
}
const newMessageInfo = {
...messageInfo,
threadID: newThreadID,
time: Date.now(),
};
// Branching to appease `flow`.
const newThreadInfo = threadInfo.minimallyEncoded
? {
...threadInfo,
id: newThreadID,
}
: {
...threadInfo,
id: newThreadID,
};
this.props.dispatchActionPromise(
sendTextMessageActionTypes,
this.sendTextMessageAction(
newMessageInfo,
newThreadInfo,
parentThreadInfo,
),
undefined,
newMessageInfo,
);
}
async sendTextMessageAction(
messageInfo: RawTextMessageInfo,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
): Promise {
try {
await this.props.textMessageCreationSideEffectsFunc(
messageInfo,
threadInfo,
parentThreadInfo,
);
const { localID } = messageInfo;
invariant(
localID !== null && localID !== undefined,
'localID should be set',
);
const sidebarCreation =
this.pendingSidebarCreationMessageLocalIDs.has(localID);
const result = await this.props.sendTextMessage({
threadID: messageInfo.threadID,
localID,
text: messageInfo.text,
sidebarCreation,
});
this.pendingSidebarCreationMessageLocalIDs.delete(localID);
return {
localID,
serverID: result.id,
threadID: messageInfo.threadID,
time: result.time,
interface: result.interface,
};
} catch (e) {
const copy = cloneError(e);
copy.localID = messageInfo.localID;
copy.threadID = messageInfo.threadID;
throw copy;
}
}
// Creates a MultimediaMessage from the unassigned pending uploads,
// if there are any
createMultimediaMessage(localID: number, threadInfo: ThreadInfo) {
this.props.sendCallbacks.forEach(callback => callback());
const localMessageID = `${localIDPrefix}${localID}`;
this.startThreadCreation(threadInfo);
if (threadIsPendingSidebar(threadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const currentPendingUploads = prevState.pendingUploads[newThreadID];
if (!currentPendingUploads) {
return {};
}
const newPendingUploads = {};
let uploadAssigned = false;
for (const localUploadID in currentPendingUploads) {
const upload = currentPendingUploads[localUploadID];
if (upload.messageID) {
newPendingUploads[localUploadID] = upload;
} else {
const newUpload = {
...upload,
messageID: localMessageID,
};
uploadAssigned = true;
newPendingUploads[localUploadID] = newUpload;
}
}
if (!uploadAssigned) {
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
}
setDraft(threadID: ?string, draft: string) {
invariant(threadID, 'threadID should be set in setDraft');
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
this.props.dispatch({
type: 'UPDATE_DRAFT',
payload: {
key: draftKeyFromThreadID(newThreadID),
text: draft,
},
});
}
setTextCursorPosition(threadID: ?string, newPosition: number) {
invariant(threadID, 'threadID should be set in setTextCursorPosition');
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
return {
textCursorPositions: {
...prevState.textCursorPositions,
[newThreadID]: newPosition,
},
};
});
}
setTypeaheadState = (newState: $Shape) => {
this.setState(prevState => ({
typeaheadState: {
...prevState.typeaheadState,
...newState,
},
}));
};
setProgress(
threadID: string,
localUploadID: string,
progressPercent: number,
) {
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadID);
const pendingUploads = prevState.pendingUploads[newThreadID];
if (!pendingUploads) {
return {};
}
const pendingUpload = pendingUploads[localUploadID];
if (!pendingUpload) {
return {};
}
const newPendingUploads = {
...pendingUploads,
[localUploadID]: {
...pendingUpload,
progressPercent,
},
};
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
}
messageHasUploadFailure(
pendingUploads: ?$ReadOnlyArray,
- ) {
+ ): boolean {
if (!pendingUploads) {
return false;
}
return pendingUploads.some(upload => upload.failed);
}
retryMultimediaMessage(
localMessageID: string,
threadInfo: ThreadInfo,
pendingUploads: ?$ReadOnlyArray,
) {
this.props.sendCallbacks.forEach(callback => callback());
const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID);
let newRawMessageInfo;
// This conditional is for Flow
if (rawMessageInfo.type === messageTypes.MULTIMEDIA) {
newRawMessageInfo = ({
...rawMessageInfo,
time: Date.now(),
}: RawMediaMessageInfo);
} else {
newRawMessageInfo = ({
...rawMessageInfo,
time: Date.now(),
}: RawImagesMessageInfo);
}
this.startThreadCreation(threadInfo);
if (threadIsPendingSidebar(threadInfo.id)) {
this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
}
const completed = InputStateContainer.completedMessageIDs(this.state);
if (completed.has(localMessageID)) {
this.sendMultimediaMessage(newRawMessageInfo);
return;
}
if (!pendingUploads) {
return;
}
// We're not actually starting the send here,
// we just use this action to update the message's timestamp in Redux
this.props.dispatch({
type: sendMultimediaMessageActionTypes.started,
payload: newRawMessageInfo,
});
const uploadIDsToRetry = new Set();
const uploadsToRetry = [];
for (const pendingUpload of pendingUploads) {
const { serverID, messageID, localID, abort } = pendingUpload;
if (serverID || messageID !== localMessageID) {
continue;
}
if (abort) {
abort();
}
uploadIDsToRetry.add(localID);
uploadsToRetry.push(pendingUpload);
}
this.setState(prevState => {
const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
const prevPendingUploads = prevState.pendingUploads[newThreadID];
if (!prevPendingUploads) {
return {};
}
const newPendingUploads = {};
let pendingUploadChanged = false;
for (const localID in prevPendingUploads) {
const pendingUpload = prevPendingUploads[localID];
if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) {
newPendingUploads[localID] = {
...pendingUpload,
failed: false,
progressPercent: 0,
abort: null,
};
pendingUploadChanged = true;
} else {
newPendingUploads[localID] = pendingUpload;
}
}
if (!pendingUploadChanged) {
return {};
}
return {
pendingUploads: {
...prevState.pendingUploads,
[newThreadID]: newPendingUploads,
},
};
});
this.uploadFiles(threadInfo.id, uploadsToRetry);
}
addReply = (message: string) => {
this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message));
};
addReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks.push(callbackReply);
};
removeReplyListener = (callbackReply: (message: string) => void) => {
this.replyCallbacks = this.replyCallbacks.filter(
candidate => candidate !== callbackReply,
);
};
- render() {
+ render(): React.Node {
const { activeChatThreadID } = this.props;
// we're going with two selectors as we want to avoid
// recreation of chat state setter functions on typeahead state updates
const inputBaseState = this.inputBaseStateSelector(activeChatThreadID)({
...this.state,
...this.props,
});
const typeaheadState = this.typeaheadStateSelector({
...this.state,
...this.props,
});
const inputState = this.inputStateSelector({
inputBaseState,
typeaheadState,
});
return (
{this.props.children}
);
}
}
const ConnectedInputStateContainer: React.ComponentType =
React.memo(function ConnectedInputStateContainer(props) {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const drafts = useSelector(state => state.draftStore.drafts);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const messageStoreMessages = useSelector(
state => state.messageStore.messages,
);
const pendingToRealizedThreadIDs = useSelector(state =>
pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos),
);
const calendarQuery = useSelector(nonThreadCalendarQuery);
const callUploadMultimedia = useServerCall(uploadMultimedia);
const callBlobServiceUpload = useBlobServiceUpload();
const callDeleteUpload = useDeleteUpload();
const callSendMultimediaMessage = useLegacySendMultimediaMessage();
const callSendTextMessage = useSendTextMessage();
const callNewThread = useNewThread();
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const modalContext = useModalContext();
const [sendCallbacks, setSendCallbacks] = React.useState<
$ReadOnlyArray<() => mixed>,
>([]);
const registerSendCallback = React.useCallback((callback: () => mixed) => {
setSendCallbacks(prevCallbacks => [...prevCallbacks, callback]);
}, []);
const unregisterSendCallback = React.useCallback(
(callback: () => mixed) => {
setSendCallbacks(prevCallbacks =>
prevCallbacks.filter(candidate => candidate !== callback),
);
},
[],
);
const textMessageCreationSideEffectsFunc =
useMessageCreationSideEffectsFunc(messageTypes.TEXT);
return (
);
});
export default ConnectedInputStateContainer;
diff --git a/web/input/input-state.js b/web/input/input-state.js
index e851dd220..f19c00457 100644
--- a/web/input/input-state.js
+++ b/web/input/input-state.js
@@ -1,105 +1,113 @@
// @flow
import * as React from 'react';
import {
type MediaType,
type EncryptedMediaType,
type Dimensions,
type MediaMissionStep,
} from 'lib/types/media-types.js';
import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
import type {
MinimallyEncodedRelativeMemberInfo,
MinimallyEncodedThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
ThreadInfo,
RelativeMemberInfo,
ChatMentionCandidates,
} from 'lib/types/thread-types.js';
export type PendingMultimediaUpload = {
+localID: string,
// Pending uploads are assigned a serverID once they are complete
+serverID: ?string,
// Pending uploads are assigned a messageID once they are sent
+messageID: ?string,
// This is set to true if the upload fails for whatever reason
+failed: boolean,
+file: File,
+mediaType: MediaType | EncryptedMediaType,
+dimensions: ?Dimensions,
+uri: string,
+blobHolder: ?string,
+blobHash: ?string,
+encryptionKey: ?string,
+thumbHash: ?string,
+loop: boolean,
// URLs created with createObjectURL aren't considered "real". The distinction
// is required because those "fake" URLs must be disposed properly
+uriIsReal: boolean,
+progressPercent: number,
// This is set once the network request begins and used if the upload is
// cancelled
+abort: ?() => void,
+steps: MediaMissionStep[],
+selectTime: number,
+shouldEncrypt: boolean,
};
export type TypeaheadState = {
+canBeVisible: boolean,
+keepUpdatingThreadMembers: boolean,
+frozenUserMentionsCandidates: $ReadOnlyArray<
RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo,
>,
+frozenChatMentionsCandidates: ChatMentionCandidates,
+moveChoiceUp: ?() => void,
+moveChoiceDown: ?() => void,
+close: ?() => void,
+accept: ?() => void,
};
-// This type represents the input state for a particular thread
-export type InputState = {
+export type BaseInputState = {
+pendingUploads: $ReadOnlyArray,
+assignedUploads: {
[messageID: string]: $ReadOnlyArray,
},
+draft: string,
+textCursorPosition: number,
- +typeaheadState: TypeaheadState,
+appendFiles: (
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
files: $ReadOnlyArray,
) => Promise,
+cancelPendingUpload: (localUploadID: string) => void,
+sendTextMessage: (
messageInfo: RawTextMessageInfo,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
) => Promise,
+createMultimediaMessage: (
localID: number,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
) => void,
+setDraft: (draft: string) => void,
+setTextCursorPosition: (newPosition: number) => void,
- +setTypeaheadState: ($Shape) => void,
+messageHasUploadFailure: (localMessageID: string) => boolean,
+retryMultimediaMessage: (
localMessageID: string,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
) => void,
+addReply: (text: string) => void,
+addReplyListener: ((message: string) => void) => void,
+removeReplyListener: ((message: string) => void) => void,
+registerSendCallback: (() => mixed) => void,
+unregisterSendCallback: (() => mixed) => void,
};
+export type TypeaheadInputState = {
+ +typeaheadState: TypeaheadState,
+ +setTypeaheadState: ($Shape) => void,
+};
+
+// This type represents the input state for a particular thread
+export type InputState = {
+ ...BaseInputState,
+ ...TypeaheadInputState,
+};
+
const InputStateContext: React.Context =
React.createContext(null);
export { InputStateContext };
diff --git a/web/modals/history/history-entry.react.js b/web/modals/history/history-entry.react.js
index 89116bfd7..b42036e4b 100644
--- a/web/modals/history/history-entry.react.js
+++ b/web/modals/history/history-entry.react.js
@@ -1,188 +1,189 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import {
restoreEntryActionTypes,
useRestoreEntry,
} from 'lib/actions/entry-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { colorIsDark } from 'lib/shared/color-utils.js';
import {
type EntryInfo,
type RestoreEntryInfo,
type RestoreEntryResult,
type CalendarQuery,
+ type RestoreEntryPayload,
} from 'lib/types/entry-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { MinimallyEncodedResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ResolvedThreadInfo } from 'lib/types/thread-types.js';
import type { UserInfo } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './history.css';
import LoadingIndicator from '../../loading-indicator.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../../selectors/nav-selectors.js';
type BaseProps = {
+entryInfo: EntryInfo,
+onClick: (entryID: string) => void,
+animateAndLoadEntry: (entryID: string) => void,
};
type Props = {
...BaseProps,
+threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo,
+loggedIn: boolean,
+restoreLoadingStatus: LoadingStatus,
+calendarQuery: () => CalendarQuery,
+dispatchActionPromise: DispatchActionPromise,
+restoreEntry: (info: RestoreEntryInfo) => Promise,
+creator: ?UserInfo,
};
class HistoryEntry extends React.PureComponent {
- render() {
+ render(): React.Node {
let deleted = null;
if (this.props.entryInfo.deleted) {
let restore = null;
if (this.props.loggedIn) {
restore = (
(
restore
)
);
}
deleted = (
deleted
{restore}
);
}
const textClasses = classNames({
[css.entry]: true,
[css.darkEntry]: colorIsDark(this.props.threadInfo.color),
});
const textStyle = { backgroundColor: '#' + this.props.threadInfo.color };
const creator = this.props.creator?.username ? (
{this.props.creator.username}
) : (
'anonymous'
);
return (
{this.props.entryInfo.text}
{'created by '}
{creator}
{this.props.threadInfo.uiName}
{deleted}
revision history >
);
}
onRestore = (event: SyntheticEvent) => {
event.preventDefault();
const entryID = this.props.entryInfo.id;
invariant(entryID, 'entryInfo.id (serverID) should be set');
this.props.dispatchActionPromise(
restoreEntryActionTypes,
this.restoreEntryAction(),
{ customKeyName: `${restoreEntryActionTypes.started}:${entryID}` },
);
};
onClick = (event: SyntheticEvent) => {
event.preventDefault();
const entryID = this.props.entryInfo.id;
invariant(entryID, 'entryInfo.id (serverID) should be set');
this.props.onClick(entryID);
};
- async restoreEntryAction() {
+ async restoreEntryAction(): Promise {
const entryID = this.props.entryInfo.id;
invariant(entryID, 'entry should have ID');
const result = await this.props.restoreEntry({
entryID,
calendarQuery: this.props.calendarQuery(),
});
this.props.animateAndLoadEntry(entryID);
return { ...result, threadID: this.props.threadInfo.id };
}
}
const ConnectedHistoryEntry: React.ComponentType =
React.memo(function ConnectedHistoryEntry(props) {
const entryID = props.entryInfo.id;
invariant(entryID, 'entryInfo.id (serverID) should be set');
const unresolvedThreadInfo = useSelector(
state => threadInfoSelector(state)[props.entryInfo.threadID],
);
const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo);
const loggedIn = useSelector(
state =>
!!(state.currentUserInfo && !state.currentUserInfo.anonymous && true),
);
const restoreLoadingStatus = useSelector(
createLoadingStatusSelector(
restoreEntryActionTypes,
`${restoreEntryActionTypes.started}:${entryID}`,
),
);
const calenderQuery = useSelector(nonThreadCalendarQuery);
const callRestoreEntry = useRestoreEntry();
const dispatchActionPromise = useDispatchActionPromise();
const { creator } = props.entryInfo;
const [creatorWithENSName] = useENSNames([creator]);
return (
);
});
export default ConnectedHistoryEntry;
diff --git a/web/modals/history/history-modal.react.js b/web/modals/history/history-modal.react.js
index 39791cf03..0711dcf71 100644
--- a/web/modals/history/history-modal.react.js
+++ b/web/modals/history/history-modal.react.js
@@ -1,287 +1,287 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import _filter from 'lodash/fp/filter.js';
import _flow from 'lodash/fp/flow.js';
import _map from 'lodash/fp/map.js';
import _unionBy from 'lodash/fp/unionBy.js';
import * as React from 'react';
import {
fetchEntriesActionTypes,
useFetchEntries,
fetchRevisionsForEntryActionTypes,
useFetchRevisionsForEntry,
} from 'lib/actions/entry-actions.js';
import type { FetchRevisionsForEntryInput } from 'lib/actions/entry-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { nonExcludeDeletedCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type {
EntryInfo,
CalendarQuery,
FetchEntryInfosResult,
FetchRevisionsForEntryPayload,
} from 'lib/types/entry-types.js';
import { type CalendarFilter } from 'lib/types/filter-types.js';
import type {
HistoryMode,
HistoryRevisionInfo,
} from 'lib/types/history-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { prettyDateWithoutDay } from 'lib/utils/date-utils.js';
import HistoryEntry from './history-entry.react.js';
import HistoryRevision from './history-revision.react.js';
import css from './history.css';
import LoadingIndicator from '../../loading-indicator.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import { allDaysToEntries } from '../../selectors/entry-selectors.js';
import Modal from '../modal.react.js';
type BaseProps = {
+mode: HistoryMode,
+dayString: string,
+currentEntryID?: ?string,
};
type Props = {
...BaseProps,
+entryInfos: ?(EntryInfo[]),
+dayLoadingStatus: LoadingStatus,
+entryLoadingStatus: LoadingStatus,
+calendarFilters: $ReadOnlyArray,
+dispatchActionPromise: DispatchActionPromise,
+fetchEntries: (
calendarQuery: CalendarQuery,
) => Promise,
+fetchRevisionsForEntry: (
input: FetchRevisionsForEntryInput,
) => Promise<$ReadOnlyArray>,
+onClose: () => void,
};
type State = {
+mode: HistoryMode,
+animateModeChange: boolean,
+currentEntryID: ?string,
+revisions: $ReadOnlyArray,
};
class HistoryModal extends React.PureComponent {
- static defaultProps = { currentEntryID: null };
+ static defaultProps: Partial = { currentEntryID: null };
constructor(props: Props) {
super(props);
this.state = {
mode: props.mode,
animateModeChange: false,
currentEntryID: props.currentEntryID,
revisions: [],
};
}
componentDidMount() {
this.loadDay();
if (this.state.mode === 'entry') {
invariant(this.state.currentEntryID, 'entry ID should be set');
this.loadEntry(this.state.currentEntryID);
}
}
- render() {
+ render(): React.Node {
let allHistoryButton = null;
if (this.state.mode === 'entry') {
allHistoryButton = (
< all entries
);
}
const prettyDate = prettyDateWithoutDay(this.props.dayString);
const loadingStatus =
this.state.mode === 'day'
? this.props.dayLoadingStatus
: this.props.entryLoadingStatus;
let entries;
const entryInfos = this.props.entryInfos;
if (entryInfos) {
entries = _flow(
_filter((entryInfo: EntryInfo) => entryInfo.id),
_map((entryInfo: EntryInfo) => {
const serverID = entryInfo.id;
invariant(serverID, 'serverID should be set');
return (
);
}),
)(entryInfos);
} else {
entries = [];
}
const revisionInfos = this.state.revisions.filter(
revisionInfo => revisionInfo.entryID === this.state.currentEntryID,
);
const revisions = [];
for (let i = 0; i < revisionInfos.length; i++) {
const revisionInfo = revisionInfos[i];
const nextRevisionInfo = revisionInfos[i + 1];
const isDeletionOrRestoration =
nextRevisionInfo !== undefined &&
revisionInfo.deleted !== nextRevisionInfo.deleted;
revisions.push(
,
);
}
const animate = this.state.animateModeChange;
const dayMode = this.state.mode === 'day';
const dayClasses = classNames({
[css.dayHistory]: true,
[css.dayHistoryVisible]: dayMode && !animate,
[css.dayHistoryInvisible]: !dayMode && !animate,
[css.dayHistoryVisibleAnimate]: dayMode && animate,
[css.dayHistoryInvisibleAnimate]: !dayMode && animate,
});
const entryMode = this.state.mode === 'entry';
const entryClasses = classNames({
[css.entryHistory]: true,
[css.entryHistoryVisible]: entryMode && !animate,
[css.entryHistoryInvisible]: !entryMode && !animate,
[css.entryHistoryVisibleAnimate]: entryMode && animate,
[css.entryHistoryInvisibleAnimate]: !entryMode && animate,
});
return (
{allHistoryButton}
{prettyDate}
);
}
loadDay() {
this.props.dispatchActionPromise(
fetchEntriesActionTypes,
this.props.fetchEntries({
startDate: this.props.dayString,
endDate: this.props.dayString,
filters: this.props.calendarFilters,
}),
);
}
loadEntry(entryID: string) {
this.setState({ mode: 'entry', currentEntryID: entryID });
this.props.dispatchActionPromise(
fetchRevisionsForEntryActionTypes,
this.fetchRevisionsForEntryAction(entryID),
);
}
async fetchRevisionsForEntryAction(
entryID: string,
): Promise {
const result = await this.props.fetchRevisionsForEntry({ entryID });
this.setState(prevState => {
// This merge here will preserve time ordering correctly
const revisions = _unionBy('id')(result)(prevState.revisions);
return { ...prevState, revisions };
});
return {
entryID,
text: result[0].text,
deleted: result[0].deleted,
};
}
onClickEntry = (entryID: string) => {
this.setState({ animateModeChange: true });
this.loadEntry(entryID);
};
onClickAllEntries = (event: SyntheticEvent) => {
event.preventDefault();
this.setState({
mode: 'day',
animateModeChange: true,
});
};
animateAndLoadEntry = (entryID: string) => {
this.setState({ animateModeChange: true });
this.loadEntry(entryID);
};
}
const dayLoadingStatusSelector = createLoadingStatusSelector(
fetchEntriesActionTypes,
);
const entryLoadingStatusSelector = createLoadingStatusSelector(
fetchRevisionsForEntryActionTypes,
);
const ConnectedHistoryModal: React.ComponentType =
React.memo(function ConnectedHistoryModal(props) {
const entryInfos = useSelector(
state => allDaysToEntries(state)[props.dayString],
);
const dayLoadingStatus = useSelector(dayLoadingStatusSelector);
const entryLoadingStatus = useSelector(entryLoadingStatusSelector);
const calendarFilters = useSelector(
nonExcludeDeletedCalendarFiltersSelector,
);
const callFetchEntries = useFetchEntries();
const callFetchRevisionsForEntry = useFetchRevisionsForEntry();
const dispatchActionPromise = useDispatchActionPromise();
const modalContext = useModalContext();
return (
);
});
export default ConnectedHistoryModal;
diff --git a/web/modals/search/message-search-modal.react.js b/web/modals/search/message-search-modal.react.js
index ac375efbe..6108a0234 100644
--- a/web/modals/search/message-search-modal.react.js
+++ b/web/modals/search/message-search-modal.react.js
@@ -1,159 +1,160 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
+import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './message-search-modal.css';
import { useParseSearchResults } from './message-search-utils.react.js';
import { useTooltipContext } from '../../chat/tooltip-provider.js';
import Button from '../../components/button.react.js';
import MessageResult from '../../components/message-result.react.js';
import Search from '../../components/search.react.js';
import LoadingIndicator from '../../loading-indicator.react.js';
import { useMessageSearchContext } from '../../search/message-search-state-provider.react.js';
import Modal from '../modal.react.js';
type ContentProps = {
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
function MessageSearchModal(props: ContentProps): React.Node {
const { threadInfo } = props;
const {
getQuery,
setQuery,
clearQuery,
searchMessages,
getSearchResults,
getEndReached,
} = useMessageSearchContext();
const [input, setInput] = React.useState(getQuery(threadInfo.id));
const onPressSearch = React.useCallback(() => {
setQuery(input, threadInfo.id);
searchMessages(threadInfo.id);
}, [setQuery, input, searchMessages, threadInfo.id]);
const onKeyDown = React.useCallback(
- event => {
+ (event: SyntheticKeyboardEvent) => {
if (event.key === 'Enter') {
onPressSearch();
}
},
[onPressSearch],
);
const modifiedItems = useParseSearchResults(
threadInfo,
getSearchResults(threadInfo.id),
);
const { clearTooltip } = useTooltipContext();
const messageContainer = React.useRef(null);
const possiblyLoadMoreMessages = React.useCallback(() => {
if (!messageContainer.current) {
return;
}
const loaderTopOffset = 32;
const { scrollTop, scrollHeight, clientHeight } = messageContainer.current;
if (Math.abs(scrollTop) + clientHeight + loaderTopOffset < scrollHeight) {
return;
}
searchMessages(threadInfo.id);
}, [searchMessages, threadInfo.id]);
const onScroll = React.useCallback(() => {
clearTooltip();
possiblyLoadMoreMessages();
}, [clearTooltip, possiblyLoadMoreMessages]);
const renderItem = React.useCallback(
- item => (
+ (item: ChatMessageInfoItem) => (
),
[threadInfo],
);
const messages = React.useMemo(
() => modifiedItems.map(item => renderItem(item)),
[modifiedItems, renderItem],
);
const endReached = getEndReached(threadInfo.id);
const query = getQuery(threadInfo.id);
const footer = React.useMemo(() => {
if (query === '') {
return (
Your search results will appear here
);
}
if (!endReached) {
return (
);
}
if (modifiedItems.length > 0) {
return End of results
;
}
return (
No results. Please try using different keywords to refine your search
);
}, [query, endReached, modifiedItems.length]);
const { uiName } = useResolvedThreadInfo(threadInfo);
const searchPlaceholder = `Searching in ${uiName}`;
const { popModal } = useModalContext();
const clearQueryWrapper = React.useCallback(
() => clearQuery(threadInfo.id),
[clearQuery, threadInfo.id],
);
return (
Search
{messages}
{footer}
);
}
export default MessageSearchModal;
diff --git a/web/modals/threads/gallery/thread-settings-media-gallery.react.js b/web/modals/threads/gallery/thread-settings-media-gallery.react.js
index 64c319e5c..bbf579025 100644
--- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js
+++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js
@@ -1,191 +1,193 @@
// @flow
+import invariant from 'invariant';
import * as React from 'react';
import { useFetchThreadMedia } from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import {
encryptedMediaBlobURI,
encryptedVideoThumbnailBlobURI,
} from 'lib/media/media-utils.js';
import type { Media } from 'lib/types/media-types.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import GalleryItem from './thread-settings-media-gallery-item.react.js';
import css from './thread-settings-media-gallery.css';
import Tabs from '../../../components/tabs.react.js';
import MultimediaModal from '../../../media/multimedia-modal.react.js';
import Modal from '../../modal.react.js';
type MediaGalleryTab = 'All' | 'Images' | 'Videos';
type ThreadSettingsMediaGalleryModalProps = {
+onClose: () => void,
+parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+limit: number,
+activeTab: MediaGalleryTab,
};
function ThreadSettingsMediaGalleryModal(
props: ThreadSettingsMediaGalleryModalProps,
): React.Node {
const { pushModal } = useModalContext();
const { onClose, parentThreadInfo, limit, activeTab } = props;
const { id: threadID } = parentThreadInfo;
const modalName = 'Media';
const callFetchThreadMedia = useFetchThreadMedia();
const [mediaInfos, setMediaInfos] = React.useState([]);
const [tab, setTab] = React.useState(activeTab);
React.useEffect(() => {
const fetchData = async () => {
const result = await callFetchThreadMedia({
threadID,
limit,
offset: 0,
});
setMediaInfos(result.media);
};
fetchData();
}, [callFetchThreadMedia, threadID, limit]);
const onClick = React.useCallback(
(media: Media) => {
const thumbHash = media.thumbnailThumbHash ?? media.thumbHash;
let mediaInfo = {
thumbHash,
dimensions: media.dimensions,
};
if (media.type === 'photo' || media.type === 'video') {
const { uri, thumbnailURI } = media;
mediaInfo = {
...mediaInfo,
type: media.type,
uri,
thumbnailURI,
};
} else {
const { encryptionKey, thumbnailEncryptionKey } = media;
const thumbnailBlobURI =
media.type === 'encrypted_video'
? encryptedVideoThumbnailBlobURI(media)
: null;
mediaInfo = {
...mediaInfo,
type: media.type,
blobURI: encryptedMediaBlobURI(media),
encryptionKey,
thumbnailBlobURI,
thumbnailEncryptionKey,
};
}
pushModal( );
},
[pushModal],
);
const mediaGalleryItems = React.useMemo(() => {
let filteredMediaInfos = mediaInfos;
if (tab === 'Images') {
filteredMediaInfos = mediaInfos.filter(
mediaInfo =>
mediaInfo.type === 'photo' || mediaInfo.type === 'encrypted_photo',
);
} else if (tab === 'Videos') {
filteredMediaInfos = mediaInfos.filter(
mediaInfo =>
mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video',
);
}
return filteredMediaInfos.map((media, i) => {
let imageSource;
if (media.type === 'photo') {
imageSource = {
kind: 'plain',
uri: media.uri,
thumbHash: media.thumbHash,
};
} else if (media.type === 'video') {
imageSource = {
kind: 'plain',
uri: media.thumbnailURI,
thumbHash: media.thumbnailThumbHash,
};
} else if (media.type === 'encrypted_photo') {
imageSource = {
kind: 'encrypted',
blobURI: encryptedMediaBlobURI(media),
encryptionKey: media.encryptionKey,
thumbHash: media.thumbHash,
};
} else {
imageSource = {
kind: 'encrypted',
blobURI: encryptedVideoThumbnailBlobURI(media),
encryptionKey: media.thumbnailEncryptionKey,
thumbHash: media.thumbnailThumbHash,
};
}
return (
onClick(media)}
imageSource={imageSource}
imageCSSClass={css.media}
imageContainerCSSClass={css.mediaContainer}
/>
);
});
}, [tab, mediaInfos, onClick]);
const handleScroll = React.useCallback(
- async event => {
+ async (event: SyntheticEvent) => {
const container = event.target;
+ invariant(container instanceof HTMLDivElement, 'target not div');
// Load more data when the user is within 1000 pixels of the end
const buffer = 1000;
if (
container.scrollHeight - container.scrollTop >
container.clientHeight + buffer
) {
return;
}
const result = await callFetchThreadMedia({
threadID,
limit,
offset: mediaInfos.length,
});
setMediaInfos([...mediaInfos, ...result.media]);
},
[callFetchThreadMedia, threadID, limit, mediaInfos],
);
return (
{mediaGalleryItems}
{mediaGalleryItems}
{mediaGalleryItems}
);
}
export default ThreadSettingsMediaGalleryModal;
diff --git a/web/modals/threads/members/add-members-modal.react.js b/web/modals/threads/members/add-members-modal.react.js
index 2c50df35b..94956158a 100644
--- a/web/modals/threads/members/add-members-modal.react.js
+++ b/web/modals/threads/members/add-members-modal.react.js
@@ -1,199 +1,199 @@
// @flow
import * as React from 'react';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import {
userSearchIndexForPotentialMembers,
userInfoSelectorForPotentialMembers,
} from 'lib/selectors/user-selectors.js';
import { getPotentialMemberItems } from 'lib/shared/search-utils.js';
import { threadActualMembers } from 'lib/shared/thread-utils.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import AddMembersListContent from './add-members-list-content.react.js';
import css from './members-modal.css';
import Button from '../../../components/button.react.js';
import Label from '../../../components/label.react.js';
import { useSelector } from '../../../redux/redux-utils.js';
import SearchModal from '../../search-modal.react.js';
type ContentProps = {
+searchText: string,
+threadID: string,
+onClose: () => void,
};
function AddMembersModalContent(props: ContentProps): React.Node {
const { searchText, threadID, onClose } = props;
const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState<
$ReadOnlySet,
>(new Set());
const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]);
const { parentThreadID, community } = threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const communityThreadInfo = useSelector(state =>
community ? threadInfoSelector(state)[community] : null,
);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers);
const excludeUserIDs = React.useMemo(
() =>
threadActualMembers(threadInfo.members).concat(
Array.from(pendingUsersToAdd),
),
[pendingUsersToAdd, threadInfo.members],
);
const userSearchResults = React.useMemo(
() =>
getPotentialMemberItems({
text: searchText,
userInfos: otherUserInfos,
searchIndex: userSearchIndex,
excludeUserIDs,
inputParentThreadInfo: parentThreadInfo,
inputCommunityThreadInfo: communityThreadInfo,
threadType: threadInfo.type,
}),
[
communityThreadInfo,
excludeUserIDs,
otherUserInfos,
parentThreadInfo,
searchText,
threadInfo.type,
userSearchIndex,
],
);
const userSearchResultsWithENSNames = useENSNames(userSearchResults);
const onSwitchUser = React.useCallback(
- userID =>
+ (userID: string) =>
setPendingUsersToAdd(users => {
const newUsers = new Set(users);
if (newUsers.has(userID)) {
newUsers.delete(userID);
} else {
newUsers.add(userID);
}
return newUsers;
}),
[],
);
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
const addUsers = React.useCallback(() => {
dispatchActionPromise(
changeThreadSettingsActionTypes,
callChangeThreadSettings({
threadID,
changes: { newMemberIDs: Array.from(pendingUsersToAdd) },
}),
);
onClose();
}, [
callChangeThreadSettings,
dispatchActionPromise,
onClose,
pendingUsersToAdd,
threadID,
]);
const pendingUserInfos = React.useMemo(
() =>
Array.from(pendingUsersToAdd)
.map(userID => ({
id: userID,
username: otherUserInfos[userID].username,
}))
.sort((a, b) => a.username.localeCompare(b.username)),
[otherUserInfos, pendingUsersToAdd],
);
const pendingUserInfosWithENSNames = useENSNames(pendingUserInfos);
const labelItems = React.useMemo(() => {
if (!pendingUserInfosWithENSNames.length) {
return null;
}
return (
{pendingUserInfosWithENSNames.map(userInfo => (
onSwitchUser(userInfo.id)}>
{userInfo.username}
))}
);
}, [onSwitchUser, pendingUserInfosWithENSNames]);
return (
{labelItems}
Cancel
Add selected members
);
}
type Props = {
+threadID: string,
+onClose: () => void,
};
function AddMembersModal(props: Props): React.Node {
const { threadID, onClose } = props;
const addMembersModalContent = React.useCallback(
(searchText: string) => (
),
[onClose, threadID],
);
return (
{addMembersModalContent}
);
}
export { AddMembersModal, AddMembersModalContent };
diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js
index e7d89714f..1578db4e4 100644
--- a/web/modals/threads/members/member.react.js
+++ b/web/modals/threads/members/member.react.js
@@ -1,142 +1,142 @@
// @flow
import * as React from 'react';
import { useRemoveUsersFromThread } from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
removeMemberFromThread,
getAvailableThreadMemberActions,
} from 'lib/shared/thread-utils.js';
import { stringForUser } from 'lib/shared/user-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
import type {
MinimallyEncodedRelativeMemberInfo,
MinimallyEncodedThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
import {
type RelativeMemberInfo,
type ThreadInfo,
} from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js';
import ChangeMemberRoleModal from './change-member-role-modal.react.js';
import css from './members-modal.css';
import UserAvatar from '../../../avatars/user-avatar.react.js';
import CommIcon from '../../../CommIcon.react.js';
import Label from '../../../components/label.react.js';
import MenuItem from '../../../components/menu-item.react.js';
import Menu from '../../../components/menu.react.js';
import { usePushUserProfileModal } from '../../user-profile/user-profile-utils.js';
const commIconComponent = ;
type Props = {
+memberInfo: RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+setOpenMenu: SetState,
};
function ThreadMember(props: Props): React.Node {
const { memberInfo, threadInfo, setOpenMenu } = props;
const { pushModal } = useModalContext();
const userName = stringForUser(memberInfo);
const roles = useRolesFromCommunityThreadInfo(threadInfo, [memberInfo]);
const roleName = roles.get(memberInfo.id)?.name;
const onMenuChange = React.useCallback(
- menuOpen => {
+ (menuOpen: boolean) => {
if (menuOpen) {
setOpenMenu(() => memberInfo.id);
} else {
setOpenMenu(menu => (menu === memberInfo.id ? null : menu));
}
},
[memberInfo.id, setOpenMenu],
);
const dispatchActionPromise = useDispatchActionPromise();
const boundRemoveUsersFromThread = useRemoveUsersFromThread();
const onClickRemoveUser = React.useCallback(
() =>
removeMemberFromThread(
threadInfo,
memberInfo,
dispatchActionPromise,
boundRemoveUsersFromThread,
),
[boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo],
);
const onClickChangeRole = React.useCallback(() => {
pushModal(
,
);
}, [memberInfo, pushModal, threadInfo]);
const menuItems = React.useMemo(
() =>
getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => {
if (action === 'change_role') {
return (
);
}
if (action === 'remove_user') {
return (
);
}
return null;
}),
[memberInfo, onClickRemoveUser, onClickChangeRole, threadInfo],
);
const userSettingsIcon = React.useMemo(
() => ,
[],
);
const label = React.useMemo(
() => {roleName} ,
[roleName],
);
const pushUserProfileModal = usePushUserProfileModal(memberInfo.id);
return (
{userName}
{label}
{menuItems}
);
}
export default ThreadMember;
diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js
index f08d65abe..8a97c9083 100644
--- a/web/modals/threads/sidebars/sidebar.react.js
+++ b/web/modals/threads/sidebars/sidebar.react.js
@@ -1,103 +1,103 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { useMessagePreview } from 'lib/shared/message-utils.js';
import { shortAbsoluteDate } from 'lib/utils/date-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './sidebars-modal.css';
import ThreadAvatar from '../../../avatars/thread-avatar.react.js';
import Button from '../../../components/button.react.js';
import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js';
import { useOnClickThread } from '../../../selectors/thread-selectors.js';
type Props = {
+sidebar: ChatThreadItem,
+isLastItem?: boolean,
};
function Sidebar(props: Props): React.Node {
const { sidebar, isLastItem } = props;
const { threadInfo, lastUpdatedTime, mostRecentMessageInfo } = sidebar;
const { unread } = threadInfo.currentUser;
const { popModal } = useModalContext();
const navigateToThread = useOnClickThread(threadInfo);
const onClickThread = React.useCallback(
- event => {
+ (event: SyntheticEvent) => {
popModal();
navigateToThread(event);
},
[popModal, navigateToThread],
);
const sidebarInfoClassName = classNames({
[css.sidebarInfo]: true,
[css.unread]: unread,
});
const previewTextClassName = classNames([
css.longTextEllipsis,
css.avatarOffset,
]);
const lastActivity = React.useMemo(
() => shortAbsoluteDate(lastUpdatedTime),
[lastUpdatedTime],
);
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const messagePreviewResult = useMessagePreview(
mostRecentMessageInfo,
threadInfo,
getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules,
);
const lastMessage = React.useMemo(() => {
if (!messagePreviewResult) {
return No messages
;
}
const { message, username } = messagePreviewResult;
const previewText = username
? `${username.text}: ${message.text}`
: message.text;
return (
<>
{previewText}
{lastActivity}
>
);
}, [lastActivity, messagePreviewResult, previewTextClassName]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
);
}
export default Sidebar;
diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js
index 6965a52b8..b6994d2ae 100644
--- a/web/modals/threads/subchannels/subchannel.react.js
+++ b/web/modals/threads/subchannels/subchannel.react.js
@@ -1,90 +1,90 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import { type ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { useMessagePreview } from 'lib/shared/message-utils.js';
import { shortAbsoluteDate } from 'lib/utils/date-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './subchannels-modal.css';
import ThreadAvatar from '../../../avatars/thread-avatar.react.js';
import Button from '../../../components/button.react.js';
import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js';
import { useOnClickThread } from '../../../selectors/thread-selectors.js';
type Props = {
+chatThreadItem: ChatThreadItem,
};
function Subchannel(props: Props): React.Node {
const { chatThreadItem } = props;
const {
threadInfo,
mostRecentMessageInfo,
lastUpdatedTimeIncludingSidebars,
} = chatThreadItem;
const { unread } = threadInfo.currentUser;
const subchannelTitleClassName = classNames({
[css.subchannelInfo]: true,
[css.unread]: unread,
});
const { popModal } = useModalContext();
const navigateToThread = useOnClickThread(threadInfo);
const onClickThread = React.useCallback(
- event => {
+ (event: SyntheticEvent) => {
popModal();
navigateToThread(event);
},
[popModal, navigateToThread],
);
const lastActivity = React.useMemo(
() => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars),
[lastUpdatedTimeIncludingSidebars],
);
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
const messagePreviewResult = useMessagePreview(
mostRecentMessageInfo,
threadInfo,
getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules,
);
const lastMessage = React.useMemo(() => {
if (!messagePreviewResult) {
return No messages
;
}
const { message, username } = messagePreviewResult;
const previewText = username
? `${username.text}: ${message.text}`
: message.text;
return (
<>
{previewText}
{lastActivity}
>
);
}, [lastActivity, messagePreviewResult]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
);
}
export default Subchannel;
diff --git a/web/modals/update-modal.react.js b/web/modals/update-modal.react.js
index 61d60844c..278d01585 100644
--- a/web/modals/update-modal.react.js
+++ b/web/modals/update-modal.react.js
@@ -1,98 +1,98 @@
// @flow
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import Modal from './modal.react.js';
import css from './update-modal.css';
import Button from '../components/button.react.js';
import electron from '../electron.js';
type Props = {
+title: string,
+text: string,
+confirmText: string,
+onConfirm: () => void,
};
function UpdateModal(props: Props): React.Node {
const { title, text, confirmText, onConfirm } = props;
const { popModal } = useModalContext();
return (
{text}
Later
{confirmText}
);
}
function UpdateModalHandler(): React.Node {
const { pushModal, popModal } = useModalContext();
// This modal is only for the update from the first version (0.0.1)
// to the self-updating version
React.useEffect(() => {
if (electron === null || electron.version !== undefined) {
return;
}
pushModal(
{
window.open(
'https://electron-update.commtechnologies.org/download',
'_blank',
'noopener noreferrer',
);
popModal();
}}
/>,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(
() =>
- electron?.onNewVersionAvailable?.(version => {
+ electron?.onNewVersionAvailable?.((version: string) => {
// On these versions we want to update immediately because there's
// an issue if the user decides to update 10min after showing the modal
if (electron?.version === '1.0.0' || electron?.version === '2.0.0') {
electron?.updateToNewVersion?.();
}
pushModal(
electron?.updateToNewVersion?.()}
/>,
);
}),
[pushModal],
);
return null;
}
export default UpdateModalHandler;
diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js
index cdc35a85d..f66bcf4ba 100644
--- a/web/push-notif/push-notifs-handler.js
+++ b/web/push-notif/push-notifs-handler.js
@@ -1,209 +1,211 @@
// @flow
import * as React from 'react';
import {
useSetDeviceTokenFanout,
setDeviceTokenActionTypes,
} from 'lib/actions/device-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js';
import {
shouldSkipPushPermissionAlert,
recordNotifPermissionAlertActionType,
} from 'lib/utils/push-alerts.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import {
WORKERS_MODULES_DIR_PATH,
DEFAULT_OLM_FILENAME,
} from '../database/utils/constants.js';
import electron from '../electron.js';
import PushNotifModal from '../modals/push-notif-modal.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { useStaffCanSee } from '../utils/staff-utils.js';
declare var baseURL: string;
declare var olmFilename: string;
function useCreateDesktopPushSubscription() {
const dispatchActionPromise = useDispatchActionPromise();
const callSetDeviceToken = useSetDeviceTokenFanout();
React.useEffect(
() =>
- electron?.onDeviceTokenRegistered?.(token => {
+ electron?.onDeviceTokenRegistered?.((token: ?string) => {
dispatchActionPromise(
setDeviceTokenActionTypes,
callSetDeviceToken(token),
);
}),
[callSetDeviceToken, dispatchActionPromise],
);
const dispatch = useDispatch();
React.useEffect(
() =>
- electron?.onNotificationClicked?.(({ threadID }) => {
- const convertedThreadID = convertNonPendingIDToNewSchema(
- threadID,
- ashoatKeyserverID,
- );
-
- const payload = {
- chatMode: 'view',
- activeChatThreadID: convertedThreadID,
- tab: 'chat',
- };
-
- dispatch({ type: updateNavInfoActionType, payload });
- }),
+ electron?.onNotificationClicked?.(
+ ({ threadID }: { +threadID: string }) => {
+ const convertedThreadID = convertNonPendingIDToNewSchema(
+ threadID,
+ ashoatKeyserverID,
+ );
+
+ const payload = {
+ chatMode: 'view',
+ activeChatThreadID: convertedThreadID,
+ tab: 'chat',
+ };
+
+ dispatch({ type: updateNavInfoActionType, payload });
+ },
+ ),
[dispatch],
);
}
function useCreatePushSubscription(): () => Promise {
const publicKey = useSelector(state => state.pushApiPublicKey);
const dispatchActionPromise = useDispatchActionPromise();
const callSetDeviceToken = useSetDeviceTokenFanout();
const staffCanSee = useStaffCanSee();
return React.useCallback(async () => {
if (!publicKey) {
return;
}
const workerRegistration = await navigator.serviceWorker?.ready;
if (!workerRegistration) {
return;
}
const origin = window.location.origin;
const olmWasmDirPath = `${origin}${baseURL}${WORKERS_MODULES_DIR_PATH}`;
const olmWasmFilename = olmFilename ? olmFilename : DEFAULT_OLM_FILENAME;
const olmWasmPath = `${olmWasmDirPath}/${olmWasmFilename}`;
workerRegistration.active?.postMessage({ olmWasmPath, staffCanSee });
const subscription = await workerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
dispatchActionPromise(
setDeviceTokenActionTypes,
callSetDeviceToken(JSON.stringify(subscription)),
);
}, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]);
}
function PushNotificationsHandler(): React.Node {
useCreateDesktopPushSubscription();
const createPushSubscription = useCreatePushSubscription();
const notifPermissionAlertInfo = useSelector(
state => state.notifPermissionAlertInfo,
);
const modalContext = useModalContext();
const loggedIn = useSelector(isLoggedIn);
const dispatch = useDispatch();
const supported = 'Notification' in window && !electron;
React.useEffect(() => {
(async () => {
if (!navigator.serviceWorker || !supported) {
return;
}
await navigator.serviceWorker.register('worker/notif', { scope: '/' });
if (Notification.permission === 'granted') {
// Make sure the subscription is current if we have the permissions
await createPushSubscription();
} else if (
Notification.permission === 'default' &&
loggedIn &&
!shouldSkipPushPermissionAlert(notifPermissionAlertInfo)
) {
// Ask existing users that are already logged in for permission
modalContext.pushModal( );
dispatch({
type: recordNotifPermissionAlertActionType,
payload: { time: Date.now() },
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Ask for permission on login
const prevLoggedIn = React.useRef(loggedIn);
React.useEffect(() => {
if (!navigator.serviceWorker || !supported) {
return;
}
if (!prevLoggedIn.current && loggedIn) {
if (Notification.permission === 'granted') {
createPushSubscription();
} else if (
Notification.permission === 'default' &&
!shouldSkipPushPermissionAlert(notifPermissionAlertInfo)
) {
modalContext.pushModal( );
dispatch({
type: recordNotifPermissionAlertActionType,
payload: { time: Date.now() },
});
}
}
prevLoggedIn.current = loggedIn;
}, [
createPushSubscription,
dispatch,
loggedIn,
modalContext,
notifPermissionAlertInfo,
prevLoggedIn,
supported,
]);
// Redirect to thread on notification click
React.useEffect(() => {
if (!navigator.serviceWorker || !supported) {
return undefined;
}
const callback = (event: MessageEvent) => {
if (typeof event.data !== 'object' || !event.data) {
return;
}
if (event.data.targetThreadID) {
const payload = {
chatMode: 'view',
activeChatThreadID: event.data.targetThreadID,
tab: 'chat',
};
dispatch({ type: updateNavInfoActionType, payload });
}
};
navigator.serviceWorker.addEventListener('message', callback);
return () =>
navigator.serviceWorker?.removeEventListener('message', callback);
}, [dispatch, supported]);
return null;
}
export { PushNotificationsHandler, useCreatePushSubscription };
diff --git a/web/redux/focus-handler.react.js b/web/redux/focus-handler.react.js
index 101493fed..c41fa4c16 100644
--- a/web/redux/focus-handler.react.js
+++ b/web/redux/focus-handler.react.js
@@ -1,66 +1,66 @@
// @flow
import * as React from 'react';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { updateWindowActiveActionType } from './action-types.js';
import { useSelector } from './redux-utils.js';
function FocusHandler(): React.Node {
const [focused, setFocused] = React.useState(
typeof window === 'undefined' ||
!window ||
!window.hasFocus ||
window.hasFocus(),
);
const onFocus = React.useCallback(() => {
setFocused(true);
}, []);
const onBlur = React.useCallback(() => {
setFocused(false);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
};
}, [onFocus, onBlur]);
const dispatch = useDispatch();
const curWindowActive = useSelector(state => state.windowActive);
const updateRedux = React.useCallback(
- windowActive => {
+ (windowActive: boolean) => {
if (windowActive === curWindowActive) {
return;
}
dispatch({ type: updateWindowActiveActionType, payload: windowActive });
},
[dispatch, curWindowActive],
);
const prevFocusedRef = React.useRef(curWindowActive);
const timerRef = React.useRef();
React.useEffect(() => {
const prevFocused = prevFocusedRef.current;
if (focused && !prevFocused) {
timerRef.current = setTimeout(() => updateRedux(true), 2000);
} else if (!focused && prevFocused) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
updateRedux(false);
}
prevFocusedRef.current = focused;
}, [focused, updateRedux]);
return null;
}
export default FocusHandler;
diff --git a/web/redux/initial-state-gate.js b/web/redux/initial-state-gate.js
index 8a0997ca4..6ea6b4858 100644
--- a/web/redux/initial-state-gate.js
+++ b/web/redux/initial-state-gate.js
@@ -1,136 +1,136 @@
// @flow
import * as React from 'react';
import { PersistGate } from 'redux-persist/es/integration/react.js';
import type { Persistor } from 'redux-persist/es/types';
import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js';
import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js';
import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js';
import type { RawThreadInfo } from 'lib/types/thread-types.js';
import { convertIDToNewSchema } from 'lib/utils/migration-utils.js';
import { entries } from 'lib/utils/objects.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { infoFromURL } from 'lib/utils/url-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import {
setInitialReduxState,
useGetInitialReduxState,
} from './action-types.js';
import { useSelector } from './redux-utils.js';
import {
getClientStore,
processDBStoreOperations,
} from '../database/utils/store.js';
import Loading from '../loading.react.js';
type Props = {
+persistor: Persistor,
+children: React.Node,
};
function InitialReduxStateGate(props: Props): React.Node {
const { children, persistor } = props;
const callGetInitialReduxState = useGetInitialReduxState();
const dispatch = useDispatch();
const [initError, setInitError] = React.useState(null);
React.useEffect(() => {
if (initError) {
throw initError;
}
}, [initError]);
const isRehydrated = useSelector(state => !!state._persist?.rehydrated);
const prevIsRehydrated = React.useRef(false);
React.useEffect(() => {
if (!prevIsRehydrated.current && isRehydrated) {
prevIsRehydrated.current = isRehydrated;
(async () => {
try {
let urlInfo = infoFromURL(decodeURI(window.location.href));
// Handle older links
if (urlInfo.thread) {
urlInfo = {
...urlInfo,
thread: convertIDToNewSchema(urlInfo.thread, ashoatKeyserverID),
};
}
const clientDBStore = await getClientStore();
const payload = await callGetInitialReduxState({
urlInfo,
excludedData: { threadStore: !!clientDBStore.threadStore },
});
const currentLoggedInUserID = payload.currentUserInfo?.anonymous
? undefined
: payload.currentUserInfo?.id;
const useDatabase = canUseDatabaseOnWeb(currentLoggedInUserID);
if (!currentLoggedInUserID || !useDatabase) {
dispatch({ type: setInitialReduxState, payload });
return;
}
if (clientDBStore.threadStore) {
// If there is data in the DB, populate the store
dispatch({
type: setClientDBStoreActionType,
payload: clientDBStore,
});
const { threadStore, ...rest } = payload;
dispatch({ type: setInitialReduxState, payload: rest });
return;
} else {
// When there is no data in the DB, it's necessary to migrate data
// from the keyserver payload to the DB
const {
threadStore: { threadInfos },
} = payload;
const threadStoreOperations: ThreadStoreOperation[] = entries(
threadInfos,
).map(([id, threadInfo]: [string, RawThreadInfo]) => ({
type: 'replace',
payload: {
id,
threadInfo,
},
}));
await processDBStoreOperations({
threadStoreOperations,
draftStoreOperations: [],
messageStoreOperations: [],
reportStoreOperations: [],
userStoreOperations: [],
});
}
dispatch({ type: setInitialReduxState, payload });
} catch (err) {
setInitError(err);
}
})();
}
}, [callGetInitialReduxState, dispatch, isRehydrated]);
const initialStateLoaded = useSelector(state => state.initialStateLoaded);
const childFunction = React.useCallback(
// This argument is passed from `PersistGate`. It means that the state is
// rehydrated and we can start fetching the initial info.
- bootstrapped => {
+ (bootstrapped: boolean) => {
if (bootstrapped && initialStateLoaded) {
return children;
} else {
return ;
}
},
[children, initialStateLoaded],
);
return {childFunction} ;
}
export default InitialReduxStateGate;
diff --git a/web/redux/persist.js b/web/redux/persist.js
index 57528e30b..f5c0111fc 100644
--- a/web/redux/persist.js
+++ b/web/redux/persist.js
@@ -1,318 +1,318 @@
// @flow
import invariant from 'invariant';
import {
getStoredState,
purgeStoredState,
createTransform,
} from 'redux-persist';
import storage from 'redux-persist/es/storage/index.js';
import type { Transform } from 'redux-persist/es/types.js';
import type { PersistConfig } from 'redux-persist/src/types.js';
import {
createAsyncMigrate,
type StorageMigrationFunction,
} from 'lib/shared/create-async-migrate.js';
import type {
KeyserverInfo,
KeyserverStore,
} from 'lib/types/keyserver-types.js';
import { cookieTypes } from 'lib/types/session-types.js';
import {
defaultConnectionInfo,
type ConnectionInfo,
} from 'lib/types/socket-types.js';
import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js';
import { parseCookies } from 'lib/utils/cookie-utils.js';
import { isDev } from 'lib/utils/dev-utils.js';
import {
generateIDSchemaMigrationOpsForDrafts,
convertDraftStoreToNewIDSchema,
} from 'lib/utils/migration-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import commReduxStorageEngine from './comm-redux-storage-engine.js';
import type { AppState } from './redux-setup.js';
import { getDatabaseModule } from '../database/database-module-provider.js';
import { isSQLiteSupported } from '../database/utils/db-utils.js';
import { workerRequestMessageTypes } from '../types/worker-types.js';
declare var keyserverURL: string;
const migrations = {
- [1]: async state => {
+ [1]: async (state: any) => {
const {
primaryIdentityPublicKey,
...stateWithoutPrimaryIdentityPublicKey
} = state;
return {
...stateWithoutPrimaryIdentityPublicKey,
cryptoStore: {
primaryAccount: null,
primaryIdentityKeys: null,
notificationAccount: null,
notificationIdentityKeys: null,
},
};
},
- [2]: async state => {
+ [2]: async (state: AppState) => {
return state;
},
[3]: async (state: AppState) => {
let newState = state;
if (state.draftStore) {
newState = {
...newState,
draftStore: convertDraftStoreToNewIDSchema(state.draftStore),
};
}
const databaseModule = await getDatabaseModule();
const isDatabaseSupported = await databaseModule.isDatabaseSupported();
if (!isDatabaseSupported) {
return newState;
}
const stores = await databaseModule.schedule({
type: workerRequestMessageTypes.GET_CLIENT_STORE,
});
invariant(stores?.store, 'Stores should exist');
await databaseModule.schedule({
type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS,
storeOperations: {
draftStoreOperations: generateIDSchemaMigrationOpsForDrafts(
stores.store.drafts,
),
},
});
return newState;
},
- [4]: async state => {
+ [4]: async (state: any) => {
const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state;
return {
...rest,
keyserverStore: {
...keyserverStore,
keyserverInfos: {
...keyserverStore.keyserverInfos,
[ashoatKeyserverID]: {
...keyserverStore.keyserverInfos[ashoatKeyserverID],
lastCommunicatedPlatformDetails,
},
},
},
};
},
- [5]: async state => {
+ [5]: async (state: any) => {
const databaseModule = await getDatabaseModule();
const isDatabaseSupported = await databaseModule.isDatabaseSupported();
if (!isDatabaseSupported) {
return state;
}
if (!state.draftStore) {
return state;
}
const { drafts } = state.draftStore;
const draftStoreOperations = [];
for (const key in drafts) {
const text = drafts[key];
draftStoreOperations.push({
type: 'update',
payload: { key, text },
});
}
await databaseModule.schedule({
type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS,
storeOperations: { draftStoreOperations },
});
return state;
},
- [6]: async state => ({
+ [6]: async (state: AppState) => ({
...state,
integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' },
}),
[7]: async (state: AppState): Promise => {
if (!document.cookie) {
return state;
}
const params = parseCookies(document.cookie);
let cookie = null;
if (params[cookieTypes.USER]) {
cookie = `${cookieTypes.USER}=${params[cookieTypes.USER]}`;
} else if (params[cookieTypes.ANONYMOUS]) {
cookie = `${cookieTypes.ANONYMOUS}=${params[cookieTypes.ANONYMOUS]}`;
}
return {
...state,
keyserverStore: {
...state.keyserverStore,
keyserverInfos: {
...state.keyserverStore.keyserverInfos,
[ashoatKeyserverID]: {
...state.keyserverStore.keyserverInfos[ashoatKeyserverID],
cookie,
},
},
},
};
},
- [8]: async state => ({
+ [8]: async (state: AppState) => ({
...state,
globalThemeInfo: defaultGlobalThemeInfo,
}),
- [9]: async state => ({
+ [9]: async (state: AppState) => ({
...state,
keyserverStore: {
...state.keyserverStore,
keyserverInfos: {
...state.keyserverStore.keyserverInfos,
[ashoatKeyserverID]: {
...state.keyserverStore.keyserverInfos[ashoatKeyserverID],
urlPrefix: keyserverURL,
},
},
},
}),
- [10]: async state => {
+ [10]: async (state: AppState) => {
const { keyserverInfos } = state.keyserverStore;
const newKeyserverInfos = {};
for (const key in keyserverInfos) {
newKeyserverInfos[key] = {
...keyserverInfos[key],
connection: { ...defaultConnectionInfo },
updatesCurrentAsOf: 0,
sessionID: null,
};
}
return {
...state,
keyserverStore: {
...state.keyserverStore,
keyserverInfos: newKeyserverInfos,
},
};
},
};
const persistWhitelist = [
'enabledApps',
'cryptoStore',
'notifPermissionAlertInfo',
'commServicesAccessToken',
'keyserverStore',
'globalThemeInfo',
];
const rootKey = 'root';
const migrateStorageToSQLite: StorageMigrationFunction = async debug => {
const databaseModule = await getDatabaseModule();
const isSupported = await databaseModule.isDatabaseSupported();
if (!isSupported) {
return undefined;
}
const oldStorage = await getStoredState({ storage, key: rootKey });
if (!oldStorage) {
return undefined;
}
purgeStoredState({ storage, key: rootKey });
if (debug) {
console.log('redux-persist: migrating state to SQLite storage');
}
// We need to simulate the keyserverStoreTransform for data stored in the
// old local storage (because redux persist will only run it for the
// sqlite storage which is empty in this case).
// We don't just use keyserverStoreTransform.out(oldStorage) because
// the transform might change in the future, but we need to treat
// this code like migration code (it shouldn't change).
if (oldStorage?._persist?.version === 4) {
const defaultConnection = defaultConnectionInfo;
return {
...oldStorage,
keyserverStore: {
...oldStorage.keyserverStore,
keyserverInfos: {
...oldStorage.keyserverStore.keyserverInfos,
[ashoatKeyserverID]: {
...oldStorage.keyserverStore.keyserverInfos[ashoatKeyserverID],
connection: { ...defaultConnection },
updatesCurrentAsOf: 0,
sessionID: null,
},
},
},
};
}
return oldStorage;
};
type PersistedKeyserverInfo = $Diff<
KeyserverInfo,
{
+connection: ConnectionInfo,
+updatesCurrentAsOf: number,
+sessionID?: ?string,
},
>;
type PersistedKeyserverStore = {
+keyserverInfos: { +[key: string]: PersistedKeyserverInfo },
};
const keyserverStoreTransform: Transform = createTransform(
(state: KeyserverStore): PersistedKeyserverStore => {
const keyserverInfos = {};
for (const key in state.keyserverInfos) {
const { connection, sessionID, ...rest } = state.keyserverInfos[key];
keyserverInfos[key] = rest;
}
return {
...state,
keyserverInfos,
};
},
(state: PersistedKeyserverStore): KeyserverStore => {
const keyserverInfos = {};
for (const key in state.keyserverInfos) {
keyserverInfos[key] = {
...state.keyserverInfos[key],
connection: { ...defaultConnectionInfo },
sessionID: null,
};
}
return {
...state,
keyserverInfos,
};
},
{ whitelist: ['keyserverStore'] },
);
const persistConfig: PersistConfig = {
key: rootKey,
storage: commReduxStorageEngine,
whitelist: isSQLiteSupported()
? persistWhitelist
: [...persistWhitelist, 'draftStore'],
migrate: (createAsyncMigrate(
migrations,
{ debug: isDev },
migrateStorageToSQLite,
): any),
version: 10,
transforms: [keyserverStoreTransform],
};
export { persistConfig };
diff --git a/web/redux/visibility-handler.react.js b/web/redux/visibility-handler.react.js
index 9ed07a4e4..3e71d5807 100644
--- a/web/redux/visibility-handler.react.js
+++ b/web/redux/visibility-handler.react.js
@@ -1,57 +1,60 @@
// @flow
import * as React from 'react';
import { updateLifecycleStateActionType } from 'lib/reducers/lifecycle-state-reducer.js';
import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { useVisibility } from './visibility.js';
function VisibilityHandler(): React.Node {
const visibility = useVisibility();
const [visible, setVisible] = React.useState(!visibility.hidden());
- const onVisibilityChange = React.useCallback((event, state: string) => {
- setVisible(state === 'visible');
- }, []);
+ const onVisibilityChange = React.useCallback(
+ (event: mixed, state: string) => {
+ setVisible(state === 'visible');
+ },
+ [],
+ );
React.useEffect(() => {
const listener = visibility.change(onVisibilityChange);
return () => {
visibility.unbind(listener);
};
}, [visibility, onVisibilityChange]);
const dispatch = useDispatch();
const curForeground = useIsAppForegrounded();
const updateRedux = React.useCallback(
- foreground => {
+ (foreground: boolean) => {
if (foreground === curForeground) {
return;
}
if (foreground) {
dispatch({ type: updateLifecycleStateActionType, payload: 'active' });
} else {
dispatch({
type: updateLifecycleStateActionType,
payload: 'background',
});
}
},
[dispatch, curForeground],
);
const prevVisibleRef = React.useRef(curForeground);
React.useEffect(() => {
const prevVisible = prevVisibleRef.current;
if (visible && !prevVisible) {
updateRedux(true);
} else if (!visible && prevVisible) {
updateRedux(false);
}
prevVisibleRef.current = visible;
}, [visible, updateRedux]);
return null;
}
export default VisibilityHandler;
diff --git a/web/settings/password-change-modal.js b/web/settings/password-change-modal.js
index 2c6de73ce..1ddf85c97 100644
--- a/web/settings/password-change-modal.js
+++ b/web/settings/password-change-modal.js
@@ -1,262 +1,262 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
changeUserPasswordActionTypes,
changeUserPassword,
} from 'lib/actions/user-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { useStringForUser } from 'lib/hooks/ens-cache.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { type PasswordUpdate } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import css from './password-change-modal.css';
import Button from '../components/button.react.js';
import Input from '../modals/input.react.js';
import Modal from '../modals/modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
+inputDisabled: boolean,
+dispatchActionPromise: DispatchActionPromise,
+changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise,
+popModal: () => void,
+stringForUser: ?string,
};
type State = {
+newPassword: string,
+confirmNewPassword: string,
+currentPassword: string,
+errorMessage: string,
};
class PasswordChangeModal extends React.PureComponent {
newPasswordInput: ?HTMLInputElement;
currentPasswordInput: ?HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
newPassword: '',
confirmNewPassword: '',
currentPassword: '',
errorMessage: '',
};
}
componentDidMount() {
invariant(this.newPasswordInput, 'newPasswordInput ref unset');
this.newPasswordInput.focus();
}
- render() {
+ render(): React.Node {
let errorMsg;
if (this.state.errorMessage) {
errorMsg = (
{this.state.errorMessage}
);
}
const { inputDisabled } = this.props;
return (
);
}
newPasswordInputRef = (newPasswordInput: ?HTMLInputElement) => {
this.newPasswordInput = newPasswordInput;
};
currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => {
this.currentPasswordInput = currentPasswordInput;
};
onChangeNewPassword = (event: SyntheticEvent) => {
const target = event.target;
invariant(target instanceof HTMLInputElement, 'target not input');
this.setState({ newPassword: target.value });
};
onChangeConfirmNewPassword = (event: SyntheticEvent) => {
const target = event.target;
invariant(target instanceof HTMLInputElement, 'target not input');
this.setState({ confirmNewPassword: target.value });
};
onChangeCurrentPassword = (event: SyntheticEvent) => {
const target = event.target;
invariant(target instanceof HTMLInputElement, 'target not input');
this.setState({ currentPassword: target.value });
};
onSubmit = (event: SyntheticEvent) => {
event.preventDefault();
if (this.state.newPassword === '') {
this.setState(
{
newPassword: '',
confirmNewPassword: '',
errorMessage: 'empty password',
},
() => {
invariant(this.newPasswordInput, 'newPasswordInput ref unset');
this.newPasswordInput.focus();
},
);
} else if (this.state.newPassword !== this.state.confirmNewPassword) {
this.setState(
{
newPassword: '',
confirmNewPassword: '',
errorMessage: 'passwords don’t match',
},
() => {
invariant(this.newPasswordInput, 'newPasswordInput ref unset');
this.newPasswordInput.focus();
},
);
return;
}
this.props.dispatchActionPromise(
changeUserPasswordActionTypes,
this.changeUserSettingsAction(),
);
};
- async changeUserSettingsAction() {
+ async changeUserSettingsAction(): Promise {
try {
await this.props.changeUserPassword({
updatedFields: {
password: this.state.newPassword,
},
currentPassword: this.state.currentPassword,
});
this.props.popModal();
} catch (e) {
if (e.message === 'invalid_credentials') {
this.setState(
{
currentPassword: '',
errorMessage: 'wrong current password',
},
() => {
invariant(
this.currentPasswordInput,
'currentPasswordInput ref unset',
);
this.currentPasswordInput.focus();
},
);
} else {
this.setState(
{
newPassword: '',
confirmNewPassword: '',
currentPassword: '',
errorMessage: 'unknown error',
},
() => {
invariant(this.newPasswordInput, 'newPasswordInput ref unset');
this.newPasswordInput.focus();
},
);
}
throw e;
}
}
}
const changeUserPasswordLoadingStatusSelector = createLoadingStatusSelector(
changeUserPasswordActionTypes,
);
const ConnectedPasswordChangeModal: React.ComponentType<{}> = React.memo<{}>(
function ConnectedPasswordChangeModal(): React.Node {
const inputDisabled = useSelector(
state => changeUserPasswordLoadingStatusSelector(state) === 'loading',
);
const callChangeUserPassword = useServerCall(changeUserPassword);
const dispatchActionPromise = useDispatchActionPromise();
const modalContext = useModalContext();
const currentUserInfo = useSelector(state => state.currentUserInfo);
const stringForUser = useStringForUser(currentUserInfo);
return (
);
},
);
export default ConnectedPasswordChangeModal;
diff --git a/web/settings/tunnelbroker-test.react.js b/web/settings/tunnelbroker-test.react.js
index 47a3649ca..67b19aa8d 100644
--- a/web/settings/tunnelbroker-test.react.js
+++ b/web/settings/tunnelbroker-test.react.js
@@ -1,94 +1,94 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { type ClientMessageToDevice } from 'lib/tunnelbroker/tunnelbroker-context.js';
import css from './tunnelbroker-test.css';
import Button from '../components/button.react.js';
import Input from '../modals/input.react.js';
import Modal from '../modals/modal.react.js';
type Props = {
+sendMessage: (message: ClientMessageToDevice) => Promise,
+onClose: () => void,
};
function TunnelbrokerTestScreen(props: Props): React.Node {
const { sendMessage, onClose } = props;
const [recipient, setRecipient] = React.useState('');
const [message, setMessage] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const recipientInput = React.useRef(null);
const messageInput = React.useRef(null);
const onSubmit = React.useCallback(
- async event => {
+ async (event: SyntheticEvent) => {
event.preventDefault();
setLoading(true);
try {
await sendMessage({ deviceID: recipient, payload: message });
} catch (e) {
setErrorMessage(e.message);
}
setLoading(false);
},
[message, recipient, sendMessage],
);
let errorMsg;
if (errorMessage) {
errorMsg = {errorMessage}
;
}
return (
);
}
export default TunnelbrokerTestScreen;
diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js
index c99f3504f..08ad34cad 100644
--- a/web/utils/tooltip-action-utils.js
+++ b/web/utils/tooltip-action-utils.js
@@ -1,486 +1,486 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { useResettingState } from 'lib/hooks/use-resetting-state.js';
import type {
ReactionInfo,
ChatMessageInfoItem,
} from 'lib/selectors/chat-selectors.js';
import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js';
import { createMessageReply } from 'lib/shared/message-utils.js';
import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js';
import {
threadHasPermission,
useSidebarExistsOrCanBeCreated,
} from 'lib/shared/thread-utils.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { longAbsoluteDate } from 'lib/utils/date-utils.js';
import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js';
import {
type MessageTooltipAction,
getTooltipPositionStyle,
calculateMessageTooltipSize,
calculateReactionTooltipSize,
type TooltipPosition,
type TooltipPositionStyle,
type TooltipSize,
} from './tooltip-utils.js';
import { getComposedMessageID } from '../chat/chat-constants.js';
import { useEditModalContext } from '../chat/edit-message-provider.js';
import MessageTooltip from '../chat/message-tooltip.react.js';
import ReactionTooltip from '../chat/reaction-tooltip.react.js';
import { useTooltipContext } from '../chat/tooltip-provider.js';
import CommIcon from '../CommIcon.react.js';
import { InputStateContext } from '../input/input-state.js';
import TogglePinModal from '../modals/chat/toggle-pin-modal.react.js';
import {
useOnClickPendingSidebar,
useOnClickThread,
} from '../selectors/thread-selectors.js';
type UseTooltipArgs = {
+createTooltip: (tooltipPositionStyle: TooltipPositionStyle) => React.Node,
+tooltipSize: TooltipSize,
+availablePositions: $ReadOnlyArray,
};
type UseTooltipResult = {
+onMouseEnter: (event: SyntheticEvent) => mixed,
+onMouseLeave: ?() => mixed,
};
function useTooltip({
createTooltip,
tooltipSize,
availablePositions,
}: UseTooltipArgs): UseTooltipResult {
const [onMouseLeave, setOnMouseLeave] = React.useState() => mixed>(null);
const [tooltipSourcePosition, setTooltipSourcePosition] = React.useState();
const { renderTooltip } = useTooltipContext();
const updateTooltip = React.useRef();
const onMouseEnter = React.useCallback(
(event: SyntheticEvent) => {
if (!renderTooltip) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const { top, bottom, left, right, height, width } = rect;
const sourcePosition = { top, bottom, left, right, height, width };
setTooltipSourcePosition(sourcePosition);
const tooltipPositionStyle = getTooltipPositionStyle({
tooltipSourcePosition: sourcePosition,
tooltipSize,
availablePositions,
});
if (!tooltipPositionStyle) {
return;
}
const tooltip = createTooltip(tooltipPositionStyle);
const renderTooltipResult = renderTooltip({
newNode: tooltip,
tooltipPositionStyle,
});
if (renderTooltipResult) {
const { onMouseLeaveCallback: callback } = renderTooltipResult;
setOnMouseLeave((() => callback: () => () => mixed));
updateTooltip.current = renderTooltipResult.updateTooltip;
}
},
[availablePositions, createTooltip, renderTooltip, tooltipSize],
);
React.useEffect(() => {
if (!updateTooltip.current) {
return;
}
const tooltipPositionStyle = getTooltipPositionStyle({
tooltipSourcePosition,
tooltipSize,
availablePositions,
});
if (!tooltipPositionStyle) {
return;
}
const tooltip = createTooltip(tooltipPositionStyle);
updateTooltip.current?.(tooltip);
}, [availablePositions, createTooltip, tooltipSize, tooltipSourcePosition]);
return {
onMouseEnter,
onMouseLeave,
};
}
function useMessageTooltipSidebarAction(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): ?MessageTooltipAction {
const { threadCreatedFromMessage, messageInfo } = item;
const { popModal } = useModalContext();
const sidebarExists = !!threadCreatedFromMessage;
const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated(
threadInfo,
item,
);
const openThread = useOnClickThread(threadCreatedFromMessage);
const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo);
return React.useMemo(() => {
if (!sidebarExistsOrCanBeCreated) {
return null;
}
const buttonContent = ;
const onClick = (event: SyntheticEvent) => {
popModal();
if (threadCreatedFromMessage) {
openThread(event);
} else {
openPendingSidebar(event);
}
};
return {
actionButtonContent: buttonContent,
onClick,
label: sidebarExists ? 'Go to thread' : 'Create thread',
};
}, [
popModal,
openPendingSidebar,
openThread,
sidebarExists,
sidebarExistsOrCanBeCreated,
threadCreatedFromMessage,
]);
}
function useMessageTooltipReplyAction(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const { popModal } = useModalContext();
const inputState = React.useContext(InputStateContext);
invariant(inputState, 'inputState is required');
const { addReply } = inputState;
return React.useMemo(() => {
if (
item.messageInfo.type !== messageTypes.TEXT ||
!threadHasPermission(threadInfo, threadPermissions.VOICED)
) {
return null;
}
const buttonContent = ;
const onClick = () => {
popModal();
if (!messageInfo.text) {
return;
}
addReply(createMessageReply(messageInfo.text));
};
return {
actionButtonContent: buttonContent,
onClick,
label: 'Reply',
};
}, [popModal, addReply, item.messageInfo.type, messageInfo, threadInfo]);
}
const copiedMessageDurationMs = 2000;
function useMessageCopyAction(
item: ChatMessageInfoItem,
): ?MessageTooltipAction {
const { messageInfo } = item;
const [successful, setSuccessful] = useResettingState(
false,
copiedMessageDurationMs,
);
return React.useMemo(() => {
if (messageInfo.type !== messageTypes.TEXT) {
return null;
}
const buttonContent = ;
const onClick = async () => {
try {
await navigator.clipboard.writeText(messageInfo.text);
setSuccessful(true);
} catch (e) {
setSuccessful(false);
}
};
return {
actionButtonContent: buttonContent,
onClick,
label: successful ? 'Copied!' : 'Copy',
};
}, [messageInfo.text, messageInfo.type, setSuccessful, successful]);
}
function useMessageReactAction(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const { setShouldRenderEmojiKeyboard } = useTooltipContext();
const canCreateReactionFromMessage = useCanCreateReactionFromMessage(
threadInfo,
messageInfo,
);
return React.useMemo(() => {
if (!canCreateReactionFromMessage) {
return null;
}
const buttonContent = ;
const onClickReact = () => {
if (!setShouldRenderEmojiKeyboard) {
return;
}
setShouldRenderEmojiKeyboard(true);
};
return {
actionButtonContent: buttonContent,
onClick: onClickReact,
label: 'React',
};
}, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]);
}
function useMessageTogglePinAction(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): ?MessageTooltipAction {
const { pushModal } = useModalContext();
const { messageInfo, isPinned } = item;
const canTogglePin = canToggleMessagePin(messageInfo, threadInfo);
const inputState = React.useContext(InputStateContext);
return React.useMemo(() => {
if (!canTogglePin) {
return null;
}
const iconName = isPinned ? 'unpin' : 'pin';
const buttonContent = ;
const onClickTogglePin = () => {
pushModal(
,
);
};
return {
actionButtonContent: buttonContent,
onClick: onClickTogglePin,
label: isPinned ? 'Unpin' : 'Pin',
};
}, [canTogglePin, inputState, isPinned, pushModal, item, threadInfo]);
}
function useMessageEditAction(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): ?MessageTooltipAction {
const { messageInfo } = item;
const canEditMessage = useCanEditMessage(threadInfo, messageInfo);
const { renderEditModal, scrollToMessage } = useEditModalContext();
const { clearTooltip } = useTooltipContext();
return React.useMemo(() => {
if (!canEditMessage) {
return null;
}
const buttonContent = ;
const onClickEdit = () => {
const callback = (maxHeight: number) =>
renderEditModal({
messageInfo: item,
threadInfo,
isError: false,
editedMessageDraft: messageInfo.text,
maxHeight: maxHeight,
});
clearTooltip();
scrollToMessage(getComposedMessageID(messageInfo), callback);
};
return {
actionButtonContent: buttonContent,
onClick: onClickEdit,
label: 'Edit',
};
}, [
canEditMessage,
clearTooltip,
item,
messageInfo,
renderEditModal,
scrollToMessage,
threadInfo,
]);
}
function useMessageTooltipActions(
item: ChatMessageInfoItem,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): $ReadOnlyArray {
const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo);
const replyAction = useMessageTooltipReplyAction(item, threadInfo);
const copyAction = useMessageCopyAction(item);
const reactAction = useMessageReactAction(item, threadInfo);
const togglePinAction = useMessageTogglePinAction(item, threadInfo);
const editAction = useMessageEditAction(item, threadInfo);
return React.useMemo(
() =>
[
replyAction,
sidebarAction,
copyAction,
reactAction,
togglePinAction,
editAction,
].filter(Boolean),
[
replyAction,
sidebarAction,
copyAction,
reactAction,
togglePinAction,
editAction,
],
);
}
const undefinedTooltipSize = {
width: 0,
height: 0,
};
type UseMessageTooltipArgs = {
+availablePositions: $ReadOnlyArray,
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
function useMessageTooltip({
availablePositions,
item,
threadInfo,
}: UseMessageTooltipArgs): UseTooltipResult {
const tooltipActions = useMessageTooltipActions(item, threadInfo);
const messageTimestamp = React.useMemo(() => {
const time = item.messageInfo.time;
return longAbsoluteDate(time);
}, [item.messageInfo.time]);
const tooltipSize = React.useMemo(() => {
if (typeof document === 'undefined') {
return undefinedTooltipSize;
}
const tooltipLabels = tooltipActions.map(action => action.label);
return calculateMessageTooltipSize({
tooltipLabels,
timestamp: messageTimestamp,
});
}, [messageTimestamp, tooltipActions]);
const createMessageTooltip = React.useCallback(
- tooltipPositionStyle => (
+ (tooltipPositionStyle: TooltipPositionStyle) => (
),
[item, messageTimestamp, threadInfo, tooltipActions, tooltipSize],
);
const { onMouseEnter, onMouseLeave } = useTooltip({
createTooltip: createMessageTooltip,
tooltipSize,
availablePositions,
});
return {
onMouseEnter,
onMouseLeave,
};
}
type UseReactionTooltipArgs = {
+reaction: string,
+reactions: ReactionInfo,
+availablePositions: $ReadOnlyArray,
};
function useReactionTooltip({
reaction,
reactions,
availablePositions,
}: UseReactionTooltipArgs): UseTooltipResult {
const { users } = reactions[reaction];
const tooltipSize = React.useMemo(() => {
if (typeof document === 'undefined') {
return undefinedTooltipSize;
}
const usernames = users.map(user => user.username).filter(Boolean);
return calculateReactionTooltipSize(usernames);
}, [users]);
const createReactionTooltip = React.useCallback(
() => ,
[reaction, reactions],
);
const { onMouseEnter, onMouseLeave } = useTooltip({
createTooltip: createReactionTooltip,
tooltipSize,
availablePositions,
});
return {
onMouseEnter,
onMouseLeave,
};
}
export {
useMessageTooltipSidebarAction,
useMessageTooltipReplyAction,
useMessageReactAction,
useMessageTooltipActions,
useMessageTooltip,
useReactionTooltip,
};
diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js
index 4e67d4b0c..db3d6404b 100644
--- a/web/utils/typeahead-utils.js
+++ b/web/utils/typeahead-utils.js
@@ -1,275 +1,282 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import {
getNewTextAndSelection,
type MentionTypeaheadSuggestionItem,
type TypeaheadTooltipActionItem,
getRawChatMention,
} from 'lib/shared/mention-utils.js';
import { stringForUserExplicit } from 'lib/shared/user-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
import { validChatNameRegexString } from 'lib/utils/validation-utils.js';
import ThreadAvatar from '../avatars/thread-avatar.react.js';
import UserAvatar from '../avatars/user-avatar.react.js';
import { typeaheadStyle } from '../chat/chat-constants.js';
import css from '../chat/typeahead-tooltip.css';
import Button from '../components/button.react.js';
const webMentionTypeaheadRegex: RegExp = new RegExp(
`(?(?:^(?:.|\n)*\\s+)|^)@(?${validChatNameRegexString})?$`,
);
export type TooltipPosition = {
+top: number,
+left: number,
};
function getCaretOffsets(
textarea: HTMLTextAreaElement,
text: string,
): { caretTopOffset: number, caretLeftOffset: number } {
if (!textarea) {
return { caretTopOffset: 0, caretLeftOffset: 0 };
}
// terribly hacky but it works I guess :D
// we had to use it, as it's hard to count lines in textarea
// and track cursor position within it as
// lines can be wrapped into new lines without \n character
// as result of overflow
const textareaStyle: CSSStyleDeclaration = window.getComputedStyle(
textarea,
null,
);
const div = document.createElement('div');
for (const styleName of textareaStyle) {
div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName));
}
div.style.display = 'inline-block';
div.style.position = 'absolute';
div.textContent = text;
const span = document.createElement('span');
span.textContent = textarea.value.slice(text.length);
div.appendChild(span);
document.body?.appendChild(div);
const { offsetTop, offsetLeft } = span;
document.body?.removeChild(div);
const textareaWidth = parseInt(textareaStyle.getPropertyValue('width'));
const caretLeftOffset =
offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth
? textareaWidth - typeaheadStyle.tooltipWidth
: offsetLeft;
return {
caretTopOffset: offsetTop - textarea.scrollTop,
caretLeftOffset,
};
}
-export type GetTypeaheadTooltipActionsParams = {
+type MentionTypeaheadSharedParams = {
+inputStateDraft: string,
+inputStateSetDraft: (draft: string) => mixed,
+inputStateSetTextCursorPosition: (newPosition: number) => mixed,
- +suggestions: $ReadOnlyArray,
+textBeforeAtSymbol: string,
+query: string,
};
+export type GetTypeaheadTooltipActionsParams = {
+ ...MentionTypeaheadSharedParams,
+ +suggestions: $ReadOnlyArray,
+};
+export type MentionTypeaheadTooltipActionExecuteHandlerParams = {
+ ...MentionTypeaheadSharedParams,
+ +mentionText: string,
+};
function mentionTypeaheadTooltipActionExecuteHandler({
textBeforeAtSymbol,
inputStateDraft,
query,
mentionText,
inputStateSetDraft,
inputStateSetTextCursorPosition,
-}) {
+}: MentionTypeaheadTooltipActionExecuteHandlerParams) {
const { newText, newSelectionStart } = getNewTextAndSelection(
textBeforeAtSymbol,
inputStateDraft,
query,
mentionText,
);
inputStateSetDraft(newText);
inputStateSetTextCursorPosition(newSelectionStart);
}
function getMentionTypeaheadTooltipActions(
params: GetTypeaheadTooltipActionsParams,
): $ReadOnlyArray> {
const {
inputStateDraft,
inputStateSetDraft,
inputStateSetTextCursorPosition,
suggestions,
textBeforeAtSymbol,
query,
} = params;
const actions = [];
for (const suggestion of suggestions) {
if (suggestion.type === 'user') {
const suggestedUser = suggestion.userInfo;
if (stringForUserExplicit(suggestedUser) === 'anonymous') {
continue;
}
const mentionText = `@${stringForUserExplicit(suggestedUser)}`;
actions.push({
key: suggestedUser.id,
execute: () =>
mentionTypeaheadTooltipActionExecuteHandler({
textBeforeAtSymbol,
inputStateDraft,
query,
mentionText,
inputStateSetDraft,
inputStateSetTextCursorPosition,
}),
actionButtonContent: {
type: 'user',
userInfo: suggestedUser,
},
});
} else if (suggestion.type === 'chat') {
const suggestedChat = suggestion.threadInfo;
const mentionText = getRawChatMention(suggestedChat);
actions.push({
key: suggestedChat.id,
execute: () =>
mentionTypeaheadTooltipActionExecuteHandler({
textBeforeAtSymbol,
inputStateDraft,
query,
mentionText,
inputStateSetDraft,
inputStateSetTextCursorPosition,
}),
actionButtonContent: {
type: 'chat',
threadInfo: suggestedChat,
},
});
}
}
return actions;
}
export type GetMentionTypeaheadTooltipButtonsParams = {
+setChosenPositionInOverlay: SetState,
+chosenPositionInOverlay: number,
+actions: $ReadOnlyArray>,
};
function getMentionTypeaheadTooltipButtons(
params: GetMentionTypeaheadTooltipButtonsParams,
): $ReadOnlyArray {
const { setChosenPositionInOverlay, chosenPositionInOverlay, actions } =
params;
return actions.map((action, idx) => {
const { key, execute, actionButtonContent } = action;
const buttonClasses = classNames(css.suggestion, {
[css.suggestionHover]: idx === chosenPositionInOverlay,
});
const onMouseMove: (
event: SyntheticEvent,
) => mixed = () => {
setChosenPositionInOverlay(idx);
};
let avatarComponent = null;
let typeaheadButtonText = null;
if (actionButtonContent.type === 'user') {
const suggestedUser = actionButtonContent.userInfo;
avatarComponent = (
);
typeaheadButtonText = `@${stringForUserExplicit(suggestedUser)}`;
} else if (actionButtonContent.type === 'chat') {
const suggestedChat = actionButtonContent.threadInfo;
avatarComponent = (
);
typeaheadButtonText = `@${suggestedChat.uiName}`;
}
return (
{avatarComponent}
{typeaheadButtonText}
);
});
}
function getTypeaheadOverlayScroll(
currentScrollTop: number,
chosenActionPosition: number,
): number {
const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight;
const lowerButtonBoundary =
(chosenActionPosition + 1) * typeaheadStyle.rowHeight;
if (upperButtonBoundary < currentScrollTop) {
return upperButtonBoundary;
} else if (
lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight >
currentScrollTop
) {
return (
lowerButtonBoundary +
typeaheadStyle.tooltipVerticalPadding -
typeaheadStyle.tooltipMaxHeight
);
}
return currentScrollTop;
}
function getTypeaheadTooltipPosition(
textarea: HTMLTextAreaElement,
actionsLength: number,
textBeforeAtSymbol: string,
): TooltipPosition {
const { caretTopOffset, caretLeftOffset } = getCaretOffsets(
textarea,
textBeforeAtSymbol,
);
const textareaBoundingClientRect = textarea.getBoundingClientRect();
const top: number =
textareaBoundingClientRect.top -
Math.min(
typeaheadStyle.tooltipVerticalPadding +
actionsLength * typeaheadStyle.rowHeight,
typeaheadStyle.tooltipMaxHeight,
) -
typeaheadStyle.tooltipTopOffset +
caretTopOffset;
const left: number =
textareaBoundingClientRect.left -
typeaheadStyle.tooltipLeftOffset +
caretLeftOffset;
return { top, left };
}
export {
webMentionTypeaheadRegex,
getCaretOffsets,
getMentionTypeaheadTooltipActions,
getMentionTypeaheadTooltipButtons,
getTypeaheadOverlayScroll,
getTypeaheadTooltipPosition,
};