(false);
const onSIWEButtonClick = React.useCallback(() => {
setSIWEAuthFlowSelected(true);
openConnectModal && openConnectModal();
}, [openConnectModal]);
const cancelSIWEAuthFlow = React.useCallback(() => {
setSIWEAuthFlowSelected(false);
}, []);
if (siweAuthFlowSelected && signer) {
return (
);
}
return (
{qrCodeLoginButton}
);
}
export default LoginForm;
diff --git a/web/account/siwe-login-form.react.js b/web/account/siwe-login-form.react.js
index 4162fc733..4bc6624f8 100644
--- a/web/account/siwe-login-form.react.js
+++ b/web/account/siwe-login-form.react.js
@@ -1,265 +1,265 @@
// @flow
import '@rainbow-me/rainbowkit/styles.css';
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
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 { 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) => {
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/app.react.js b/web/app.react.js
index 0ddace9c2..590d25d33 100644
--- a/web/app.react.js
+++ b/web/app.react.js
@@ -1,401 +1,401 @@
// @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 { useDispatch } from 'react-redux';
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() {
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;
renderLoginPage() {
const { loginMethod } = this.props.navInfo;
if (loginMethod === 'qr-code') {
return ;
}
return ;
}
renderMainContent() {
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() {
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/calendar/day.react.js b/web/calendar/day.react.js
index 95883a335..111c7a1bf 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 { useDispatch } from 'react-redux';
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() {
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/entry.react.js b/web/calendar/entry.react.js
index 2dace3b53..9ba7115c7 100644
--- a/web/calendar/entry.react.js
+++ b/web/calendar/entry.react.js
@@ -1,507 +1,507 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import {
createEntryActionTypes,
useCreateEntry,
saveEntryActionTypes,
useSaveEntry,
deleteEntryActionTypes,
useDeleteEntry,
concurrentModificationResetActionType,
} from 'lib/actions/entry-actions.js';
import {
useModalContext,
type PushModal,
} from 'lib/components/modal-provider.react.js';
import { connectionSelector } from 'lib/selectors/keyserver-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { colorIsDark } from 'lib/shared/color-utils.js';
import { entryKey } from 'lib/shared/entry-utils.js';
import { threadHasPermission } from 'lib/shared/thread-utils.js';
import type { Shape } from 'lib/types/core.js';
import {
type EntryInfo,
type CreateEntryInfo,
type SaveEntryInfo,
type SaveEntryResult,
type SaveEntryPayload,
type CreateEntryPayload,
type DeleteEntryInfo,
type DeleteEntryResult,
type CalendarQuery,
} 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 { Dispatch } from 'lib/types/redux-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import type { ResolvedThreadInfo } from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { dateString } from 'lib/utils/date-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import { ServerError } from 'lib/utils/errors.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import css from './calendar.css';
import LoadingIndicator from '../loading-indicator.react.js';
import LogInFirstModal from '../modals/account/log-in-first-modal.react.js';
import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react.js';
import HistoryModal from '../modals/history/history-modal.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
import { HistoryVector, DeleteVector } from '../vectors.react.js';
type BaseProps = {
+innerRef: (key: string, me: Entry) => void,
+entryInfo: EntryInfo,
+focusOnFirstEntryNewerThan: (time: number) => void,
+tabIndex: number,
};
type Props = {
...BaseProps,
+threadInfo: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo,
+loggedIn: boolean,
+calendarQuery: () => CalendarQuery,
+online: boolean,
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
+createEntry: (info: CreateEntryInfo) => Promise,
+saveEntry: (info: SaveEntryInfo) => Promise,
+deleteEntry: (info: DeleteEntryInfo) => Promise,
+pushModal: PushModal,
+popModal: () => void,
};
type State = {
+focused: boolean,
+loadingStatus: LoadingStatus,
+text: string,
};
class Entry extends React.PureComponent {
textarea: ?HTMLTextAreaElement;
creating: boolean;
needsUpdateAfterCreation: boolean;
needsDeleteAfterCreation: boolean;
nextSaveAttemptIndex: number;
mounted: boolean;
currentlySaving: ?string;
constructor(props: Props) {
super(props);
this.state = {
focused: false,
loadingStatus: 'inactive',
text: props.entryInfo.text,
};
this.creating = false;
this.needsUpdateAfterCreation = false;
this.needsDeleteAfterCreation = false;
this.nextSaveAttemptIndex = 0;
}
guardedSetState(input: Shape) {
if (this.mounted) {
this.setState(input);
}
}
componentDidMount() {
this.mounted = true;
this.props.innerRef(entryKey(this.props.entryInfo), this);
this.updateHeight();
// Whenever a new Entry is created, focus on it
if (!this.props.entryInfo.id) {
this.focus();
}
}
componentDidUpdate(prevProps: Props) {
if (
!this.state.focused &&
this.props.entryInfo.text !== this.state.text &&
this.props.entryInfo.text !== prevProps.entryInfo.text
) {
this.setState({ text: this.props.entryInfo.text });
this.currentlySaving = null;
}
if (
this.props.online &&
!prevProps.online &&
this.state.loadingStatus === 'error'
) {
this.save();
}
}
focus() {
invariant(
this.textarea instanceof HTMLTextAreaElement,
'textarea ref not set',
);
this.textarea.focus();
}
onMouseDown: (event: SyntheticEvent) => void = event => {
if (this.state.focused && event.target !== this.textarea) {
// Don't lose focus when some non-textarea part is clicked
event.preventDefault();
}
};
componentWillUnmount() {
this.mounted = false;
}
updateHeight: () => void = () => {
invariant(
this.textarea instanceof HTMLTextAreaElement,
'textarea ref not set',
);
this.textarea.style.height = 'auto';
this.textarea.style.height = this.textarea.scrollHeight + 'px';
};
render(): React.Node {
let actionLinks = null;
if (this.state.focused) {
let historyButton = null;
if (this.props.entryInfo.id) {
historyButton = (
History
);
}
const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`;
actionLinks = (
Delete
{historyButton}
{this.props.threadInfo.uiName}
);
}
const darkColor = colorIsDark(this.props.threadInfo.color);
const entryClasses = classNames({
[css.entry]: true,
[css.darkEntry]: darkColor,
[css.focusedEntry]: this.state.focused,
});
const style = { backgroundColor: `#${this.props.threadInfo.color}` };
const loadingIndicatorColor = darkColor ? 'white' : 'black';
const canEditEntry = threadHasPermission(
this.props.threadInfo,
threadPermissions.EDIT_ENTRIES,
);
return (
{actionLinks}
);
}
textareaRef: (textarea: ?HTMLTextAreaElement) => void = textarea => {
this.textarea = textarea;
};
onFocus: () => void = () => {
if (!this.state.focused) {
this.setState({ focused: true });
}
};
onBlur: () => void = () => {
this.setState({ focused: false });
if (this.state.text.trim() === '') {
this.delete();
} else if (this.props.entryInfo.text !== this.state.text) {
this.save();
}
};
delete() {
this.dispatchDelete(this.props.entryInfo.id, false);
}
save() {
this.dispatchSave(this.props.entryInfo.id, this.state.text);
}
onChange: (event: SyntheticEvent) => void = event => {
if (!this.props.loggedIn) {
this.props.pushModal( );
return;
}
const target = event.target;
invariant(target instanceof HTMLTextAreaElement, 'target not textarea');
this.setState({ text: target.value }, this.updateHeight);
};
onKeyDown: (event: SyntheticKeyboardEvent) => void =
event => {
if (event.key === 'Escape') {
invariant(
this.textarea instanceof HTMLTextAreaElement,
'textarea ref not set',
);
this.textarea.blur();
}
};
dispatchSave(serverID: ?string, newText: string) {
if (this.currentlySaving === newText) {
return;
}
this.currentlySaving = newText;
if (newText.trim() === '') {
// We don't save the empty string, since as soon as the element loses
// focus it'll get deleted
return;
}
if (!serverID) {
if (this.creating) {
// We need the first save call to return so we know the ID of the entry
// we're updating, so we'll need to handle this save later
this.needsUpdateAfterCreation = true;
return;
} else {
this.creating = true;
}
}
if (!serverID) {
this.props.dispatchActionPromise(
createEntryActionTypes,
this.createAction(newText),
);
} else {
this.props.dispatchActionPromise(
saveEntryActionTypes,
this.saveAction(serverID, newText),
);
}
}
async createAction(text: string): Promise {
const localID = this.props.entryInfo.localID;
invariant(localID, "if there's no serverID, there should be a localID");
const curSaveAttempt = this.nextSaveAttemptIndex++;
this.guardedSetState({ loadingStatus: 'loading' });
try {
const response = await this.props.createEntry({
text,
timestamp: this.props.entryInfo.creationTime,
date: dateString(
this.props.entryInfo.year,
this.props.entryInfo.month,
this.props.entryInfo.day,
),
threadID: this.props.entryInfo.threadID,
localID,
calendarQuery: this.props.calendarQuery(),
});
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'inactive' });
}
this.creating = false;
if (this.needsUpdateAfterCreation) {
this.needsUpdateAfterCreation = false;
this.dispatchSave(response.entryID, this.state.text);
}
if (this.needsDeleteAfterCreation) {
this.needsDeleteAfterCreation = false;
this.dispatchDelete(response.entryID, false);
}
return response;
} catch (e) {
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'error' });
}
this.currentlySaving = null;
this.creating = false;
throw e;
}
}
async saveAction(
entryID: string,
newText: string,
): Promise {
const curSaveAttempt = this.nextSaveAttemptIndex++;
this.guardedSetState({ loadingStatus: 'loading' });
try {
const response = await this.props.saveEntry({
entryID,
text: newText,
prevText: this.props.entryInfo.text,
timestamp: Date.now(),
calendarQuery: this.props.calendarQuery(),
});
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'inactive' });
}
return { ...response, threadID: this.props.entryInfo.threadID };
} catch (e) {
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'error' });
}
this.currentlySaving = null;
if (e instanceof ServerError && e.message === 'concurrent_modification') {
const onRefresh = () => {
this.setState({ loadingStatus: 'inactive' }, this.updateHeight);
this.props.dispatch({
type: concurrentModificationResetActionType,
payload: { id: entryID, dbText: e.payload?.db },
});
this.props.popModal();
};
this.props.pushModal(
,
);
}
throw e;
}
}
onDelete: (event: SyntheticEvent) => void = event => {
event.preventDefault();
if (!this.props.loggedIn) {
this.props.pushModal( );
return;
}
this.dispatchDelete(this.props.entryInfo.id, true);
};
dispatchDelete(serverID: ?string, focusOnNextEntry: boolean) {
const { localID } = this.props.entryInfo;
this.props.dispatchActionPromise(
deleteEntryActionTypes,
this.deleteAction(serverID, focusOnNextEntry),
undefined,
{ localID, serverID },
);
}
async deleteAction(
serverID: ?string,
focusOnNextEntry: boolean,
): Promise {
invariant(
this.props.loggedIn,
'user should be logged in if delete triggered',
);
if (focusOnNextEntry) {
this.props.focusOnFirstEntryNewerThan(this.props.entryInfo.creationTime);
}
if (serverID) {
return await this.props.deleteEntry({
entryID: serverID,
prevText: this.props.entryInfo.text,
calendarQuery: this.props.calendarQuery(),
});
} else if (this.creating) {
this.needsDeleteAfterCreation = true;
}
return null;
}
onHistory: (event: SyntheticEvent) => void = event => {
event.preventDefault();
this.props.pushModal(
,
);
};
}
export type InnerEntry = Entry;
const ConnectedEntry: React.ComponentType = React.memo(
function ConnectedEntry(props) {
const { threadID } = props.entryInfo;
const unresolvedThreadInfo = useSelector(
state => threadInfoSelector(state)[threadID],
);
const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo);
const loggedIn = useSelector(
state =>
!!(state.currentUserInfo && !state.currentUserInfo.anonymous && true),
);
const calendarQuery = useSelector(nonThreadCalendarQuery);
const connection = useSelector(connectionSelector(ashoatKeyserverID));
invariant(connection, 'keyserver missing from keyserverStore');
const online = connection.status === 'connected';
const callCreateEntry = useCreateEntry();
const callSaveEntry = useSaveEntry();
const callDeleteEntry = useDeleteEntry();
const dispatchActionPromise = useDispatchActionPromise();
const dispatch = useDispatch();
const modalContext = useModalContext();
return (
);
},
);
export default ConnectedEntry;
diff --git a/web/calendar/filter-panel.react.js b/web/calendar/filter-panel.react.js
index a3f66b9ce..285b32e32 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 { useDispatch } from 'react-redux';
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() {
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() {
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() {
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-message-list-container.react.js b/web/chat/chat-message-list-container.react.js
index 74120c3e6..69ba5cc66 100644
--- a/web/chat/chat-message-list-container.react.js
+++ b/web/chat/chat-message-list-container.react.js
@@ -1,160 +1,160 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import * as React from 'react';
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
-import { useDispatch } from 'react-redux';
import { useWatchThread, threadIsPending } from 'lib/shared/thread-utils.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import ChatInputBar from './chat-input-bar.react.js';
import css from './chat-message-list-container.css';
import ChatMessageList from './chat-message-list.react.js';
import ChatThreadComposer from './chat-thread-composer.react.js';
import ThreadTopBar from './thread-top-bar.react.js';
import { InputStateContext } from '../input/input-state.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import {
useThreadInfoForPossiblyPendingThread,
useInfosForPendingThread,
} from '../utils/thread-utils.js';
type Props = {
+activeChatThreadID: ?string,
};
function ChatMessageListContainer(props: Props): React.Node {
const { activeChatThreadID } = props;
const { isChatCreation, selectedUserInfos, otherUserInfos } =
useInfosForPendingThread();
const threadInfo = useThreadInfoForPossiblyPendingThread(activeChatThreadID);
invariant(threadInfo, 'ThreadInfo should be set');
const dispatch = useDispatch();
React.useEffect(() => {
if (isChatCreation && activeChatThreadID !== threadInfo.id) {
let payload = {
activeChatThreadID: threadInfo.id,
};
if (threadIsPending(threadInfo.id)) {
payload = {
...payload,
pendingThread: threadInfo,
};
}
dispatch({
type: updateNavInfoActionType,
payload,
});
}
}, [activeChatThreadID, dispatch, isChatCreation, threadInfo]);
const inputState = React.useContext(InputStateContext);
invariant(inputState, 'InputState should be set');
const [{ isActive }, connectDropTarget] = useDrop({
accept: NativeTypes.FILE,
drop: item => {
const { files } = item;
if (inputState && files.length > 0) {
inputState.appendFiles(threadInfo, files);
}
},
collect: monitor => ({
isActive: monitor.isOver() && monitor.canDrop(),
}),
});
useWatchThread(threadInfo);
const containerStyle = classNames({
[css.container]: true,
[css.activeContainer]: isActive,
});
const containerRef = React.useRef();
const onPaste = React.useCallback(
(e: ClipboardEvent) => {
if (!inputState) {
return;
}
const { clipboardData } = e;
if (!clipboardData) {
return;
}
const { files } = clipboardData;
if (files.length === 0) {
return;
}
e.preventDefault();
inputState.appendFiles(threadInfo, [...files]);
},
[inputState, threadInfo],
);
React.useEffect(() => {
const currentContainerRef = containerRef.current;
if (!currentContainerRef) {
return undefined;
}
currentContainerRef.addEventListener('paste', onPaste);
return () => {
currentContainerRef.removeEventListener('paste', onPaste);
};
}, [onPaste]);
const content = React.useMemo(() => {
const topBar = ;
const messageListAndInput = (
<>
>
);
if (!isChatCreation) {
return (
<>
{topBar}
{messageListAndInput}
>
);
}
const chatUserSelection = (
);
if (!selectedUserInfos.length) {
return chatUserSelection;
}
return (
<>
{topBar}
{chatUserSelection}
{messageListAndInput}
>
);
}, [
inputState,
isChatCreation,
otherUserInfos,
selectedUserInfos,
threadInfo,
]);
return connectDropTarget(
{content}
,
);
}
export default ChatMessageListContainer;
diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js
index b395b8073..597b54277 100644
--- a/web/chat/chat-thread-composer.react.js
+++ b/web/chat/chat-thread-composer.react.js
@@ -1,263 +1,263 @@
// @flow
import classNames from 'classnames';
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual.js';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js';
import {
getPotentialMemberItems,
useSearchUsers,
notFriendNotice,
} from 'lib/shared/search-utils.js';
import {
createPendingThread,
threadIsPending,
useExistingThreadInfoFinder,
} from 'lib/shared/thread-utils.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import css from './chat-thread-composer.css';
import UserAvatar from '../avatars/user-avatar.react.js';
import Button from '../components/button.react.js';
import Label from '../components/label.react.js';
import Search from '../components/search.react.js';
import type { InputState } from '../input/input-state.js';
import Alert from '../modals/alert.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
+userInfoInputArray: $ReadOnlyArray,
+otherUserInfos: { [id: string]: AccountUserInfo },
+threadID: string,
+inputState: InputState,
};
type ActiveThreadBehavior =
| 'reset-active-thread-if-pending'
| 'keep-active-thread';
function ChatThreadComposer(props: Props): React.Node {
const { userInfoInputArray, otherUserInfos, threadID, inputState } = props;
const [usernameInputText, setUsernameInputText] = React.useState('');
const dispatch = useDispatch();
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers);
const userInfoInputIDs = React.useMemo(
() => userInfoInputArray.map(userInfo => userInfo.id),
[userInfoInputArray],
);
const serverSearchResults = useSearchUsers(usernameInputText);
const userListItems = React.useMemo(
() =>
getPotentialMemberItems({
text: usernameInputText,
userInfos: otherUserInfos,
searchIndex: userSearchIndex,
excludeUserIDs: userInfoInputIDs,
includeServerSearchUsers: serverSearchResults,
}),
[
usernameInputText,
otherUserInfos,
userSearchIndex,
userInfoInputIDs,
serverSearchResults,
],
);
const userListItemsWithENSNames = useENSNames(userListItems);
const { pushModal } = useModalContext();
const loggedInUserInfo = useLoggedInUserInfo();
invariant(loggedInUserInfo, 'loggedInUserInfo should be set');
const pendingPrivateThread = React.useRef(
createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
members: [loggedInUserInfo],
}),
);
const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder(
pendingPrivateThread.current,
);
const onSelectUserFromSearch = React.useCallback(
(userListItem: UserListItem) => {
const { alert, notice, disabled, ...user } = userListItem;
setUsernameInputText('');
if (!alert) {
dispatch({
type: updateNavInfoActionType,
payload: {
selectedUserList: [...userInfoInputArray, user],
},
});
} else if (
notice === notFriendNotice &&
userInfoInputArray.length === 0
) {
const newUserInfoInputArray = [
{ id: userListItem.id, username: userListItem.username },
];
const threadInfo = existingThreadInfoFinderForCreatingThread({
searching: true,
userInfoInputArray: newUserInfoInputArray,
});
dispatch({
type: updateNavInfoActionType,
payload: {
chatMode: 'view',
activeChatThreadID: threadInfo?.id,
pendingThread: threadInfo,
},
});
} else {
pushModal({alert.text} );
}
},
[
dispatch,
existingThreadInfoFinderForCreatingThread,
pushModal,
userInfoInputArray,
],
);
const onRemoveUserFromSelected = React.useCallback(
(userID: string) => {
const newSelectedUserList = userInfoInputArray.filter(
({ id }) => userID !== id,
);
if (_isEqual(userInfoInputArray)(newSelectedUserList)) {
return;
}
dispatch({
type: updateNavInfoActionType,
payload: {
selectedUserList: newSelectedUserList,
},
});
},
[dispatch, userInfoInputArray],
);
const userSearchResultList = React.useMemo(() => {
if (
!userListItemsWithENSNames.length ||
(!usernameInputText && userInfoInputArray.length)
) {
return null;
}
const userItems = userListItemsWithENSNames.map(
(userSearchResult: UserListItem) => {
return (
onSelectUserFromSearch(userSearchResult)}
className={css.searchResultsButton}
>
{userSearchResult.username}
{userSearchResult.notice}
);
},
);
return ;
}, [
onSelectUserFromSearch,
userInfoInputArray.length,
userListItemsWithENSNames,
usernameInputText,
]);
const hideSearch = React.useCallback(
(threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => {
dispatch({
type: updateNavInfoActionType,
payload: {
chatMode: 'view',
activeChatThreadID:
threadBehavior === 'keep-active-thread' ||
!threadIsPending(threadID)
? threadID
: null,
},
});
},
[dispatch, threadID],
);
const onCloseSearch = React.useCallback(() => {
hideSearch('reset-active-thread-if-pending');
}, [hideSearch]);
const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray);
const tagsList = React.useMemo(() => {
if (!userInfoInputArrayWithENSNames?.length) {
return null;
}
const labels = userInfoInputArrayWithENSNames.map(user => {
return (
onRemoveUserFromSelected(user.id)}>
{user.username}
);
});
return {labels}
;
}, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]);
React.useEffect(() => {
if (!inputState) {
return undefined;
}
inputState.registerSendCallback(hideSearch);
return () => inputState.unregisterSendCallback(hideSearch);
}, [hideSearch, inputState]);
const threadSearchContainerStyles = classNames(css.threadSearchContainer, {
[css.fullHeight]: !userInfoInputArray.length,
});
return (
{tagsList}
{userSearchResultList}
);
}
export default ChatThreadComposer;
diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js
index 4f667291a..7f43e33d9 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 { useDispatch } from 'react-redux';
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() {
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/database/sqlite-data-handler.js b/web/database/sqlite-data-handler.js
index 1d985b19e..46b42eb62 100644
--- a/web/database/sqlite-data-handler.js
+++ b/web/database/sqlite-data-handler.js
@@ -1,65 +1,66 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
+
+import { useDispatch } from 'lib/utils/redux-utils.js';
import { getDatabaseModule } from './database-module-provider.js';
import { useSelector } from '../redux/redux-utils.js';
import { workerRequestMessageTypes } from '../types/worker-types.js';
function SQLiteDataHandler(): React.Node {
const dispatch = useDispatch();
const rehydrateConcluded = useSelector(
state => !!(state._persist && state._persist.rehydrated),
);
const currentLoggedInUserID = useSelector(state =>
state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id,
);
const handleSensitiveData = React.useCallback(async () => {
const databaseModule = await getDatabaseModule();
try {
const currentUserData = await databaseModule.schedule({
type: workerRequestMessageTypes.GET_CURRENT_USER_ID,
});
const currentDBUserID = currentUserData?.userID;
if (currentDBUserID === currentLoggedInUserID) {
return;
}
if (currentDBUserID) {
await databaseModule.init({ clearDatabase: true });
}
if (currentLoggedInUserID) {
await databaseModule.schedule({
type: workerRequestMessageTypes.SET_CURRENT_USER_ID,
userID: currentLoggedInUserID,
});
}
} catch (error) {
console.error(error);
throw error;
}
}, [currentLoggedInUserID]);
React.useEffect(() => {
(async () => {
const databaseModule = await getDatabaseModule();
if (!rehydrateConcluded) {
return;
}
const isSupported = await databaseModule.isDatabaseSupported();
if (!isSupported) {
return;
}
await handleSensitiveData();
})();
}, [dispatch, handleSensitiveData, rehydrateConcluded]);
return null;
}
export { SQLiteDataHandler };
diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
index e199ef58d..8e9bba1d3 100644
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -1,1676 +1,1676 @@
// @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 { useDispatch } from 'react-redux';
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,
} 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>();
// 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();
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) {
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) {
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) {
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]];
}
}
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(
(propsAndState: PropsAndState) => propsAndState.typeaheadState,
(typeaheadState: TypeaheadState) => ({
typeaheadState,
setTypeaheadState: this.setTypeaheadState,
}),
);
inputStateSelector = 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,
) {
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,
) {
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() {
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/invite-links/invite-link-handler.react.js b/web/invite-links/invite-link-handler.react.js
index 80e0e6593..9f01380ee 100644
--- a/web/invite-links/invite-link-handler.react.js
+++ b/web/invite-links/invite-link-handler.react.js
@@ -1,63 +1,63 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import {
verifyInviteLink,
verifyInviteLinkActionTypes,
} from 'lib/actions/link-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import AcceptInviteModal from './accept-invite-modal.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
function InviteLinkHandler(): null {
const inviteSecret = useSelector(state => state.navInfo.inviteSecret);
const loggedIn = useSelector(isLoggedIn);
const dispatchActionPromise = useDispatchActionPromise();
const dispatch = useDispatch();
const validateLink = useServerCall(verifyInviteLink);
const { pushModal } = useModalContext();
React.useEffect(() => {
if (!inviteSecret || !loggedIn) {
return;
}
dispatch({
type: updateNavInfoActionType,
payload: { inviteSecret: null },
});
const validateLinkPromise = validateLink({ secret: inviteSecret });
dispatchActionPromise(verifyInviteLinkActionTypes, validateLinkPromise);
(async () => {
const result = await validateLinkPromise;
if (result.status === 'already_joined') {
return;
}
pushModal(
,
);
})();
}, [
dispatch,
dispatchActionPromise,
inviteSecret,
loggedIn,
pushModal,
validateLink,
]);
return null;
}
export default InviteLinkHandler;
diff --git a/web/modals/apps/app-listing.react.js b/web/modals/apps/app-listing.react.js
index 18dda46f8..3efdbe486 100644
--- a/web/modals/apps/app-listing.react.js
+++ b/web/modals/apps/app-listing.react.js
@@ -1,81 +1,81 @@
// @flow
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { faPlusCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
disableAppActionType,
enableAppActionType,
} from 'lib/reducers/enabled-apps-reducer.js';
import type { SupportedApps } from 'lib/types/enabled-apps.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import css from './apps.css';
import Button from '../../components/button.react.js';
type Props = {
+id: SupportedApps | 'chat',
+readOnly: boolean,
+enabled: boolean,
+name: string,
+icon: 'message-square' | 'calendar',
+copy: string,
};
function AppListing(props: Props): React.Node {
const { id, readOnly, enabled, name, icon, copy } = props;
const dispatch = useDispatch();
const switchAppState = React.useCallback(
() =>
dispatch({
type: enabled ? disableAppActionType : enableAppActionType,
payload: id,
}),
[dispatch, enabled, id],
);
const actionButton = React.useMemo(() => {
const switchIcon = enabled ? faCheckCircle : faPlusCircle;
if (readOnly) {
const readOnlyIconClasses = classnames(
css.appListingIcon,
css.appListingIconState,
css.iconReadOnly,
);
return (
);
}
const iconClasses = classnames({
[css.appListingIconState]: true,
[css.iconEnabled]: enabled,
[css.iconDisabled]: !enabled,
});
return (
);
}, [enabled, readOnly, switchAppState]);
return (
{name}
{copy}
{actionButton}
);
}
export default AppListing;
diff --git a/web/modals/threads/create/compose-subchannel-modal.react.js b/web/modals/threads/create/compose-subchannel-modal.react.js
index 36bc06bb9..e9e08bba9 100644
--- a/web/modals/threads/create/compose-subchannel-modal.react.js
+++ b/web/modals/threads/create/compose-subchannel-modal.react.js
@@ -1,277 +1,279 @@
// @flow
+
import * as React from 'react';
-import { useDispatch, useSelector } from 'react-redux';
import {
useNewThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.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 { useDispatchActionPromise } from 'lib/utils/action-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import { trimText } from 'lib/utils/text-utils.js';
import css from './compose-subchannel-modal.css';
import SubchannelMembers from './steps/subchannel-members.react.js';
import SubchannelSettings from './steps/subchannel-settings.react.js';
import type { VisibilityType } from './steps/subchannel-settings.react.js';
import Stepper from '../../../components/stepper.react.js';
import { updateNavInfoActionType } from '../../../redux/action-types.js';
+import { useSelector } from '../../../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../../../selectors/nav-selectors.js';
import Modal from '../../modal.react.js';
type Props = {
+onClose: () => void,
+parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
const getThreadType = (visibility: VisibilityType, announcement: boolean) => {
if (visibility === 'open') {
return announcement
? threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD
: threadTypes.COMMUNITY_OPEN_SUBTHREAD;
} else {
return announcement
? threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD
: threadTypes.COMMUNITY_SECRET_SUBTHREAD;
}
};
type Steps = 'settings' | 'members';
type HeaderProps = {
+parentThreadName: string,
};
function ComposeSubchannelHeader(props: HeaderProps): React.Node {
const { parentThreadName } = props;
return (
{'within '}
{parentThreadName}
);
}
const createSubchannelLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
function ComposeSubchannelModal(props: Props): React.Node {
const { parentThreadInfo, onClose } = props;
const { uiName: parentThreadName } = useResolvedThreadInfo(parentThreadInfo);
const [activeStep, setActiveStep] = React.useState('settings');
const [channelName, setChannelName] = React.useState('');
const [visibilityType, setVisibilityType] =
React.useState('open');
const [announcement, setAnnouncement] = React.useState(false);
const [selectedUsers, setSelectedUsers] = React.useState<
$ReadOnlySet,
>(new Set());
const [searchUserText, setSearchUserText] = React.useState('');
const loadingState = useSelector(createSubchannelLoadingStatusSelector);
const [errorMessage, setErrorMessage] = React.useState('');
const calendarQuery = useSelector(nonThreadCalendarQuery);
const callNewThread = useNewThread();
const dispatchActionPromise = useDispatchActionPromise();
const dispatch = useDispatch();
const createSubchannel = React.useCallback(async () => {
try {
const threadType = getThreadType(visibilityType, announcement);
const query = calendarQuery();
const result = await callNewThread({
name: channelName,
type: threadType,
parentThreadID: parentThreadInfo.id,
initialMemberIDs: Array.from(selectedUsers),
calendarQuery: query,
color: parentThreadInfo.color,
});
return result;
} catch (e) {
await setErrorMessage('unknown error');
return null;
}
}, [
parentThreadInfo,
selectedUsers,
visibilityType,
announcement,
callNewThread,
calendarQuery,
channelName,
]);
const dispatchCreateSubchannel = React.useCallback(async () => {
await setErrorMessage('');
const response = createSubchannel();
await dispatchActionPromise(newThreadActionTypes, response);
const result = await response;
if (result) {
const { newThreadID } = result;
await dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: newThreadID,
},
});
props.onClose();
}
}, [dispatchActionPromise, createSubchannel, props, dispatch]);
const onChangeChannelName = React.useCallback(
(event: SyntheticEvent) => {
const target = event.currentTarget;
setChannelName(target.value);
},
[],
);
const onOpenVisibilityTypeSelected = React.useCallback(
() => setVisibilityType('open'),
[],
);
const onSecretVisibilityTypeSelected = React.useCallback(
() => setVisibilityType('secret'),
[],
);
const onAnnouncementSelected = React.useCallback(
() => setAnnouncement(!announcement),
[announcement],
);
const toggleUserSelection = React.useCallback((userID: string) => {
setSelectedUsers((users: $ReadOnlySet) => {
const newUsers = new Set(users);
if (newUsers.has(userID)) {
newUsers.delete(userID);
} else {
newUsers.add(userID);
}
return newUsers;
});
}, []);
const subchannelSettings = React.useMemo(
() => (
),
[
channelName,
visibilityType,
announcement,
onChangeChannelName,
onOpenVisibilityTypeSelected,
onSecretVisibilityTypeSelected,
onAnnouncementSelected,
],
);
const stepperButtons = React.useMemo(
() => ({
settings: {
nextProps: {
content: 'Next',
disabled: !channelName.trim(),
onClick: () => {
setErrorMessage('');
setChannelName(channelName.trim());
setActiveStep('members');
},
},
},
members: {
prevProps: {
content: 'Back',
onClick: () => setActiveStep('settings'),
},
nextProps: {
content: 'Create',
loading: loadingState === 'loading',
disabled: selectedUsers.size === 0,
onClick: () => {
dispatchCreateSubchannel();
},
},
},
}),
[channelName, dispatchCreateSubchannel, loadingState, selectedUsers],
);
const subchannelMembers = React.useMemo(
() => (
),
[
selectedUsers,
toggleUserSelection,
parentThreadInfo,
searchUserText,
setSearchUserText,
],
);
const modalName =
activeStep === 'members'
? `Create channel - ${trimText(channelName, 11)}`
: 'Create channel';
return (
);
}
export default ComposeSubchannelModal;
diff --git a/web/modals/threads/create/steps/subchannel-members.react.js b/web/modals/threads/create/steps/subchannel-members.react.js
index 48be25b4d..508b09df8 100644
--- a/web/modals/threads/create/steps/subchannel-members.react.js
+++ b/web/modals/threads/create/steps/subchannel-members.react.js
@@ -1,65 +1,65 @@
// @flow
import * as React from 'react';
-import { useSelector } from 'react-redux';
import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js';
import { useAncestorThreads } from 'lib/shared/ancestor-threads.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import MembersList from './subchannel-members-list.react.js';
import css from './subchannel-members.css';
import Search from '../../../../components/search.react.js';
+import { useSelector } from '../../../../redux/redux-utils.js';
type SubchannelMembersProps = {
+parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+selectedUsers: $ReadOnlySet,
+searchText: string,
+setSearchText: string => void,
+toggleUserSelection: (userID: string) => void,
};
function SubchannelMembers(props: SubchannelMembersProps): React.Node {
const {
toggleUserSelection,
searchText,
setSearchText,
parentThreadInfo,
selectedUsers,
} = props;
const ancestorThreads = useAncestorThreads(parentThreadInfo);
const communityThread = ancestorThreads[0] ?? parentThreadInfo;
const userSearchIndex = useSelector(userStoreSearchIndex);
const searchResult = React.useMemo(
() => new Set(userSearchIndex.getSearchResults(searchText)),
[userSearchIndex, searchText],
);
return (
<>
>
);
}
export default SubchannelMembers;
diff --git a/web/navigation-panels/app-switcher.react.js b/web/navigation-panels/app-switcher.react.js
index 1a1adaa40..6e1b54cc4 100644
--- a/web/navigation-panels/app-switcher.react.js
+++ b/web/navigation-panels/app-switcher.react.js
@@ -1,106 +1,106 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import NavigationPanel from './navigation-panel.react.js';
import css from './topbar.css';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
import { navTabSelector } from '../selectors/nav-selectors.js';
import { unreadCountInSelectedCommunity } from '../selectors/thread-selectors.js';
function AppSwitcher(): React.Node {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const mostRecentlyReadThread = useSelector(mostRecentlyReadThreadSelector);
const isActiveThreadCurrentlyUnread = useSelector(
state =>
!activeChatThreadID ||
!!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread,
);
const dispatch = useDispatch();
const onClickChat = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: {
tab: 'chat',
activeChatThreadID: isActiveThreadCurrentlyUnread
? mostRecentlyReadThread
: activeChatThreadID,
},
});
},
[
dispatch,
isActiveThreadCurrentlyUnread,
mostRecentlyReadThread,
activeChatThreadID,
],
);
const boundUnreadCount = useSelector(unreadCountInSelectedCommunity);
let chatBadge = null;
if (boundUnreadCount > 0) {
chatBadge = {boundUnreadCount} ;
}
const chatNavigationItem = React.useMemo(
() => (
{chatBadge}
Chat
),
[chatBadge, onClickChat],
);
const onClickCalendar = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'calendar' },
});
},
[dispatch],
);
const isCalendarEnabled = useSelector(state => state.enabledApps.calendar);
const calendarNavigationItem = React.useMemo(() => {
if (!isCalendarEnabled) {
return null;
}
return (
Calendar
);
}, [isCalendarEnabled, onClickCalendar]);
return (
{chatNavigationItem}
{calendarNavigationItem}
);
}
export default AppSwitcher;
diff --git a/web/navigation-panels/settings-switcher.react.js b/web/navigation-panels/settings-switcher.react.js
index 155524b75..f44038431 100644
--- a/web/navigation-panels/settings-switcher.react.js
+++ b/web/navigation-panels/settings-switcher.react.js
@@ -1,92 +1,93 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
+
+import { useDispatch } from 'lib/utils/redux-utils.js';
import NavigationPanel from './navigation-panel.react.js';
import css from './settings-switcher.css';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { navSettingsSectionSelector } from '../selectors/nav-selectors.js';
import { useStaffCanSee } from '../utils/staff-utils.js';
function SettingsSwitcher(): React.Node {
const dispatch = useDispatch();
const onClickAccountSettings = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'settings', settingsSection: 'account' },
});
},
[dispatch],
);
const accountSettingsNavigationItem = React.useMemo(
() => (
My Account
),
[onClickAccountSettings],
);
const staffCanSee = useStaffCanSee();
const onClickKeyservers = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'settings', settingsSection: 'keyservers' },
});
},
[dispatch],
);
const keyserversNavigationItem = React.useMemo(() => {
if (!staffCanSee) {
return null;
}
return (
Keyservers
);
}, [onClickKeyservers, staffCanSee]);
const onClickDangerZone = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'settings', settingsSection: 'danger-zone' },
});
},
[dispatch],
);
const dangerZoneNavigationItem = React.useMemo(
() => (
Danger Zone
),
[onClickDangerZone],
);
return (
{accountSettingsNavigationItem}
{keyserversNavigationItem}
{dangerZoneNavigationItem}
);
}
export default SettingsSwitcher;
diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js
index 20682015c..cdc35a85d 100644
--- a/web/push-notif/push-notifs-handler.js
+++ b/web/push-notif/push-notifs-handler.js
@@ -1,209 +1,209 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
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 => {
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 });
}),
[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 3934d8b2e..101493fed 100644
--- a/web/redux/focus-handler.react.js
+++ b/web/redux/focus-handler.react.js
@@ -1,65 +1,66 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
+
+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 => {
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 be8701754..8a0997ca4 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 { useDispatch } from 'react-redux';
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 => {
if (bootstrapped && initialStateLoaded) {
return children;
} else {
return ;
}
},
[children, initialStateLoaded],
);
return {childFunction} ;
}
export default InitialReduxStateGate;
diff --git a/web/redux/visibility-handler.react.js b/web/redux/visibility-handler.react.js
index 48739d424..9ed07a4e4 100644
--- a/web/redux/visibility-handler.react.js
+++ b/web/redux/visibility-handler.react.js
@@ -1,57 +1,57 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
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');
}, []);
React.useEffect(() => {
const listener = visibility.change(onVisibilityChange);
return () => {
visibility.unbind(listener);
};
}, [visibility, onVisibilityChange]);
const dispatch = useDispatch();
const curForeground = useIsAppForegrounded();
const updateRedux = React.useCallback(
foreground => {
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/selectors/thread-selectors.js b/web/selectors/thread-selectors.js
index 5bd481a80..be4f8c923 100644
--- a/web/selectors/thread-selectors.js
+++ b/web/selectors/thread-selectors.js
@@ -1,160 +1,160 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import { createSelector } from 'reselect';
import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
import {
createPendingSidebar,
threadInHomeChatList,
} from 'lib/shared/thread-utils.js';
import type {
ComposableMessageInfo,
RobotextMessageInfo,
} from 'lib/types/message-types.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo, RawThreadInfos } from 'lib/types/thread-types.js';
import { values } from 'lib/utils/objects.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import { getDefaultTextMessageRules } from '../markdown/rules.react.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import type { AppState } from '../redux/redux-setup.js';
import { useSelector } from '../redux/redux-utils.js';
function useOnClickThread(
thread: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
): (event: SyntheticEvent) => void {
const dispatch = useDispatch();
return React.useCallback(
(event: SyntheticEvent) => {
invariant(
thread?.id,
'useOnClickThread should be called with threadID set',
);
event.preventDefault();
const { id: threadID } = thread;
let payload;
if (threadID.includes('pending')) {
payload = {
chatMode: 'view',
activeChatThreadID: threadID,
pendingThread: thread,
tab: 'chat',
};
} else {
payload = {
chatMode: 'view',
activeChatThreadID: threadID,
tab: 'chat',
};
}
dispatch({ type: updateNavInfoActionType, payload });
},
[dispatch, thread],
);
}
function useThreadIsActive(threadID: string): boolean {
return useSelector(state => threadID === state.navInfo.activeChatThreadID);
}
function useOnClickPendingSidebar(
messageInfo: ComposableMessageInfo | RobotextMessageInfo,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): (event: SyntheticEvent) => mixed {
const dispatch = useDispatch();
const loggedInUserInfo = useLoggedInUserInfo();
const cacheContext = React.useContext(ENSCacheContext);
const { getENSNames } = cacheContext;
const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo);
return React.useCallback(
async (event: SyntheticEvent) => {
event.preventDefault();
if (!loggedInUserInfo) {
return;
}
const pendingSidebarInfo = await createPendingSidebar({
sourceMessageInfo: messageInfo,
parentThreadInfo: threadInfo,
loggedInUserInfo,
markdownRules: getDefaultTextMessageRules(chatMentionCandidates)
.simpleMarkdownRules,
getENSNames,
});
dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: pendingSidebarInfo.id,
pendingThread: pendingSidebarInfo,
},
});
},
[
loggedInUserInfo,
chatMentionCandidates,
threadInfo,
messageInfo,
getENSNames,
dispatch,
],
);
}
function useOnClickNewThread(): (event: SyntheticEvent) => void {
const dispatch = useDispatch();
return React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: {
chatMode: 'create',
selectedUserList: [],
},
});
},
[dispatch],
);
}
function useDrawerSelectedThreadID(): ?string {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const pickedCommunityID = useSelector(
state => state.communityPickerStore.calendar,
);
const inCalendar = useSelector(state => state.navInfo.tab === 'calendar');
return inCalendar ? pickedCommunityID : activeChatThreadID;
}
const unreadCountInSelectedCommunity: (state: AppState) => number =
createSelector(
(state: AppState) => state.threadStore.threadInfos,
(state: AppState) => state.communityPickerStore.chat,
(threadInfos: RawThreadInfos, communityID: ?string): number =>
values(threadInfos).filter(
threadInfo =>
threadInHomeChatList(threadInfo) &&
threadInfo.currentUser.unread &&
(!communityID || communityID === threadInfo.community),
).length,
);
export {
useOnClickThread,
useThreadIsActive,
useOnClickPendingSidebar,
useOnClickNewThread,
useDrawerSelectedThreadID,
unreadCountInSelectedCommunity,
};
diff --git a/web/sidebar/community-creation/community-creation-modal.react.js b/web/sidebar/community-creation/community-creation-modal.react.js
index b024631f4..e51415293 100644
--- a/web/sidebar/community-creation/community-creation-modal.react.js
+++ b/web/sidebar/community-creation/community-creation-modal.react.js
@@ -1,209 +1,209 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import {
useNewThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import type { NewThreadResult } from 'lib/types/thread-types.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js';
import CommunityCreationMembersModal from './community-creation-members-modal.react.js';
import css from './community-creation-modal.css';
import UserAvatar from '../../avatars/user-avatar.react.js';
import CommIcon from '../../CommIcon.react.js';
import Button, { buttonThemes } from '../../components/button.react.js';
import EnumSettingsOption from '../../components/enum-settings-option.react.js';
import LoadingIndicator from '../../loading-indicator.react.js';
import Input from '../../modals/input.react.js';
import Modal from '../../modals/modal.react.js';
import { updateNavInfoActionType } from '../../redux/action-types.js';
import { useSelector } from '../../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../../selectors/nav-selectors.js';
const announcementStatements = [
{
statement:
`This option sets the community’s root channel to an ` +
`announcement channel. Only admins and other admin-appointed ` +
`roles can send messages in an announcement channel.`,
isStatementValid: true,
styleStatementBasedOnValidity: false,
},
];
const createNewCommunityLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
function CommunityCreationModal(): React.Node {
const modalContext = useModalContext();
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callNewThread = useNewThread();
const calendarQueryFunc = useSelector(nonThreadCalendarQuery);
const [errorMessage, setErrorMessage] = React.useState();
const [pendingCommunityName, setPendingCommunityName] =
React.useState('');
const onChangePendingCommunityName = React.useCallback(
(event: SyntheticEvent) => {
setErrorMessage();
setPendingCommunityName(event.currentTarget.value);
},
[],
);
const [announcementSetting, setAnnouncementSetting] = React.useState(false);
const onAnnouncementSelected = React.useCallback(() => {
setErrorMessage();
setAnnouncementSetting(!announcementSetting);
}, [announcementSetting]);
const callCreateNewCommunity = React.useCallback(async () => {
const calendarQuery = calendarQueryFunc();
try {
const newThreadResult: NewThreadResult = await callNewThread({
name: pendingCommunityName,
type: announcementSetting
? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT
: threadTypes.COMMUNITY_ROOT,
calendarQuery,
});
return newThreadResult;
} catch (e) {
setErrorMessage('Community creation failed. Please try again.');
throw e;
}
}, [
announcementSetting,
calendarQueryFunc,
callNewThread,
pendingCommunityName,
]);
const createNewCommunity = React.useCallback(async () => {
setErrorMessage();
const newThreadResultPromise = callCreateNewCommunity();
dispatchActionPromise(newThreadActionTypes, newThreadResultPromise);
const newThreadResult: NewThreadResult = await newThreadResultPromise;
const { newThreadID } = newThreadResult;
await dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: newThreadID,
},
});
modalContext.popModal();
modalContext.pushModal(
,
);
}, [callCreateNewCommunity, dispatch, dispatchActionPromise, modalContext]);
const megaphoneIcon = React.useMemo(
() => ,
[],
);
const avatarNodeEnabled = false;
let avatarNode;
if (avatarNodeEnabled) {
avatarNode = (
);
}
const createNewCommunityLoadingStatus: LoadingStatus = useSelector(
createNewCommunityLoadingStatusSelector,
);
let buttonContent;
if (createNewCommunityLoadingStatus === 'loading') {
buttonContent = (
);
} else if (errorMessage) {
buttonContent = errorMessage;
} else {
buttonContent = 'Create community';
}
return (
);
}
export default CommunityCreationModal;
diff --git a/web/sidebar/community-drawer-item-community-handlers.react.js b/web/sidebar/community-drawer-item-community-handlers.react.js
index e31ef2ff9..d9c7cca64 100644
--- a/web/sidebar/community-drawer-item-community-handlers.react.js
+++ b/web/sidebar/community-drawer-item-community-handlers.react.js
@@ -1,117 +1,117 @@
// @flow
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import {
updateCalendarCommunityFilter,
updateChatCommunityFilter,
clearChatCommunityFilter,
} from 'lib/actions/community-actions.js';
import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import type { CommunityDrawerItemCommunityHandler } from './community-drawer-item-handler.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { useCommunityIsPickedCalendar } from '../selectors/calendar-selectors.js';
import {
useOnClickThread,
useThreadIsActive,
} from '../selectors/thread-selectors.js';
import type { NavigationTab } from '../types/nav-types.js';
export type HandlerProps = {
+setHandler: (handler: CommunityDrawerItemCommunityHandler) => void,
+threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
};
function ChatDrawerItemCommunityHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const onClickThread = useOnClickThread(threadInfo);
const isActive = useThreadIsActive(threadInfo.id);
const dispatch = useDispatch();
const openCommunityID = useSelector(state => state.communityPickerStore.chat);
const expanded = openCommunityID === threadInfo.id;
const onClick = React.useCallback(
(event: SyntheticEvent) => {
if (!isActive) {
onClickThread(event);
}
if (openCommunityID === threadInfo.id && isActive) {
dispatch({
type: clearChatCommunityFilter,
});
return;
}
const community = threadInfo.community ?? threadInfo.id;
dispatch({
type: updateChatCommunityFilter,
payload: community,
});
},
[
dispatch,
isActive,
onClickThread,
openCommunityID,
threadInfo.community,
threadInfo.id,
],
);
const handler = React.useMemo(
() => ({ onClick, isActive, expanded }),
[expanded, isActive, onClick],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
function CalendarDrawerItemCommunityHandler(props: HandlerProps): React.Node {
const { setHandler, threadInfo } = props;
const dispatch = useDispatch();
const onClick = React.useCallback(() => {
dispatch({
type: updateCalendarCommunityFilter,
payload: threadInfo.id,
});
}, [dispatch, threadInfo.id]);
const isActive = useCommunityIsPickedCalendar(threadInfo.id);
const expanded = false;
const handler = React.useMemo(
() => ({ onClick, isActive, expanded }),
[onClick, isActive, expanded],
);
React.useEffect(() => {
setHandler(handler);
}, [handler, setHandler]);
return null;
}
const communityDrawerItemCommunityHandlers: {
+[tab: NavigationTab]: React.ComponentType,
} = Object.freeze({
chat: ChatDrawerItemCommunityHandler,
calendar: CalendarDrawerItemCommunityHandler,
});
function getCommunityDrawerItemCommunityHandler(
tab: NavigationTab,
): React.ComponentType {
return (
communityDrawerItemCommunityHandlers[tab] ?? ChatDrawerItemCommunityHandler
);
}
export { getCommunityDrawerItemCommunityHandler };
diff --git a/web/sidebar/community-picker.react.js b/web/sidebar/community-picker.react.js
index 70f5d3ced..4cb1f3114 100644
--- a/web/sidebar/community-picker.react.js
+++ b/web/sidebar/community-picker.react.js
@@ -1,127 +1,127 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import {
clearCalendarCommunityFilter,
clearChatCommunityFilter,
} from 'lib/actions/community-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { unreadCount } from 'lib/selectors/thread-selectors.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import CommunityCreationModal from './community-creation/community-creation-modal.react.js';
import CommunityDrawer from './community-drawer.react.js';
import css from './community-picker.css';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
function CommunityPicker(): React.Node {
const dispatch = useDispatch();
const modalContext = useModalContext();
const openAccountSettings = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'settings', settingsSection: 'account' },
});
},
[dispatch],
);
const isCalendarOpen = useSelector(state => state.navInfo.tab === 'calendar');
const onPressInbox = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
if (isCalendarOpen) {
dispatch({
type: clearCalendarCommunityFilter,
});
} else {
dispatch({
type: updateNavInfoActionType,
payload: { tab: 'chat' },
});
dispatch({
type: clearChatCommunityFilter,
});
}
},
[dispatch, isCalendarOpen],
);
const inboxButtonTitle = isCalendarOpen ? 'All communities' : 'Inbox';
const isInboxOpen = useSelector(
state => state.navInfo.tab === 'chat' || state.navInfo.tab === 'calendar',
);
const isSettingsOpen = useSelector(state => state.navInfo.tab === 'settings');
const sideLineInbox = classNames({
[css.sideLine]: true,
[css.sideLineActive]: isInboxOpen,
});
const sideLineSettings = classNames({
[css.sideLine]: true,
[css.sideLineActive]: isSettingsOpen,
});
const onPressCommunityCreationButton = React.useCallback(() => {
modalContext.pushModal( );
}, [modalContext]);
const communityCreationButton = (
Create community
);
const boundUnreadCount = useSelector(unreadCount);
let chatBadge = null;
if (boundUnreadCount > 0 && !isCalendarOpen) {
if (boundUnreadCount < 100) {
chatBadge = {boundUnreadCount} ;
} else {
const classes = classNames(css.chatBadge, css.chatBadgePlus);
chatBadge = 99+ ;
}
}
return (
);
}
export default CommunityPicker;
diff --git a/web/socket.react.js b/web/socket.react.js
index cf37ec93e..7e2356469 100644
--- a/web/socket.react.js
+++ b/web/socket.react.js
@@ -1,117 +1,117 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
-import { useDispatch } from 'react-redux';
import { useLogOut } from 'lib/actions/user-actions.js';
import { preRequestUserStateForSingleKeyserverSelector } from 'lib/selectors/account-selectors.js';
import {
cookieSelector,
urlPrefixSelector,
connectionSelector,
lastCommunicatedPlatformDetailsSelector,
} from 'lib/selectors/keyserver-selectors.js';
import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js';
import Socket, { type BaseSocketProps } from 'lib/socket/socket.react.js';
import { useDispatchActionPromise } from 'lib/utils/action-utils.js';
+import { useDispatch } from 'lib/utils/redux-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import {
useGetSignedIdentityKeysBlob,
useWebNotificationsSessionCreator,
} from './account/account-hooks.js';
import { useSelector } from './redux/redux-utils.js';
import {
activeThreadSelector,
webCalendarQuery,
} from './selectors/nav-selectors.js';
import {
openSocketSelector,
sessionIdentificationSelector,
webGetClientResponsesSelector,
webSessionStateFuncSelector,
} from './selectors/socket-selectors.js';
import { decompressMessage } from './utils/decompress.js';
const WebSocket: React.ComponentType =
React.memo(function WebSocket(props) {
const cookie = useSelector(cookieSelector(ashoatKeyserverID));
const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID));
invariant(urlPrefix, 'missing urlPrefix for given keyserver id');
const connection = useSelector(connectionSelector(ashoatKeyserverID));
invariant(connection, 'keyserver missing from keyserverStore');
const active = useSelector(
state =>
!!state.currentUserInfo &&
!state.currentUserInfo.anonymous &&
state.lifecycleState !== 'background',
);
const openSocket = useSelector(openSocketSelector(ashoatKeyserverID));
invariant(openSocket, 'openSocket failed to be created');
const sessionIdentification = useSelector(
sessionIdentificationSelector(ashoatKeyserverID),
);
const preRequestUserState = useSelector(
preRequestUserStateForSingleKeyserverSelector(ashoatKeyserverID),
);
const getSignedIdentityKeysBlob = useGetSignedIdentityKeysBlob();
const webNotificationsSessionCreator = useWebNotificationsSessionCreator();
const getInitialNotificationsEncryptedMessage =
useInitialNotificationsEncryptedMessage(webNotificationsSessionCreator);
const getClientResponses = useSelector(state =>
webGetClientResponsesSelector({
state,
getSignedIdentityKeysBlob,
getInitialNotificationsEncryptedMessage,
}),
);
const sessionStateFunc = useSelector(
webSessionStateFuncSelector(ashoatKeyserverID),
);
const currentCalendarQuery = useSelector(webCalendarQuery);
const reduxActiveThread = useSelector(activeThreadSelector);
const windowActive = useSelector(state => state.windowActive);
const activeThread = React.useMemo(() => {
if (!active || !windowActive) {
return null;
}
return reduxActiveThread;
}, [active, windowActive, reduxActiveThread]);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callLogOut = useLogOut();
const lastCommunicatedPlatformDetails = useSelector(
lastCommunicatedPlatformDetailsSelector(ashoatKeyserverID),
);
return (
);
});
export default WebSocket;