diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 84a297ad0..2c681ea7b 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,822 +1,822 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, Platform, TouchableWithoutFeedback, LayoutAnimation, Keyboard, } from 'react-native'; import { useDispatch } from 'react-redux'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-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 { CreateEntryInfo, SaveEntryInfo, SaveEntryResult, SaveEntryPayload, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ResolvedThreadInfo, } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } 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 sleep from 'lib/utils/sleep.js'; import type { EntryInfoWithHeight } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type SharedProps = { +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterCreation: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef: (textInput: ?React.ElementRef) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } 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 becomes // inactive 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; } } this.guardedSetState({ loadingStatus: 'loading' }); 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++; 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); } 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++; 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 revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string): Promise { 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; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const connection = useSelector(connectionSelector); invariant(connection, 'keyserver missing from keyserverStore'); const online = connection.status === 'connected'; const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useServerCall(createEntry); const callSaveEntry = useServerCall(saveEntry); const callDeleteEntry = useServerCall(deleteEntry); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 9e058dd5c..af825bf20 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1414 +1,1414 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, type Selection, getMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { useNextLocalID, trimMessage, useMessagePreview, messageKey, type MessagePreviewResult, } from 'lib/shared/message-utils.js'; import SearchIndex from 'lib/shared/search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SendEditMessageResponse, MessageInfo, } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext, type EditInputBarMessageParameters, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { type NavigationRoute, ChatCameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: string, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise, +inputState: ?InputState, +userSearchIndex: SearchIndex, +mentionsCandidates: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: ( messageID: string, text: string, ) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, +isExitingDuringEditMode: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, isExitingDuringEditMode: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { const { isActive, navigation } = this.props; if (isActive) { this.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( this.focusAndUpdateText, ); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches && !isEditMode) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', usernamePrefix: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.mentionsCandidates, this.props.viewerID, typeaheadMatchedStrings.usernamePrefix, ); if (suggestedUsers.length > 0) { typeaheadTooltip = ( ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can’t send messages to a user that you’ve blocked. ); } else if (isMember) { content = ( You don’t have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( You don’t have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle = [this.props.styles.innerExpandoButtons]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { if (this.state.isExitingDuringEditMode) { return; } this.setState({ text, textEdited: true }); this.props.messageEditingContext?.setEditedMessageChanged( this.isMessageEdited(text), ); if (this.isEditMode()) { return; } this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { this.editMessage(editedMessage.id, this.state.text); return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = this.props.nextLocalID; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); }; isEditMode = () => { const editState = this.props.messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState && editState.editedMessage !== null && isThisThread; }; isMessageEdited = newText => { let text = newText ?? this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; unblockNavigation = () => { const { navigation } = this.props; if (!navigation) { return; } navigation.setParams({ removeEditMode: null }); }; removeEditMode: RemoveEditMode = action => { const { navigation } = this.props; if (!navigation || this.state.isExitingDuringEditMode) { return 'ignore_action'; } if (!this.isMessageEdited()) { this.unblockNavigation(); return 'reduce_action'; } const unblockAndDispatch = () => { this.unblockNavigation(); navigation.dispatch(action); }; const onContinueEditing = () => { this.props.overlayContext?.resetScrollBlockingModalStatus(); }; exitEditAlert({ onDiscard: unblockAndDispatch, onContinueEditing, }); return 'ignore_action'; }; blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ removeEditMode: this.removeEditMode, }); }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = () => { const editState = this.props.messageEditingContext?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert({ onDiscard: this.exitEditMode, }); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.unblockNavigation(); this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationFocus = () => { this.setState({ isExitingDuringEditMode: false }); }; onNavigationBlur = () => { if (!this.isEditMode()) { return; } this.setState( { text: this.props.draft, isExitingDuringEditMode: true }, this.exitEditMode, ); }; onNavigationBeforeRemove = e => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.setState({ isExitingDuringEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded || this.isEditMode()) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useNextLocalID(); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const mentionsCandidates = getMentionsCandidates( props.threadInfo, parentThreadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const editMessage = useEditMessage(); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo(function ConnectedChatInputBar( props: ChatInputBarProps, ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index b9fa15bae..70c9660d1 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,221 +1,221 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import MessagePreview from './message-preview.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import ThreadAncestorsLabel from '../components/thread-ancestors-label.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +data: ChatThreadItem, +onPressItem: ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => void, +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, }; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const numOfSidebarsWithExtendedArrow = data.sidebars.filter(sidebarItem => sidebarItem.type === 'sidebar').length - 1; const sidebars = data.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return ; } }); const onPress = React.useCallback(() => { onPressItem(data.threadInfo, data.pendingPersonalThreadUserInfo); }, [onPressItem, data.threadInfo, data.pendingPersonalThreadUserInfo]); const threadNameStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.threadName; } return [styles.threadName, styles.unreadThreadName]; }, [ data.threadInfo.currentUser.unread, styles.threadName, styles.unreadThreadName, ]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const lastActivityStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.lastActivity; } return [styles.lastActivity, styles.unreadLastActivity]; }, [ data.threadInfo.currentUser.unread, styles.lastActivity, styles.unreadLastActivity, ]); const resolvedThreadInfo = useResolvedThreadInfo(data.threadInfo); return ( <> {sidebars} ); } const chatThreadListItemHeight = 70; const spacerHeight = 6; const unboundStyles = { container: { height: chatThreadListItemHeight, justifyContent: 'center', backgroundColor: 'listBackground', }, content: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, avatarContainer: { marginLeft: 6, marginBottom: 12, }, threadDetails: { paddingLeft: 12, paddingRight: 18, justifyContent: 'center', flex: 1, marginTop: 5, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, unreadLastActivity: { color: 'listForegroundLabel', fontWeight: 'bold', }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 14, fontStyle: 'italic', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 21, }, unreadThreadName: { color: 'listForegroundLabel', fontWeight: '500', }, spacer: { height: spacerHeight, }, }; export { ChatThreadListItem, chatThreadListItemHeight, spacerHeight }; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index 095f4a3b1..d0ba8408e 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,97 +1,97 @@ // @flow import { useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View } from 'react-native'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { clusterEndHeight, avatarOffset } from './chat-constants.js'; import type { DisplayType } from './timestamp.react.js'; import { Timestamp, timestampHeight } from './timestamp.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { MessageListRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; type Props = { +item: ChatMessageInfoItemWithHeight, +focused: boolean, +display: DisplayType, }; function MessageHeader(props: Props): React.Node { const styles = useStyles(unboundStyles); const { item, focused, display } = props; const { creator, time } = item.messageInfo; const { isViewer } = creator; const route = useRoute(); const modalDisplay = display === 'modal'; const shouldShowUsername = !isViewer && (modalDisplay || item.startsCluster); const stringForUser = useStringForUser(shouldShowUsername ? creator : null); let authorName = null; if (stringForUser) { const style = [styles.authorName]; if (modalDisplay) { style.push(styles.modal); } authorName = {stringForUser}; } // We only want to render the top-placed timestamp for a message if it's // rendered in the message list, and not any separate screens (i.e. // the MessageResultsScreen). const presentedFromMessageList = typeof route.params?.presentedFrom === 'string' && route.params.presentedFrom.startsWith(MessageListRouteName); const messageInMessageList = route.name === MessageListRouteName || presentedFromMessageList; const timestamp = messageInMessageList && (modalDisplay || item.startsConversation) ? ( ) : null; let style = null; if (focused && !modalDisplay) { let topMargin = 0; if (!item.startsCluster && !item.messageInfo.creator.isViewer) { topMargin += authorNameHeight + clusterEndHeight; } if (!item.startsConversation) { topMargin += timestampHeight; } style = { marginTop: topMargin }; } return ( {timestamp} {authorName} ); } const authorNameHeight = 25; const unboundStyles = { authorName: { bottom: 0, color: 'listBackgroundSecondaryLabel', fontSize: 14, height: authorNameHeight, marginRight: 7, paddingHorizontal: 12, paddingVertical: 4, marginLeft: 12 + avatarOffset, }, modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, }; export { MessageHeader, authorNameHeight }; diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index 62d3cb4ec..09c363a12 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,90 +1,90 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { type MessageInfo } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +messageInfo: MessageInfo, +threadInfo: ThreadInfo, }; function MessagePreview(props: Props): React.Node { const { messageInfo, threadInfo } = props; const messagePreviewResult = useMessagePreview( messageInfo, threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); invariant( messagePreviewResult, 'useMessagePreview should only return falsey if pass null or undefined', ); const { message, username } = messagePreviewResult; let messageStyle; const styles = useStyles(unboundStyles); if (message.style === 'unread') { messageStyle = styles.unread; } else if (message.style === 'primary') { messageStyle = styles.primary; } else if (message.style === 'secondary') { messageStyle = styles.secondary; } invariant( messageStyle, `MessagePreview doesn't support ${message.style} style for message, ` + 'only unread, primary, and secondary', ); if (!username) { return ( {message.text} ); } let usernameStyle; if (username.style === 'unread') { usernameStyle = styles.unread; } else if (username.style === 'secondary') { usernameStyle = styles.secondary; } invariant( usernameStyle, `MessagePreview doesn't support ${username.style} style for username, ` + 'only unread and secondary', ); return ( {`${username.text}: `} {message.text} ); } const unboundStyles = { lastMessage: { flex: 1, fontSize: 14, }, primary: { color: 'listForegroundTertiaryLabel', }, secondary: { color: 'listForegroundTertiaryLabel', }, unread: { color: 'listForegroundLabel', }, }; export default MessagePreview; diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index 3cc19c55a..49fb561b7 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,119 +1,119 @@ // @flow import * as React from 'react'; import { View, Switch } from 'react-native'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; -import { SingleLine } from '../../components/single-line.react.js'; +import SingleLine from '../../components/single-line.react.js'; import { useStyles } from '../../themes/colors.js'; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsHomeNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render() { const componentLabel = 'Background'; return ( {componentLabel} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; const ConnectedThreadSettingsHomeNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); export default ConnectedThreadSettingsHomeNotifs; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index 9d49c89c4..b4a0ad782 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,291 +1,291 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getAvailableThreadMemberActions } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import PencilIcon from '../../components/pencil-icon.react.js'; -import { SingleLine } from '../../components/single-line.react.js'; +import SingleLine from '../../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; type BaseProps = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( {userText} ); } else { usernameInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } const roleName = this.props.memberInfo.role && this.props.threadInfo.roles[this.props.memberInfo.role].name; const roleInfo = ( {roleName} ); const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastInnerContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, innerContainer: { flex: 1, paddingHorizontal: 12, paddingVertical: 8, }, lastInnerContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, }; const ConnectedThreadSettingsMember: React.ComponentType = React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index fdad78302..5b9131763 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,247 +1,247 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, ActivityIndicator, TextInput as BaseTextInput, View, } from 'react-native'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { chatNameMaxLength } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ResolvedThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { firstLine } from 'lib/utils/string-utils.js'; import SaveSettingButton from './save-setting-button.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; -import { SingleLine } from '../../components/single-line.react.js'; +import SingleLine from '../../components/single-line.react.js'; import TextInput from '../../components/text-input.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; type BaseProps = { +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +setNameEditValue: (value: ?string, callback?: () => void) => void, +canChangeSettings: boolean, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; class ThreadSettingsName extends React.PureComponent { textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderButton() { if (this.props.loadingStatus === 'loading') { return ( ); } else if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( ); } return ; } renderContent() { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} {this.renderButton()} ); } return ( {this.renderButton()} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; threadEditName() { return firstLine( this.props.threadInfo.name ? this.props.threadInfo.name : '', ); } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = firstLine(this.props.nameEditValue); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); const action = changeThreadSettingsActionTypes.started; const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${action}:${threadID}:name`, }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const unboundStyles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const ConnectedThreadSettingsName: React.ComponentType = React.memo(function ConnectedThreadSettingsName(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const threadID = props.threadInfo.id; const loadingStatus = useSelector( createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, ), ); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); export default ConnectedThreadSettingsName; diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index 88786df8f..87d94fdf4 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,194 +1,194 @@ // @flow import * as React from 'react'; import { View, Switch, TouchableOpacity, Platform } from 'react-native'; import Linking from 'react-native/Libraries/Linking/Linking.js'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { DispatchActionPromise } from 'lib/utils/action-utils.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; -import { SingleLine } from '../../components/single-line.react.js'; +import SingleLine from '../../components/single-line.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import { CommAndroidNotifications } from '../../push/android.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +hasPushPermissions: boolean, +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, }; type State = { +currentValue: boolean, }; class ThreadSettingsPushNotifs extends React.PureComponent { constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render() { const componentLabel = 'Push notifs'; let notificationsSettingsLinkingIcon = undefined; if (!this.props.hasPushPermissions) { notificationsSettingsLinkingIcon = ( ); } return ( {componentLabel} {notificationsSettingsLinkingIcon} ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; onNotificationsSettingsLinkingIconPress = async () => { let platformRequestsPermission; if (Platform.OS !== 'android') { platformRequestsPermission = true; } else { platformRequestsPermission = await CommAndroidNotifications.canRequestNotificationsPermissionFromUser(); } const alertTitle = platformRequestsPermission ? 'Need notif permissions' : 'Unable to initialize notifs'; const notificationsSettingsPath = Platform.OS === 'ios' ? 'Settings App → Notifications → Comm' : 'Settings → Apps → Comm → Notifications'; let alertMessage; if (platformRequestsPermission && this.state.currentValue) { alertMessage = 'Notifs for this chat are enabled, but cannot be delivered ' + 'to this device because you haven’t granted notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else if (platformRequestsPermission) { alertMessage = 'In order to enable push notifs for this chat, ' + 'you need to first grant notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else { alertMessage = 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.'; } Alert.alert(alertTitle, alertMessage, [ { text: 'Go to settings', onPress: () => Linking.openSettings(), }, { text: 'Cancel', style: 'cancel', }, ]); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, infoIcon: { paddingRight: 20, }, }; const ConnectedThreadSettingsPushNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); const hasPushPermissions = useSelector( state => state.deviceToken !== null && state.deviceToken !== undefined, ); return ( ); }); export default ConnectedThreadSettingsPushNotifs; diff --git a/native/chat/sidebar-item.react.js b/native/chat/sidebar-item.react.js index 18987a932..f1f1dde90 100644 --- a/native/chat/sidebar-item.react.js +++ b/native/chat/sidebar-item.react.js @@ -1,58 +1,58 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { SidebarInfo } from 'lib/types/thread-types.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +sidebarInfo: SidebarInfo, }; function SidebarItem(props: Props): React.Node { const { lastUpdatedTime } = props.sidebarInfo; const lastActivity = shortAbsoluteDate(lastUpdatedTime); const { threadInfo } = props.sidebarInfo; const { uiName } = useResolvedThreadInfo(threadInfo); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; return ( {uiName} {lastActivity} ); } const sidebarHeight = 30; const unboundStyles = { itemContainer: { flexDirection: 'row', height: sidebarHeight, alignItems: 'center', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, }; export { SidebarItem, sidebarHeight }; diff --git a/native/chat/subchannel-item.react.js b/native/chat/subchannel-item.react.js index eed20aaa7..f25727da1 100644 --- a/native/chat/subchannel-item.react.js +++ b/native/chat/subchannel-item.react.js @@ -1,109 +1,109 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import MessagePreview from './message-preview.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +subchannelInfo: ChatThreadItem, }; function SubchannelItem(props: Props): React.Node { const { lastUpdatedTime, threadInfo, mostRecentMessageInfo } = props.subchannelInfo; const { uiName } = useResolvedThreadInfo(threadInfo); const lastActivity = shortAbsoluteDate(lastUpdatedTime); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const lastMessage = React.useMemo(() => { if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [mostRecentMessageInfo, threadInfo, styles]); return ( {uiName} {lastMessage} {lastActivity} ); } const fontSize = 14; const unboundStyles = { outerView: { flex: 1, flexDirection: 'column', justifyContent: 'center', paddingVertical: 8, paddingHorizontal: 8, height: 60, }, itemRowContainer: { flexDirection: 'row', alignItems: 'center', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingBottom: 8, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize, marginLeft: 10, }, iconWrapper: { marginRight: 8, alignItems: 'center', }, icon: { fontSize: 22, color: 'listForegroundSecondaryLabel', alignItems: 'center', height: 24, }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize, fontStyle: 'italic', }, }; export default SubchannelItem; diff --git a/native/chat/timestamp.react.js b/native/chat/timestamp.react.js index 0e07dd7f0..e2c177790 100644 --- a/native/chat/timestamp.react.js +++ b/native/chat/timestamp.react.js @@ -1,56 +1,56 @@ // @flow import * as React from 'react'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; export type DisplayType = 'lowContrast' | 'modal'; type Props = { +time: number, +display: DisplayType, }; function Timestamp(props: Props): React.Node { const styles = useStyles(unboundStyles); const style = React.useMemo( () => props.display === 'modal' ? [styles.timestamp, styles.modal] : [styles.timestamp], [props.display, styles.modal, styles.timestamp], ); const absoluteDate = React.useMemo( () => longAbsoluteDate(props.time), [props.time], ); const timestamp = React.useMemo( () => {absoluteDate}, [absoluteDate, style], ); return timestamp; } const timestampHeight = 26; const unboundStyles = { modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, timestamp: { alignSelf: 'center', bottom: 0, color: 'listBackgroundTernaryLabel', fontSize: 14, height: timestampHeight, paddingVertical: 3, }, }; export { Timestamp, timestampHeight }; diff --git a/native/components/single-line.react.js b/native/components/single-line.react.js index 196fe2e66..ec91ac3fa 100644 --- a/native/components/single-line.react.js +++ b/native/components/single-line.react.js @@ -1,21 +1,21 @@ // @flow import * as React from 'react'; import { Text } from 'react-native'; import { firstLine } from 'lib/utils/string-utils.js'; type Props = { ...React.ElementConfig, +children: ?string, }; function SingleLine(props: Props): React.Node { const text = firstLine(props.children); return ( {text} ); } -export { SingleLine }; +export default SingleLine; diff --git a/native/components/thread-list-thread.react.js b/native/components/thread-list-thread.react.js index 34be24323..2330d03f2 100644 --- a/native/components/thread-list-thread.react.js +++ b/native/components/thread-list-thread.react.js @@ -1,86 +1,86 @@ // @flow import * as React from 'react'; import type { ThreadInfo, ResolvedThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from './button.react.js'; -import { SingleLine } from './single-line.react.js'; +import SingleLine from './single-line.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; type SharedProps = { +onSelect: (threadID: string) => void, +style?: ViewStyle, +textStyle?: TextStyle, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, +colors: Colors, +styles: typeof unboundStyles, }; class ThreadListThread extends React.PureComponent { render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.threadInfo.id); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', paddingLeft: 13, }, text: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 9, paddingRight: 12, paddingVertical: 6, }, }; const ConnectedThreadListThread: React.ComponentType = React.memo(function ConnectedThreadListThread(props: BaseProps) { const { threadInfo, ...rest } = props; const styles = useStyles(unboundStyles); const colors = useColors(); const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); return ( ); }); export default ConnectedThreadListThread; diff --git a/native/components/user-list-user.react.js b/native/components/user-list-user.react.js index ca8514532..413650828 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,98 +1,98 @@ // @flow import * as React from 'react'; import { Text, Platform } from 'react-native'; import type { UserListItem, AccountUserInfo } from 'lib/types/user-types.js'; import Button from './button.react.js'; -import { SingleLine } from './single-line.react.js'; +import SingleLine from './single-line.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { TextStyle } from '../types/styles.js'; import Alert from '../utils/alert.js'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem): number => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type BaseProps = { +userInfo: UserListItem, +onSelect: (user: AccountUserInfo) => void, +textStyle?: TextStyle, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, }; class UserListUser extends React.PureComponent { render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alert) { const { alert, notice, disabled, ...accountUserInfo } = userInfo; this.props.onSelect(accountUserInfo); return; } Alert.alert(userInfo.alert.title, userInfo.alert.text, [{ text: 'OK' }], { cancelable: true, }); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const ConnectedUserListUser: React.ComponentType = React.memo(function ConnectedUserListUser(props: BaseProps) { const colors = useColors(); const styles = useStyles(unboundStyles); return ; }); export { ConnectedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/components/user-profile.react.js b/native/components/user-profile.react.js index f08443900..5ef366568 100644 --- a/native/components/user-profile.react.js +++ b/native/components/user-profile.react.js @@ -1,117 +1,117 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; import type { AccountUserInfo } from 'lib/types/user-types'; import sleep from 'lib/utils/sleep.js'; import SWMansionIcon from './swmansion-icon.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +userInfo: AccountUserInfo, }; function UserProfile(props: Props): React.Node { const { userInfo } = props; const [usernameCopied, setUsernameCopied] = React.useState(false); const styles = useStyles(unboundStyles); const onPressCopyUsername = React.useCallback(async () => { Clipboard.setString(userInfo.username); setUsernameCopied(true); await sleep(3000); setUsernameCopied(false); }, [userInfo.username]); const copyUsernameButton = React.useMemo(() => { if (usernameCopied) { return ( Username copied! ); } return ( Copy username ); }, [ onPressCopyUsername, styles.copyUsernameContainer, styles.copyUsernameIcon, styles.copyUsernameText, usernameCopied, ]); return ( {userInfo.username} {copyUsernameButton} ); } const unboundStyles = { container: { paddingHorizontal: 16, }, moreIcon: { color: 'modalButtonLabel', alignSelf: 'flex-end', }, userInfoContainer: { flexDirection: 'row', }, usernameContainer: { flex: 1, justifyContent: 'center', alignItems: 'flex-start', paddingLeft: 16, }, usernameText: { color: 'modalForegroundLabel', fontSize: 18, fontWeight: '500', }, copyUsernameContainer: { flexDirection: 'row', justifyContent: 'center', paddingTop: 8, }, copyUsernameIcon: { color: 'purpleLink', marginRight: 4, }, copyUsernameText: { color: 'purpleLink', fontSize: 12, }, }; export default UserProfile; diff --git a/native/invite-links/view-invite-links-screen.react.js b/native/invite-links/view-invite-links-screen.react.js index 8d5f690c4..0116634f0 100644 --- a/native/invite-links/view-invite-links-screen.react.js +++ b/native/invite-links/view-invite-links-screen.react.js @@ -1,169 +1,169 @@ // @flow import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; import { Text, View } from 'react-native'; import { TouchableOpacity } from 'react-native-gesture-handler'; import { inviteLinkUrl } from 'lib/facts/links.js'; import { primaryInviteLinksSelector } from 'lib/selectors/invite-links-selectors.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { InviteLink } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { ManagePublicLinkRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; export type ViewInviteLinksScreenParams = { +community: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'ViewInviteLinks'>, +route: NavigationRoute<'ViewInviteLinks'>, }; const confirmCopy = () => displayActionResultModal('copied!'); function ViewInviteLinksScreen(props: Props): React.Node { const { community } = props.route.params; const inviteLink: ?InviteLink = useSelector(primaryInviteLinksSelector)[ community.id ]; const styles = useStyles(unboundStyles); const { modalForegroundLabel } = useColors(); const linkUrl = inviteLinkUrl(inviteLink?.name ?? ''); const onPressCopy = React.useCallback(() => { Clipboard.setString(linkUrl); setTimeout(confirmCopy); }, [linkUrl]); const { navigate } = props.navigation; const onEditButtonClick = React.useCallback(() => { navigate<'ManagePublicLink'>({ name: ManagePublicLinkRouteName, params: { community, }, }); }, [community, navigate]); const canManageLinks = threadHasPermission( community, threadPermissions.MANAGE_INVITE_LINKS, ); let publicLinkSection = null; if (inviteLink || canManageLinks) { let description; if (canManageLinks) { description = ( <> Public links allow unlimited uses and never expire. Edit public link ); } else { description = ( Share this invite link to help your friends join your community! ); } publicLinkSection = ( <> PUBLIC LINK {linkUrl} Copy {description} ); } return {publicLinkSection}; } const unboundStyles = { container: { flex: 1, paddingTop: 24, }, sectionTitle: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalBackgroundLabel', paddingHorizontal: 16, paddingBottom: 4, }, section: { borderBottomColor: 'modalSeparator', borderBottomWidth: 1, borderTopColor: 'modalSeparator', borderTopWidth: 1, backgroundColor: 'modalForeground', padding: 16, }, link: { paddingHorizontal: 16, paddingVertical: 9, marginBottom: 16, backgroundColor: 'inviteLinkButtonBackground', borderRadius: 20, flexDirection: 'row', justifyContent: 'space-between', }, linkText: { fontSize: 14, fontWeight: '400', lineHeight: 22, color: 'inviteLinkLinkColor', flex: 1, }, button: { flexDirection: 'row', alignItems: 'center', }, copy: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', paddingLeft: 8, }, details: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: 'modalForegroundLabel', }, editLinkButton: { color: 'purpleLink', }, }; export default ViewInviteLinksScreen; diff --git a/native/navigation/community-drawer-item.react.js b/native/navigation/community-drawer-item.react.js index 77bb75d16..50eb861b0 100644 --- a/native/navigation/community-drawer-item.react.js +++ b/native/navigation/community-drawer-item.react.js @@ -1,142 +1,142 @@ // @flow import * as React from 'react'; import { View, TouchableOpacity } from 'react-native'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ExpandButton, ExpandButtonDisabled } from './expand-buttons.react.js'; import SubchannelsButton from './subchannels-button.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import type { MessageListParams } from '../chat/message-list-types.js'; import CommunityActionsButton from '../components/community-actions-button.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; import type { CommunityDrawerItemDataFlattened } from '../utils/drawer-utils.react.js'; export type DrawerItemProps = { +itemData: CommunityDrawerItemDataFlattened, +toggleExpanded: (threadID: string) => void, +isExpanded: boolean, +navigateToThread: (params: MessageListParams) => void, }; function CommunityDrawerItem(props: DrawerItemProps): React.Node { const { itemData: { threadInfo, labelStyle, hasSubchannelsButton, hasChildren, itemStyle, }, navigateToThread, isExpanded, toggleExpanded, } = props; const styles = useStyles(unboundStyles); const subchannelsButton = React.useMemo(() => { if (isExpanded && hasSubchannelsButton) { return ( ); } return null; }, [isExpanded, hasSubchannelsButton, styles.subchannelsButton, threadInfo]); const onExpandToggled = React.useCallback(() => { toggleExpanded(threadInfo.id); }, [toggleExpanded, threadInfo.id]); const itemExpandButton = React.useMemo(() => { if (!hasChildren && !hasSubchannelsButton) { return ; } return ; }, [hasChildren, hasSubchannelsButton, onExpandToggled, isExpanded]); const onPress = React.useCallback(() => { navigateToThread({ threadInfo }); }, [navigateToThread, threadInfo]); const { uiName } = useResolvedThreadInfo(threadInfo); const containerStyle = React.useMemo( () => [ styles.container, { paddingLeft: itemStyle.indentation, }, styles[itemStyle.background], ], [itemStyle.indentation, itemStyle.background, styles], ); return ( {itemExpandButton} {uiName} {subchannelsButton} ); } const unboundStyles = { container: { paddingRight: 8, }, none: { paddingVertical: 2, }, beginning: { backgroundColor: 'drawerOpenCommunityBackground', borderTopRightRadius: 8, paddingTop: 2, }, middle: { backgroundColor: 'drawerOpenCommunityBackground', }, end: { backgroundColor: 'drawerOpenCommunityBackground', borderBottomRightRadius: 8, paddingBottom: 2, }, avatarContainer: { marginRight: 8, }, threadEntry: { flexDirection: 'row', marginVertical: 6, }, textTouchableWrapper: { flex: 1, flexDirection: 'row', alignItems: 'center', marginRight: 24, }, subchannelsButton: { marginLeft: 24, marginBottom: 6, }, }; const MemoizedCommunityDrawerItem: React.ComponentType = React.memo(CommunityDrawerItem); export default MemoizedCommunityDrawerItem; diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js index a031efb65..586f7634e 100644 --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -1,438 +1,438 @@ // @flow import * as React from 'react'; import { View, Text, Platform, ScrollView } from 'react-native'; import { logOutActionTypes, logOut } from 'lib/actions/user-actions.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import { type PreRequestUserState } from 'lib/types/session-types.js'; import { type CurrentUserInfo } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import EditUserAvatar from '../avatars/edit-user-avatar.react.js'; import Action from '../components/action-row.react.js'; import Button from '../components/button.react.js'; import EditSettingButton from '../components/edit-setting-button.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, BlockListRouteName, PrivacyPreferencesRouteName, DefaultNotificationsPreferencesRouteName, LinkedDevicesRouteName, BackupMenuRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type ProfileRowProps = { +content: string, +onPress: () => void, +danger?: boolean, }; function ProfileRow(props: ProfileRowProps): React.Node { const { content, onPress, danger } = props; return ( ); } type BaseProps = { +navigation: ProfileNavigationProp<'ProfileScreen'>, +route: NavigationRoute<'ProfileScreen'>, }; type Props = { ...BaseProps, +currentUserInfo: ?CurrentUserInfo, +preRequestUserState: PreRequestUserState, +logOutLoading: boolean, +colors: Colors, +styles: typeof unboundStyles, +dispatchActionPromise: DispatchActionPromise, +logOut: (preRequestUserState: PreRequestUserState) => Promise, +staffCanSee: boolean, +stringForUser: ?string, +isAccountWithPassword: boolean, }; class ProfileScreen extends React.PureComponent { get loggedOutOrLoggingOut() { return ( !this.props.currentUserInfo || this.props.currentUserInfo.anonymous || this.props.logOutLoading ); } render() { let developerTools, defaultNotifications; const { staffCanSee, isAccountWithPassword } = this.props; if (staffCanSee) { developerTools = ( ); defaultNotifications = ( ); } let backupMenu; if (staffCanSee && isAccountWithPassword) { backupMenu = ( ); } let passwordEditionUI; if (accountHasPassword(this.props.currentUserInfo)) { passwordEditionUI = ( Password •••••••••••••••• ); } let linkedDevices; if (__DEV__) { linkedDevices = ( ); } return ( USER AVATAR ACCOUNT Logged in as {this.props.stringForUser} {passwordEditionUI} PREFERENCES {defaultNotifications} {backupMenu} {linkedDevices} {developerTools} ); } onPressLogOut = () => { if (this.loggedOutOrLoggingOut) { return; } if (!this.props.isAccountWithPassword) { Alert.alert( 'Log out', 'Are you sure you want to log out?', [ { text: 'No', style: 'cancel' }, { text: 'Yes', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); return; } const alertTitle = Platform.OS === 'ios' ? 'Keep Login Info in Keychain' : 'Keep Login Info'; const alertDescription = 'We will automatically fill out log-in forms with your credentials ' + 'in the app.'; Alert.alert( alertTitle, alertDescription, [ { text: 'Cancel', style: 'cancel' }, { text: 'Keep', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, }, { text: 'Remove', onPress: this.logOutAndDeleteNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); }; logOutWithoutDeletingNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.logOut(); }; logOutAndDeleteNativeCredentialsWrapper = async () => { if (this.loggedOutOrLoggingOut) { return; } await this.deleteNativeCredentials(); this.logOut(); }; logOut() { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } async deleteNativeCredentials() { await deleteNativeCredentialsFor(); } navigateIfActive(name) { this.props.navigation.navigate({ name }); } onPressEditPassword = () => { this.navigateIfActive(EditPasswordRouteName); }; onPressDeleteAccount = () => { this.navigateIfActive(DeleteAccountRouteName); }; onPressDevices = () => { this.navigateIfActive(LinkedDevicesRouteName); }; onPressBuildInfo = () => { this.navigateIfActive(BuildInfoRouteName); }; onPressDevTools = () => { this.navigateIfActive(DevToolsRouteName); }; onPressAppearance = () => { this.navigateIfActive(AppearancePreferencesRouteName); }; onPressPrivacy = () => { this.navigateIfActive(PrivacyPreferencesRouteName); }; onPressDefaultNotifications = () => { this.navigateIfActive(DefaultNotificationsPreferencesRouteName); }; onPressFriendList = () => { this.navigateIfActive(FriendListRouteName); }; onPressBlockList = () => { this.navigateIfActive(BlockListRouteName); }; onPressBackupMenu = () => { this.navigateIfActive(BackupMenuRouteName); }; } const unboundStyles = { avatarSection: { alignItems: 'center', paddingVertical: 16, }, container: { flex: 1, }, content: { flex: 1, }, deleteAccountButton: { paddingHorizontal: 24, paddingVertical: 12, }, editPasswordButton: { paddingTop: Platform.OS === 'android' ? 3 : 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, loggedInLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, logOutText: { color: 'link', fontSize: 16, paddingLeft: 6, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, paddedRow: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 1, }, unpaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, }, username: { color: 'panelForegroundLabel', flex: 1, }, value: { color: 'panelForegroundLabel', fontSize: 16, textAlign: 'right', }, }; const logOutLoadingStatusSelector = createLoadingStatusSelector(logOutActionTypes); const ConnectedProfileScreen: React.ComponentType = React.memo(function ConnectedProfileScreen(props: BaseProps) { const currentUserInfo = useSelector(state => state.currentUserInfo); const preRequestUserState = useSelector(preRequestUserStateSelector); const logOutLoading = useSelector(logOutLoadingStatusSelector) === 'loading'; const colors = useColors(); const styles = useStyles(unboundStyles); const callLogOut = useServerCall(logOut); const dispatchActionPromise = useDispatchActionPromise(); const staffCanSee = useStaffCanSee(); const stringForUser = useStringForUser(currentUserInfo); const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); return ( ); }); export default ConnectedProfileScreen; diff --git a/native/profile/relationship-list-item.react.js b/native/profile/relationship-list-item.react.js index ea58945d0..6e66c09d6 100644 --- a/native/profile/relationship-list-item.react.js +++ b/native/profile/relationship-list-item.react.js @@ -1,344 +1,344 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type RelationshipRequest, type RelationshipAction, type RelationshipErrors, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types.js'; import type { AccountUserInfo, GlobalAccountUserInfo, } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { RelationshipListNavigate } from './relationship-list.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import PencilIcon from '../components/pencil-icon.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { RelationshipListItemTooltipModalRouteName, FriendListRouteName, BlockListRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { VerticalBounds } from '../types/layout-types.js'; import Alert from '../utils/alert.js'; type BaseProps = { +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +relationshipListRoute: NavigationRoute<'FriendList' | 'BlockList'>, +navigate: RelationshipListNavigate, +onSelect: (selectedUser: GlobalAccountUserInfo) => void, }; type Props = { ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateRelationships: ( request: RelationshipRequest, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, }; class RelationshipListItem extends React.PureComponent { editButton = React.createRef>(); render() { const { lastListItem, removeUserLoadingStatus, userInfo, relationshipListRoute, } = this.props; const relationshipsToEdit = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BOTH_BLOCKED, userRelationshipStatus.BLOCKED_BY_VIEWER, ], }[relationshipListRoute.name]; const canEditFriendRequest = { [FriendListRouteName]: true, [BlockListRouteName]: false, }[relationshipListRoute.name]; const borderBottom = lastListItem ? null : this.props.styles.borderBottom; let editButton = null; if (removeUserLoadingStatus === 'loading') { editButton = ( ); } else if (relationshipsToEdit.includes(userInfo.relationshipStatus)) { editButton = ( ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED && canEditFriendRequest ) { editButton = ( Accept Reject ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT && canEditFriendRequest ) { editButton = ( Cancel request ); } else { editButton = ( Add ); } return ( {this.props.userInfo.username} {editButton} ); } onSelect = () => { const { id, username } = this.props.userInfo; this.props.onSelect({ id, username }); }; visibleEntryIDs() { const { relationshipListRoute } = this.props; const id = { [FriendListRouteName]: 'unfriend', [BlockListRouteName]: 'unblock', }[relationshipListRoute.name]; return [id]; } onPressEdit = () => { if (this.props.keyboardState?.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; const { overlayContext, userInfo } = this.props; invariant( overlayContext, 'RelationshipListItem should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); if (!editButton.current || !verticalBounds) { return; } const { relationshipStatus, ...restUserInfo } = userInfo; const relativeUserInfo = { ...restUserInfo, isViewer: false, }; editButton.current.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'RelationshipListItemTooltipModal'>({ name: RelationshipListItemTooltipModalRouteName, params: { presentedFrom: this.props.relationshipListRoute.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), relativeUserInfo, }, }); }); }; // We need to set onLayout in order to allow .measure() to be on the ref onLayout = () => {}; onPressFriendUser = () => { this.onPressUpdateFriendship(relationshipActions.FRIEND); }; onPressUnfriendUser = () => { this.onPressUpdateFriendship(relationshipActions.UNFRIEND); }; onPressUpdateFriendship(action: RelationshipAction) { const { id } = this.props.userInfo; const customKeyName = `${updateRelationshipsActionTypes.started}:${id}`; this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateFriendship(action), { customKeyName }, ); } async updateFriendship(action: RelationshipAction) { try { return await this.props.updateRelationships({ action, userIDs: [this.props.userInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } } } const unboundStyles = { editButton: { paddingLeft: 10, }, container: { flex: 1, paddingHorizontal: 12, backgroundColor: 'panelForeground', }, innerContainer: { paddingVertical: 10, paddingHorizontal: 12, borderColor: 'panelForegroundBorder', flexDirection: 'row', }, borderBottom: { borderBottomWidth: 1, }, buttonContainer: { flexDirection: 'row', }, editButtonWithMargin: { marginLeft: 15, }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, blueAction: { color: 'link', fontSize: 16, paddingLeft: 6, }, redAction: { color: 'redText', fontSize: 16, paddingLeft: 6, }, }; const ConnectedRelationshipListItem: React.ComponentType = React.memo(function ConnectedRelationshipListItem( props: BaseProps, ) { const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( updateRelationshipsActionTypes, `${updateRelationshipsActionTypes.started}:${props.userInfo.id}`, )(state), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const boundUpdateRelationships = useServerCall(updateRelationships); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); }); export default ConnectedRelationshipListItem; diff --git a/native/push/in-app-notif.react.js b/native/push/in-app-notif.react.js index bd31f6313..182534ec0 100644 --- a/native/push/in-app-notif.react.js +++ b/native/push/in-app-notif.react.js @@ -1,93 +1,93 @@ // @flow import * as React from 'react'; import { View, StyleSheet, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import type { GlobalTheme } from '../types/themes.js'; const edges = ['top']; type Props = { +title: ?string, +message: string, +activeTheme: ?GlobalTheme, }; function InAppNotif(props: Props): React.Node { const useLightStyle = Platform.OS === 'ios' && props.activeTheme !== 'dark'; let title = null; const textStyle = [ styles.text, useLightStyle ? styles.lightText : styles.darkText, ]; if (props.title) { title = ( {props.title} ); } const notificationContent = ( {title} {props.message} ); if (Platform.OS === 'android') { return ( {notificationContent} ); } return {notificationContent}; } const styles = StyleSheet.create({ darkText: { color: 'black', }, lightText: { color: 'white', }, notif: { alignItems: 'flex-start', alignSelf: 'flex-start', justifyContent: 'flex-start', width: '100%', }, notificationContent: { ...Platform.select({ ios: { marginTop: 16, marginBottom: 6, }, default: { marginVertical: 16, }, }), marginHorizontal: 10, }, text: { ...Platform.select({ ios: { fontSize: 16, color: 'black', }, default: { fontSize: 18, }, }), }, title: { fontWeight: 'bold', }, }); export default InAppNotif; diff --git a/native/tooltip/tooltip-item.react.js b/native/tooltip/tooltip-item.react.js index 82ee139b5..e13c9d827 100644 --- a/native/tooltip/tooltip-item.react.js +++ b/native/tooltip/tooltip-item.react.js @@ -1,77 +1,77 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import { TooltipContext } from './tooltip-context.react.js'; -import { SingleLine } from '../components/single-line.react.js'; +import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; export type TooltipItemBaseProps = { +id: string, +text: string, +onPress: () => mixed, +renderIcon?: (iconStyle: TextStyle) => React.Node, +isDestructive?: boolean, +isCancel?: boolean, }; type Props = { ...TooltipItemBaseProps, +containerStyle?: ViewStyle, +closeTooltip?: () => mixed, }; function TooltipItem(props: Props): React.Node { const tooltipContext = React.useContext(TooltipContext); invariant(tooltipContext, 'TooltipContext should be set in TooltipItem'); const { registerOption, unregisterOption } = tooltipContext; const symbolRef = React.useRef(Symbol()); const { containerStyle, closeTooltip, ...contextRegistrationInput } = props; const { shouldRender } = registerOption({ ...contextRegistrationInput, symbol: symbolRef.current, }); const { id, text, renderIcon, onPress: onPressItem } = props; React.useEffect(() => { return () => unregisterOption(id); }, [unregisterOption, id]); const styles = useStyles(unboundStyles); const onPress = React.useCallback(() => { onPressItem(); closeTooltip?.(); }, [onPressItem, closeTooltip]); if (!shouldRender) { return null; } const icon = renderIcon ? renderIcon(styles.icon) : null; return ( {icon} {text} ); } const unboundStyles = { label: { color: 'modalForegroundLabel', fontSize: 14, lineHeight: 17, textAlign: 'center', }, icon: { color: 'modalForegroundLabel', }, }; export default TooltipItem;