diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 8bfc65a61..55411f9b8 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,439 +1,440 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { serverCallStateSelector } from '../selectors/server-calls'; import { loginActionSources, type LogInActionSource, type LogInStartingPayload, type LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; import callServerEndpoint from './call-server-endpoint'; import type { CallServerEndpoint, CallServerEndpointOptions, } from './call-server-endpoint'; import { getConfig } from './config'; let nextPromiseIndex = 0; export type ActionTypes< STARTED_ACTION_TYPE: string, SUCCESS_ACTION_TYPE: string, FAILED_ACTION_TYPE: string, > = { started: STARTED_ACTION_TYPE, success: SUCCESS_ACTION_TYPE, failed: FAILED_ACTION_TYPE, }; function wrapActionPromise< STARTED_ACTION_TYPE: string, STARTED_PAYLOAD: ActionPayload, SUCCESS_ACTION_TYPE: string, SUCCESS_PAYLOAD: ActionPayload, FAILED_ACTION_TYPE: string, >( actionTypes: ActionTypes< STARTED_ACTION_TYPE, SUCCESS_ACTION_TYPE, FAILED_ACTION_TYPE, >, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?STARTED_PAYLOAD, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!loadingOptions?.trackMultipleRequests, customKeyName: loadingOptions?.customKeyName || null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, payload: (startingPayload: STARTED_PAYLOAD), } : { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: SUCCESS_ACTION_TYPE), payload: (result: SUCCESS_PAYLOAD), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: FAILED_ACTION_TYPE), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise(): DispatchActionPromise { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = { +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, }; let currentlyWaitingForNewCookie = false; let serverEndpointCallsWaitingForNewCookie: (( callServerEndpoint: ?CallServerEndpoint, ) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, source }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, source: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let callServerEndpointCallback = null; const boundCallServerEndpoint = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, source); }; try { const result = await callServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, source }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise(r => (callServerEndpointCallback = r)); }; await resolveInvalidatedCookie( boundCallServerEndpoint, dispatchRecoveryAttempt, source, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoCallServerEndpoint( params: BindServerCallsParams, ): CallServerEndpoint { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, loginActionSources.cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = serverEndpointCallsWaitingForNewCookie; serverEndpointCallsWaitingForNewCookie = []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response invalidating // its cookie, and is wondering if it should just like... give up? // Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; +export type BindServerCall = (serverCall: ActionFunc) => F; type BindServerCallsParams = { dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, }; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to callServerEndpoint. // Instead, we "curry" the cookie onto callServerEndpoint within react-redux's // connect's mapStateToProps function, and then pass that "bound" // callServerEndpoint that no longer needs the cookie as a parameter on to // the server call. const baseCreateBoundServerCallsSelector = ( actionFunc: ActionFunc, ): (BindServerCallsParams => F) => createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundCallServerEndpoint = bindCookieAndUtilsIntoCallServerEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return actionFunc(boundCallServerEndpoint); }, ); type CreateBoundServerCallsSelectorType = ( ActionFunc, ) => BindServerCallsParams => F; const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize( baseCreateBoundServerCallsSelector, ): any); function useServerCall(serverCall: ActionFunc): F { const dispatch = useDispatch(); const serverCallState = useSelector(serverCallStateSelector); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, registerActiveSocket, useServerCall, }; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 5739f663d..ee6c7a968 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,106 +1,106 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import { removeUsersFromThread, changeThreadMemberRoles, } from 'lib/actions/thread-actions'; import { memberIsAdmin, removeMemberFromThread, switchMemberAdminRoleInThread, } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; -import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; +import type { DispatchFunctions, BindServerCall } from 'lib/utils/action-utils'; import { createTooltip, type TooltipParams, type TooltipRoute, type BaseTooltipProps, } from '../../navigation/tooltip.react'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{ +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, }>; function onRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = bindServerCall(removeUsersFromThread); const onConfirmRemoveUser = () => removeMemberFromThread( threadInfo, memberInfo, dispatchFunctions.dispatchActionPromise, boundRemoveUsersFromThread, ); const userText = stringForUser(memberInfo); Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this chat?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } function onToggleAdmin( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, ) { const { memberInfo, threadInfo } = route.params; const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = bindServerCall(changeThreadMemberRoles); const onConfirmMakeAdmin = () => switchMemberAdminRoleInThread( threadInfo, memberInfo, isCurrentlyAdmin, dispatchFunctions.dispatchActionPromise, boundChangeThreadMemberRoles, ); const userText = stringForUser(memberInfo); const actionClause = isCurrentlyAdmin ? `remove ${userText} as an admin` : `make ${userText} an admin`; Alert.alert( 'Confirm action', `Are you sure you want to ${actionClause} of this chat?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmMakeAdmin }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'remove_user', text: 'Remove user', onPress: onRemoveUser }, { id: 'remove_admin', text: 'Remove admin', onPress: onToggleAdmin }, { id: 'make_admin', text: 'Make admin', onPress: onToggleAdmin }, ], }; const ThreadSettingsMemberTooltipModal: React.ComponentType< BaseTooltipProps<'ThreadSettingsMemberTooltipModal'>, > = createTooltip<'ThreadSettingsMemberTooltipModal'>( ThreadSettingsMemberTooltipButton, spec, ); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index 29c65abd4..555d773c3 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,79 +1,79 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createPendingSidebar } from 'lib/shared/thread-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; -import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; +import type { DispatchFunctions, BindServerCall } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { MessageTooltipRouteNames } from '../navigation/route-names'; import type { TooltipRoute } from '../navigation/tooltip.react'; import { useSelector } from '../redux/redux-utils'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types'; import type { ChatContextType } from './chat-context'; import { createNavigateToThreadAction, useNavigateToThread, } from './message-list-types'; function getSidebarThreadInfo( sourceMessage: ChatMessageInfoItemWithHeight, viewerID?: ?string, ): ?ThreadInfo { const threadCreatedFromMessage = sourceMessage.threadCreatedFromMessage; if (threadCreatedFromMessage) { return threadCreatedFromMessage; } if (!viewerID) { return null; } const { messageInfo, threadInfo } = sourceMessage; return createPendingSidebar( messageInfo, threadInfo, viewerID, getDefaultTextMessageRules().simpleMarkdownRules, ); } function navigateToSidebar( route: TooltipRoute, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, inputState: ?InputState, navigation: AppNavigationProp, viewerID: ?string, chatContext: ?ChatContextType, ) { invariant(viewerID, 'viewerID should be set'); const threadInfo = getSidebarThreadInfo(route.params.item, viewerID); invariant(threadInfo, 'threadInfo should be set'); chatContext?.setCurrentTransitionSidebarSourceID( route.params.item.messageInfo.id, ); navigation.navigate(createNavigateToThreadAction({ threadInfo })); } function useNavigateToSidebar(item: ChatMessageInfoItemWithHeight): () => void { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadInfo = React.useMemo(() => getSidebarThreadInfo(item, viewerID), [ item, viewerID, ]); const navigateToThread = useNavigateToThread(); return React.useCallback(() => { invariant(threadInfo, 'threadInfo should be set'); navigateToThread({ threadInfo }); }, [navigateToThread, threadInfo]); } export { navigateToSidebar, getSidebarThreadInfo, useNavigateToSidebar }; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index 6c240006c..5039ba6ff 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,76 +1,76 @@ // @flow import Clipboard from '@react-native-community/clipboard'; import invariant from 'invariant'; import * as React from 'react'; import { createMessageReply } from 'lib/shared/message-utils'; -import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; +import type { DispatchFunctions, BindServerCall } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, type BaseTooltipProps, } from '../navigation/tooltip.react'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types'; import { navigateToSidebar } from './sidebar-navigation'; import TextMessageTooltipButton from './text-message-tooltip-button.react'; export type TextMessageTooltipModalParams = TooltipParams<{ +item: ChatTextMessageInfoItemWithHeight, }>; const confirmCopy = () => displayActionResultModal('copied!'); const confirmReport = () => displayActionResultModal('reported to admin'); function onPressCopy(route: TooltipRoute<'TextMessageTooltipModal'>) { Clipboard.setString(route.params.item.messageInfo.text); setTimeout(confirmCopy); } function onPressReply( route: TooltipRoute<'TextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, inputState: ?InputState, ) { invariant( inputState, 'inputState should be set in TextMessageTooltipModal.onPressReply', ); inputState.addReply(createMessageReply(route.params.item.messageInfo.text)); } const spec = { entries: [ { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, { id: 'report', text: 'Report', onPress: confirmReport, }, { id: 'create_sidebar', text: 'Create thread', onPress: navigateToSidebar, }, { id: 'open_sidebar', text: 'Go to thread', onPress: navigateToSidebar, }, ], }; const TextMessageTooltipModal: React.ComponentType< BaseTooltipProps<'TextMessageTooltipModal'>, > = createTooltip<'TextMessageTooltipModal'>(TextMessageTooltipButton, spec); const textMessageTooltipHeight: number = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/native/navigation/tooltip.react.js b/native/navigation/tooltip.react.js index ce20b2781..f9a5bee33 100644 --- a/native/navigation/tooltip.react.js +++ b/native/navigation/tooltip.react.js @@ -1,551 +1,552 @@ // @flow import type { LeafRoute } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, TouchableWithoutFeedback, Platform, TouchableOpacity, } from 'react-native'; import Animated from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { type ServerCallState, serverCallStateSelector, } from 'lib/selectors/server-calls'; import type { Dispatch } from 'lib/types/redux-types'; import { createBoundServerCallsSelector, useDispatchActionPromise, type DispatchActionPromise, type ActionFunc, + type BindServerCall, type DispatchFunctions, } from 'lib/utils/action-utils'; import { ChatContext, type ChatContextType } from '../chat/chat-context'; import { SingleLine } from '../components/single-line.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { type DimensionsInfo } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { type VerticalBounds, type LayoutCoordinates, } from '../types/layout-types'; import type { LayoutEvent } from '../types/react-native'; import { AnimatedView, type ViewStyle, type TextStyle } from '../types/styles'; import type { AppNavigationProp } from './app-navigator.react'; import { OverlayContext, type OverlayContextType } from './overlay-context'; import type { TooltipModalParamList } from './route-names'; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Extrapolate, add, multiply, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ export type TooltipEntry> = { +id: string, +text: string, +onPress: ( route: TooltipRoute, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, inputState: ?InputState, navigation: AppNavigationProp, viewerID: ?string, chatContext: ?ChatContextType, ) => mixed, }; type TooltipItemProps = { +spec: TooltipEntry, +onPress: (entry: TooltipEntry) => void, +containerStyle?: ViewStyle, +labelStyle?: TextStyle, }; type TooltipSpec = { +entries: $ReadOnlyArray>, +labelStyle?: ViewStyle, }; export type TooltipParams = { ...CustomProps, +presentedFrom: string, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +location?: 'above' | 'below', +margin?: number, +visibleEntryIDs?: $ReadOnlyArray, }; export type TooltipRoute> = { ...LeafRoute, +params: $ElementType, }; export type BaseTooltipProps = { +navigation: AppNavigationProp, +route: TooltipRoute, }; type ButtonProps = { ...Base, +progress: Node, +isOpeningSidebar: boolean, }; type TooltipProps = { ...Base, // Redux state +dimensions: DimensionsInfo, +serverCallState: ServerCallState, +viewerID: ?string, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // withOverlayContext +overlayContext: ?OverlayContextType, // withInputState +inputState: ?InputState, +chatContext: ?ChatContextType, }; function createTooltip< RouteName: $Keys, BaseTooltipPropsType: BaseTooltipProps = BaseTooltipProps, >( ButtonComponent: React.ComponentType>, tooltipSpec: TooltipSpec, ): React.ComponentType { class TooltipItem extends React.PureComponent> { render() { return ( {this.props.spec.text} ); } onPress = () => { this.props.onPress(this.props.spec); }; } class Tooltip extends React.PureComponent< TooltipProps, > { backdropOpacity: Node; tooltipContainerOpacity: Node; tooltipVerticalAbove: Node; tooltipVerticalBelow: Node; tooltipHorizontalOffset: Value = new Value(0); tooltipHorizontal: Node; tooltipScale: Node; constructor(props: TooltipProps) { super(props); const { overlayContext } = props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; this.backdropOpacity = interpolateNode(position, { inputRange: [0, 1], outputRange: [0, 0.7], extrapolate: Extrapolate.CLAMP, }); this.tooltipContainerOpacity = interpolateNode(position, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const { margin } = this; this.tooltipVerticalAbove = interpolateNode(position, { inputRange: [0, 1], outputRange: [margin + this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipVerticalBelow = interpolateNode(position, { inputRange: [0, 1], outputRange: [-margin - this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipHorizontal = multiply( add(1, multiply(-1, position)), this.tooltipHorizontalOffset, ); this.tooltipScale = interpolateNode(position, { inputRange: [0, 0.2, 0.8, 1], outputRange: [0, 0, 1, 1], extrapolate: Extrapolate.CLAMP, }); } componentDidMount() { Haptics.impactAsync(); } get entries(): $ReadOnlyArray> { const { entries } = tooltipSpec; const { visibleEntryIDs } = this.props.route.params; if (!visibleEntryIDs) { return entries; } const visibleSet = new Set(visibleEntryIDs); return entries.filter(entry => visibleSet.has(entry.id)); } get tooltipHeight(): number { return tooltipHeight(this.entries.length); } get location(): 'above' | 'below' { const { params } = this.props.route; const { location } = params; if (location) { return location; } const { initialCoordinates, verticalBounds } = params; const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const { margin, tooltipHeight: curTooltipHeight } = this; const fullHeight = curTooltipHeight + margin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; } get opacityStyle() { return { ...styles.backdrop, opacity: this.backdropOpacity, }; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...styles.contentContainer, marginTop: top, marginBottom: bottom, }; } get buttonStyle() { const { params } = this.props.route; const { initialCoordinates, verticalBounds } = params; const { x, y, width, height } = initialCoordinates; return { width: Math.ceil(width), height: Math.ceil(height), marginTop: y - verticalBounds.y, marginLeft: x, }; } get margin() { const customMargin = this.props.route.params.margin; return customMargin !== null && customMargin !== undefined ? customMargin : 20; } get tooltipContainerStyle() { const { dimensions, route } = this.props; const { initialCoordinates, verticalBounds } = route.params; const { x, y, width, height } = initialCoordinates; const { margin, location } = this; const style = {}; style.position = 'absolute'; (style.alignItems = 'center'), (style.opacity = this.tooltipContainerOpacity); style.transform = [{ translateX: this.tooltipHorizontal }]; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; style.minWidth = width + 2 * extraLeftSpace; } else { style.right = 0; style.minWidth = width + 2 * extraRightSpace; } if (location === 'above') { const fullScreenHeight = dimensions.height; style.bottom = fullScreenHeight - Math.max(y, verticalBounds.y) + margin; style.transform.push({ translateY: this.tooltipVerticalAbove }); } else { style.top = Math.min(y + height, verticalBounds.y + verticalBounds.height) + margin; style.transform.push({ translateY: this.tooltipVerticalBelow }); } style.transform.push({ scale: this.tooltipScale }); return style; } render() { const { dimensions, serverCallState, viewerID, dispatch, dispatchActionPromise, overlayContext, inputState, chatContext, ...navAndRouteForFlow } = this.props; const { entries } = this; const items = entries.map((entry, index) => { const style = index !== entries.length - 1 ? styles.itemMargin : null; return ( ); }); let triangleStyle; const { route } = this.props; const { initialCoordinates } = route.params; const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { triangleStyle = { alignSelf: 'flex-start', left: extraLeftSpace + (width - 20) / 2, }; } else { triangleStyle = { alignSelf: 'flex-end', right: extraRightSpace + (width - 20) / 2, }; } let triangleDown = null; let triangleUp = null; const { location } = this; if (location === 'above') { triangleDown = ; } else { triangleUp = ; } invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; const isOpeningSidebar = !!chatContext?.currentTransitionSidebarSourceID; const buttonProps: ButtonProps = { ...navAndRouteForFlow, progress: position, isOpeningSidebar, }; return ( {triangleUp} {items} {triangleDown} ); } onPressBackdrop = () => { this.props.navigation.goBackOnce(); }; onPressEntry = (entry: TooltipEntry) => { this.props.navigation.goBackOnce(); const dispatchFunctions = { dispatch: this.props.dispatch, dispatchActionPromise: this.props.dispatchActionPromise, }; entry.onPress( this.props.route, dispatchFunctions, this.bindServerCall, this.props.inputState, this.props.navigation, this.props.viewerID, this.props.chatContext, ); }; bindServerCall = (serverCall: ActionFunc): F => { const { cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = this.props.serverCallState; return createBoundServerCallsSelector(serverCall)({ dispatch: this.props.dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); }; onTooltipContainerLayout = (event: LayoutEvent) => { const { route, dimensions } = this.props; const { x, width } = route.params.initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; const actualWidth = event.nativeEvent.layout.width; if (extraLeftSpace < extraRightSpace) { const minWidth = width + 2 * extraLeftSpace; this.tooltipHorizontalOffset.setValue((minWidth - actualWidth) / 2); } else { const minWidth = width + 2 * extraRightSpace; this.tooltipHorizontalOffset.setValue((actualWidth - minWidth) / 2); } }; } return React.memo(function ConnectedTooltip( props: BaseTooltipPropsType, ) { const dimensions = useSelector(state => state.dimensions); const serverCallState = useSelector(serverCallStateSelector); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const overlayContext = React.useContext(OverlayContext); const inputState = React.useContext(InputStateContext); const chatContext = React.useContext(ChatContext); return ( ); }); } const styles = StyleSheet.create({ backdrop: { backgroundColor: 'black', bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, container: { flex: 1, }, contentContainer: { flex: 1, overflow: 'hidden', }, itemContainer: { padding: 10, }, itemMargin: { borderBottomColor: '#E1E1E1', borderBottomWidth: 1, }, items: { backgroundColor: 'white', borderRadius: 5, overflow: 'hidden', }, label: { color: '#444', fontSize: 14, lineHeight: 17, textAlign: 'center', }, triangleDown: { borderBottomColor: 'transparent', borderBottomWidth: 0, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'white', borderTopWidth: 10, height: 10, top: Platform.OS === 'android' ? -1 : 0, width: 10, }, triangleUp: { borderBottomColor: 'white', borderBottomWidth: 10, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'transparent', borderTopWidth: 0, bottom: Platform.OS === 'android' ? -1 : 0, height: 10, width: 10, }, }); function tooltipHeight(numEntries: number): number { // 10 (triangle) + 37 * numEntries (entries) + numEntries - 1 (padding) return 9 + 38 * numEntries; } export { createTooltip, tooltipHeight }; diff --git a/native/profile/relationship-list-item-tooltip-modal.react.js b/native/profile/relationship-list-item-tooltip-modal.react.js index 301ea4ab8..ee1764840 100644 --- a/native/profile/relationship-list-item-tooltip-modal.react.js +++ b/native/profile/relationship-list-item-tooltip-modal.react.js @@ -1,130 +1,130 @@ // @flow import * as React from 'react'; import { Alert, TouchableOpacity } from 'react-native'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { stringForUser } from 'lib/shared/user-utils'; import type { RelativeUserInfo } from 'lib/types/user-types'; -import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; +import type { DispatchFunctions, BindServerCall } from 'lib/utils/action-utils'; import PencilIcon from '../components/pencil-icon.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { createTooltip, type TooltipParams, type BaseTooltipProps, } from '../navigation/tooltip.react'; type Action = 'unfriend' | 'unblock'; export type RelationshipListItemTooltipModalParams = TooltipParams<{ +relativeUserInfo: RelativeUserInfo, }>; type OnRemoveUserProps = { ...RelationshipListItemTooltipModalParams, +action: Action, }; function onRemoveUser( props: OnRemoveUserProps, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => F, + bindServerCall: BindServerCall, ) { const boundRemoveRelationships = bindServerCall(updateRelationships); const callRemoveRelationships = async () => { try { return await boundRemoveRelationships({ action: props.action, userIDs: [props.relativeUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } }; const onConfirmRemoveUser = () => { const customKeyName = `${updateRelationshipsActionTypes.started}:${props.relativeUserInfo.id}`; dispatchFunctions.dispatchActionPromise( updateRelationshipsActionTypes, callRemoveRelationships(), { customKeyName }, ); }; const userText = stringForUser(props.relativeUserInfo); const action = { unfriend: 'removal', unblock: 'unblock', }[props.action]; const message = { unfriend: `remove ${userText} from friends?`, unblock: `unblock ${userText}?`, }[props.action]; Alert.alert( `Confirm ${action}`, `Are you sure you want to ${message}`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'unfriend', text: 'Unfriend', onPress: (route, dispatchFunctions, bindServerCall) => onRemoveUser( { ...route.params, action: 'unfriend' }, dispatchFunctions, bindServerCall, ), }, { id: 'unblock', text: 'Unblock', onPress: (route, dispatchFunctions, bindServerCall) => onRemoveUser( { ...route.params, action: 'unblock' }, dispatchFunctions, bindServerCall, ), }, ], }; type Props = { +navigation: AppNavigationProp<'RelationshipListItemTooltipModal'>, ... }; class RelationshipListItemTooltipButton extends React.PureComponent { render() { return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } const RelationshipListItemTooltipModal: React.ComponentType< BaseTooltipProps<'RelationshipListItemTooltipModal'>, > = createTooltip<'RelationshipListItemTooltipModal'>( RelationshipListItemTooltipButton, spec, ); export default RelationshipListItemTooltipModal;