diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js
index aebe60541..3b1e0b51e 100644
--- a/native/calendar/entry.react.js
+++ b/native/calendar/entry.react.js
@@ -1,792 +1,792 @@
// @flow
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual';
import _omit from 'lodash/fp/omit';
import * as React from 'react';
import {
View,
Text,
TextInput,
Platform,
TouchableWithoutFeedback,
Alert,
LayoutAnimation,
Keyboard,
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
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';
import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { entryKey } from 'lib/shared/entry-utils';
import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils';
import type { Shape } from 'lib/types/core';
import type {
CreateEntryInfo,
SaveEntryInfo,
SaveEntryResponse,
CreateEntryPayload,
DeleteEntryInfo,
DeleteEntryResponse,
CalendarQuery,
} from 'lib/types/entry-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { Dispatch } from 'lib/types/redux-types';
import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types';
import {
useServerCall,
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/action-utils';
import { dateString } from 'lib/utils/date-utils';
import { ServerError } from 'lib/utils/errors';
import sleep from 'lib/utils/sleep';
import Button from '../components/button.react';
import { SingleLine } from '../components/single-line.react';
import Markdown from '../markdown/markdown.react';
import { inlineMarkdownRules } from '../markdown/rules.react';
import type { TabNavigationProp } from '../navigation/app-navigator.react';
import {
createIsForegroundSelector,
nonThreadCalendarQuery,
} from '../navigation/nav-selectors';
import { NavContext } from '../navigation/navigation-context';
import {
MessageListRouteName,
ThreadPickerModalRouteName,
} from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { colors, useStyles } from '../themes/colors';
import type { LayoutEvent } from '../types/react-native';
import { waitForInteractions } from '../utils/timers';
import type { EntryInfoWithHeight } from './calendar.react';
import LoadingIndicator from './loading-indicator.react';
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) {
const text = entryText === '' ? ' ' : entryText;
return (
{text}
);
}
type BaseProps = {|
+navigation: TabNavigationProp<'Calendar'>,
+entryInfo: EntryInfoWithHeight,
+threadInfo: ThreadInfo,
+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 Props = {|
...BaseProps,
// Redux state
+calendarQuery: () => CalendarQuery,
+online: boolean,
+styles: typeof unboundStyles,
// Nav state
+threadPickerActive: boolean,
// 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 = false;
needsUpdateAfterCreation = false;
needsDeleteAfterCreation = false;
nextSaveAttemptIndex = 0;
mounted = false;
deleted = 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) {
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) {
return (
props.active ||
state.editing ||
!props.entryInfo.id ||
state.loadingStatus !== 'inactive'
);
}
render() {
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) => {
this.textInput = textInput;
if (textInput && this.state.editing) {
this.enterEditMode();
}
};
enterEditMode = async () => {
this.setActive();
this.props.onEnterEditMode(this.props.entryInfo);
if (Platform.OS === 'android') {
// For some reason if we don't do this the scroll stops halfway through
await waitForInteractions();
await sleep(15);
}
this.focus();
};
focus = () => {
const { textInput } = this;
if (!textInput) {
return;
}
textInput.focus();
};
onFocus = () => {
if (this.props.threadPickerActive) {
this.props.navigation.goBack();
}
};
setActive = () => this.makeActive(true);
completeEdit = () => {
// 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 = () => {
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 = () => {
this.dispatchSave(this.props.entryInfo.id, this.state.text);
};
onTextContainerLayout = (event: LayoutEvent) => {
this.guardedSetState({
height: Math.ceil(event.nativeEvent.layout.height),
});
};
onChangeText = (newText: string) => {
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) {
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) {
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 = () => {
this.dispatchDelete(this.props.entryInfo.id);
};
onPressEdit = () => {
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) {
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 = () => {
Keyboard.dismiss();
const { threadInfo } = this.props;
this.props.navigation.navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
}
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,
},
textInput: {
fontFamily: 'System',
fontSize: 16,
left: Platform.OS === 'android' ? 9.8 : 10,
margin: 0,
padding: 0,
position: 'absolute',
right: 10,
top: Platform.OS === 'android' ? 4.8 : 0.5,
},
};
registerFetchKey(saveEntryActionTypes);
registerFetchKey(deleteEntryActionTypes);
const activeThreadPickerSelector = createIsForegroundSelector(
ThreadPickerModalRouteName,
);
const Entry = React.memo(function ConnectedEntry(props: BaseProps) {
const navContext = React.useContext(NavContext);
const threadPickerActive = activeThreadPickerSelector(navContext);
const calendarQuery = useSelector((state) =>
nonThreadCalendarQuery({
redux: state,
navContext,
}),
);
const online = useSelector(
(state) => state.connection.status === 'connected',
);
const styles = useStyles(unboundStyles);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callCreateEntry = useServerCall(createEntry);
const callSaveEntry = useServerCall(saveEntry);
const callDeleteEntry = useServerCall(deleteEntry);
return (
);
});
export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement };
diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js
index 90e4a801b..5a6730887 100644
--- a/native/chat/chat-input-bar.react.js
+++ b/native/chat/chat-input-bar.react.js
@@ -1,919 +1,915 @@
// @flow
import invariant from 'invariant';
import _throttle from 'lodash/throttle';
import PropTypes from 'prop-types';
import * as React from 'react';
import {
View,
TextInput,
TouchableOpacity,
Platform,
Text,
ActivityIndicator,
TouchableWithoutFeedback,
Alert,
NativeAppEventEmitter,
} from 'react-native';
import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input';
import Animated, { Easing } from 'react-native-reanimated';
import FAIcon from 'react-native-vector-icons/FontAwesome';
import Icon from 'react-native-vector-icons/Ionicons';
import { useDispatch } from 'react-redux';
import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types';
import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors';
import { trimMessage } from 'lib/shared/message-utils';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
useRealThreadCreator,
} from 'lib/shared/thread-utils';
import type { CalendarQuery } from 'lib/types/entry-types';
import { loadingStatusPropType } from 'lib/types/loading-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { PhotoPaste } from 'lib/types/media-types';
import { messageTypes } from 'lib/types/message-types';
import type { Dispatch } from 'lib/types/redux-types';
import {
type ThreadInfo,
threadInfoPropType,
threadPermissions,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
} from 'lib/types/thread-types';
import { type UserInfos, userInfoPropType } from 'lib/types/user-types';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils';
import Button from '../components/button.react';
import ClearableTextInput from '../components/clearable-text-input.react';
import {
type InputState,
inputStatePropType,
InputStateContext,
} from '../input/input-state';
import { getKeyboardHeight } from '../keyboard/keyboard';
import KeyboardInputHost from '../keyboard/keyboard-input-host.react';
import {
type KeyboardState,
keyboardStatePropType,
KeyboardContext,
} from '../keyboard/keyboard-state';
import {
nonThreadCalendarQuery,
activeThreadSelector,
} from '../navigation/nav-selectors';
import { NavContext } from '../navigation/navigation-context';
import {
type NavigationRoute,
CameraModalRouteName,
ImagePasteModalRouteName,
} from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import {
type Colors,
colorsPropType,
useStyles,
useColors,
} from '../themes/colors';
import type { ViewStyle } from '../types/styles';
import { runTiming } from '../utils/animation-utils';
import type { ChatNavigationProp } from './chat.react';
import {
messageListRoutePropType,
messageListNavPropType,
} from './message-list-types';
/* eslint-disable import/no-named-as-default-member */
const {
Value,
Clock,
block,
set,
cond,
neq,
sub,
interpolate,
stopClock,
} = Animated;
/* eslint-enable import/no-named-as-default-member */
const expandoButtonsAnimationConfig = {
duration: 500,
easing: Easing.inOut(Easing.ease),
};
const sendButtonAnimationConfig = {
duration: 150,
easing: Easing.inOut(Easing.ease),
};
const draftKeyFromThreadID = (threadID: string) =>
`${threadID}/message_composer`;
type BaseProps = {|
+threadInfo: ThreadInfo,
+navigation: ChatNavigationProp<'MessageList'>,
+route: NavigationRoute<'MessageList'>,
|};
type Props = {|
...BaseProps,
// Redux state
+viewerID: ?string,
+draft: string,
+joinThreadLoadingStatus: LoadingStatus,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+userInfos: UserInfos,
+colors: Colors,
+styles: typeof unboundStyles,
// connectNav
+isActive: boolean,
// withKeyboardState
+keyboardState: ?KeyboardState,
// Redux dispatch functions
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+joinThread: (request: ClientThreadJoinRequest) => Promise,
+getServerThreadID: () => Promise,
// withInputState
+inputState: ?InputState,
|};
type State = {|
+text: string,
+buttonsExpanded: boolean,
|};
class ChatInputBar extends React.PureComponent {
static propTypes = {
threadInfo: threadInfoPropType.isRequired,
navigation: messageListNavPropType.isRequired,
route: messageListRoutePropType.isRequired,
isActive: PropTypes.bool.isRequired,
viewerID: PropTypes.string,
draft: PropTypes.string.isRequired,
joinThreadLoadingStatus: loadingStatusPropType.isRequired,
calendarQuery: PropTypes.func.isRequired,
nextLocalID: PropTypes.number.isRequired,
userInfos: PropTypes.objectOf(userInfoPropType).isRequired,
colors: colorsPropType.isRequired,
styles: PropTypes.objectOf(PropTypes.object).isRequired,
keyboardState: keyboardStatePropType,
dispatch: PropTypes.func.isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
joinThread: PropTypes.func.isRequired,
inputState: inputStatePropType,
getServerThreadID: PropTypes.func.isRequired,
};
textInput: ?React.ElementRef;
clearableTextInput: ?ClearableTextInput;
expandoButtonsOpen: Value;
targetExpandoButtonsOpen: Value;
expandoButtonsStyle: ViewStyle;
cameraRollIconStyle: ViewStyle;
cameraIconStyle: ViewStyle;
expandIconStyle: ViewStyle;
sendButtonContainerOpen: Value;
targetSendButtonContainerOpen: Value;
sendButtonContainerStyle: ViewStyle;
constructor(props: Props) {
super(props);
this.state = {
text: props.draft,
buttonsExpanded: true,
};
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 = interpolate(expandoButtonsOpen, {
inputRange: [0, 1],
outputRange: [22, 60],
});
this.expandoButtonsStyle = {
...unboundStyles.expandoButtons,
width: expandoButtonsWidth,
};
const expandOpacity = sub(1, expandoButtonsOpen);
this.expandIconStyle = {
...unboundStyles.expandIcon,
opacity: expandOpacity,
};
const initialSendButtonContainerOpen = trimMessage(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 = interpolate(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) {
this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1);
}
componentDidMount() {
if (this.props.isActive) {
this.addReplyListener();
}
}
componentWillUnmount() {
if (this.props.isActive) {
this.removeReplyListener();
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.props.isActive && !prevProps.isActive) {
this.addReplyListener();
} else if (!this.props.isActive && prevProps.isActive) {
this.removeReplyListener();
}
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();
}
}
addReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in addReplyListener',
);
this.props.inputState.addReplyListener(this.focusAndUpdateText);
}
removeReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in removeReplyListener',
);
this.props.inputState.removeReplyListener(this.focusAndUpdateText);
}
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);
}
render() {
const isMember = viewerIsMember(this.props.threadInfo);
const canJoin = threadHasPermission(
this.props.threadInfo,
threadPermissions.JOIN_THREAD,
);
let joinButton = null;
if (!isMember && canJoin) {
let buttonContent;
if (this.props.joinThreadLoadingStatus === 'loading') {
buttonContent = (
);
} else {
buttonContent = (
Join Thread
);
}
joinButton = (
);
}
let content;
if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) {
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 {
const defaultRoleID = Object.keys(this.props.threadInfo.roles).find(
(roleID) => this.props.threadInfo.roles[roleID].isDefault,
);
invariant(
defaultRoleID !== undefined,
'all threads should have a default role',
);
const defaultRole = this.props.threadInfo.roles[defaultRoleID];
const membersAreVoiced = !!defaultRole.permissions[
threadPermissions.VOICED
];
if (membersAreVoiced && canJoin) {
content = (
Join this thread to send messages.
);
} else {
content = (
You don't have permission to send messages.
);
}
}
const keyboardInputHost =
Platform.OS === 'android' ? null : (
);
return (
{joinButton}
{content}
{keyboardInputHost}
);
}
renderInput() {
const expandoButton = (
);
const threadColor = `#${this.props.threadInfo.color}`;
return (
{this.state.buttonsExpanded ? expandoButton : null}
{this.state.buttonsExpanded ? null : expandoButton}
);
}
textInputRef = (textInput: ?React.ElementRef) => {
this.textInput = textInput;
};
clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => {
this.clearableTextInput = clearableTextInput;
};
updateText = (text: string) => {
this.setState({ text });
this.saveDraft(text);
};
saveDraft = _throttle((text: string) => {
this.props.dispatch({
type: saveDraftActionType,
payload: {
key: draftKeyFromThreadID(this.props.threadInfo.id),
draft: text,
},
});
}, 400);
focusAndUpdateText = (text: string) => {
const currentText = this.state.text;
if (!currentText.startsWith(text)) {
const prependedText = text.concat(currentText);
this.updateText(prependedText);
this.immediatelyShowSendButton();
this.immediatelyHideButtons();
}
invariant(this.textInput, 'textInput should be set in focusAndUpdateText');
this.textInput.focus();
};
onSend = async () => {
if (!trimMessage(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 = `local${this.props.nextLocalID}`;
const creatorID = this.props.viewerID;
const threadID = await this.props.getServerThreadID();
invariant(creatorID, 'should have viewer ID in order to send a message');
invariant(
this.props.inputState,
'inputState should be set in ChatInputBar.onSend',
);
if (threadID) {
this.props.inputState.sendTextMessage({
type: messageTypes.TEXT,
localID,
threadID,
text,
creatorID,
time: Date.now(),
});
}
};
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) {
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 });
}
openCamera = async () => {
this.dismissKeyboard();
this.props.navigation.navigate({
name: CameraModalRouteName,
params: {
presentedFrom: this.props.route.key,
thread: {
threadInfo: this.props.threadInfo,
- sourceMessageID: this.props.route.params.thread.sourceMessageID,
+ sourceMessageID: this.props.route.params.sourceMessageID,
},
},
});
};
showMediaGallery = () => {
const { keyboardState } = this.props;
invariant(keyboardState, 'keyboardState should be initialized');
keyboardState.showMediaGallery({
threadInfo: this.props.threadInfo,
- sourceMessageID: this.props.route.params.thread.sourceMessageID,
+ sourceMessageID: this.props.route.params.sourceMessageID,
});
};
dismissKeyboard = () => {
const { keyboardState } = this.props;
keyboardState && keyboardState.dismissKeyboard();
};
}
const unboundStyles = {
cameraIcon: {
paddingBottom: Platform.OS === 'android' ? 11 : 10,
paddingRight: 3,
},
cameraRollIcon: {
paddingBottom: Platform.OS === 'android' ? 8 : 7,
paddingRight: 8,
},
container: {
backgroundColor: 'listBackground',
},
expandButton: {
bottom: 0,
position: 'absolute',
right: 0,
},
expandIcon: {
paddingBottom: Platform.OS === 'android' ? 12 : 10,
},
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: {
backgroundColor: 'mintButton',
borderRadius: 5,
flex: 1,
justifyContent: 'center',
marginHorizontal: 12,
marginVertical: 3,
paddingBottom: 5,
paddingTop: 3,
},
joinButtonContainer: {
flexDirection: 'row',
height: 36,
},
joinButtonText: {
color: 'listBackground',
fontSize: 20,
textAlign: 'center',
},
joinThreadLoadingIndicator: {
paddingVertical: 2,
},
sendButton: {
position: 'absolute',
bottom: Platform.OS === 'android' ? 4 : 3,
left: 0,
},
sendIcon: {
paddingLeft: 9,
paddingRight: 8,
paddingVertical: 5,
},
textInput: {
backgroundColor: 'listInputBackground',
borderRadius: 10,
color: 'listForegroundLabel',
fontSize: 16,
marginLeft: 4,
marginVertical: 5,
maxHeight: 250,
paddingHorizontal: 10,
paddingVertical: 5,
},
};
const joinThreadLoadingStatusSelector = createLoadingStatusSelector(
joinThreadActionTypes,
);
const showErrorAlert = () =>
Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
cancelable: false,
});
export default React.memo(function ConnectedChatInputBar(
props: BaseProps,
) {
const inputState = React.useContext(InputStateContext);
const keyboardState = React.useContext(KeyboardContext);
const navContext = React.useContext(NavContext);
const styles = useStyles(unboundStyles);
const colors = useColors();
const isActive = React.useMemo(
() => props.threadInfo.id === activeThreadSelector(navContext),
[props.threadInfo.id, navContext],
);
const draftKey = draftKeyFromThreadID(props.threadInfo.id);
const draft = useSelector((state) => state.drafts[draftKey] || '');
const viewerID = useSelector(
(state) => state.currentUserInfo && state.currentUserInfo.id,
);
const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector);
const calendarQuery = useSelector((state) =>
nonThreadCalendarQuery({
redux: state,
navContext,
}),
);
const nextLocalID = useSelector((state) => state.nextLocalID);
const userInfos = useSelector((state) => state.userStore.userInfos);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callJoinThread = useServerCall(joinThread);
const imagePastedCallback = React.useCallback(
(imagePastedEvent) => {
if (props.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,
};
props.navigation.navigate({
name: ImagePasteModalRouteName,
params: {
imagePasteStagingInfo: pastedImage,
thread: {
threadInfo: props.threadInfo,
- sourceMessageID: props.route.params.thread.sourceMessageID,
+ sourceMessageID: props.route.params.sourceMessageID,
},
},
});
},
- [
- props.navigation,
- props.route.params.thread.sourceMessageID,
- props.threadInfo,
- ],
+ [props.navigation, props.route.params.sourceMessageID, props.threadInfo],
);
React.useEffect(() => {
const imagePasteListener = NativeAppEventEmitter.addListener(
'imagePasted',
imagePastedCallback,
);
return () => imagePasteListener.remove();
}, [imagePastedCallback]);
const getServerThreadID = useRealThreadCreator(
{
threadInfo: props.threadInfo,
- sourceMessageID: props.route.params.thread.sourceMessageID,
+ sourceMessageID: props.route.params.sourceMessageID,
},
showErrorAlert,
);
return (
);
});
diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js
index bf86950ca..9e62ce3fc 100644
--- a/native/chat/chat-thread-list.react.js
+++ b/native/chat/chat-thread-list.react.js
@@ -1,427 +1,425 @@
// @flow
import invariant from 'invariant';
import _sum from 'lodash/fp/sum';
import * as React from 'react';
import { View, FlatList, Platform, TextInput } from 'react-native';
import { FloatingAction } from 'react-native-floating-action';
import IonIcon from 'react-native-vector-icons/Ionicons';
import { createSelector } from 'reselect';
import { searchUsers } from 'lib/actions/user-actions';
import {
type ChatThreadItem,
useFlattenedChatListData,
} from 'lib/selectors/chat-selectors';
import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors';
import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors';
import SearchIndex from 'lib/shared/search-index';
import {
createPendingThread,
createPendingThreadItem,
threadIsTopLevel,
} from 'lib/shared/thread-utils';
import type { UserSearchResult } from 'lib/types/search-types';
import type { ThreadInfo } from 'lib/types/thread-types';
import { threadTypes } from 'lib/types/thread-types';
import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types';
import { useServerCall } from 'lib/utils/action-utils';
import Search from '../components/search.react';
import type { TabNavigationProp } from '../navigation/app-navigator.react';
import {
MessageListRouteName,
SidebarListModalRouteName,
HomeChatThreadListRouteName,
BackgroundChatThreadListRouteName,
type NavigationRoute,
} from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import {
type IndicatorStyle,
indicatorStyleSelector,
useStyles,
} from '../themes/colors';
import ChatThreadListItem from './chat-thread-list-item.react';
import type {
ChatTopTabsNavigationProp,
ChatNavigationProp,
} from './chat.react';
const floatingActions = [
{
text: 'Compose',
icon: ,
name: 'compose',
position: 1,
},
];
type Item =
| ChatThreadItem
| {| +type: 'search', +searchText: string |}
| {| +type: 'empty', +emptyItem: React.ComponentType<{||}> |};
type BaseProps = {|
+navigation:
| ChatTopTabsNavigationProp<'HomeChatThreadList'>
| ChatTopTabsNavigationProp<'BackgroundChatThreadList'>,
+route:
| NavigationRoute<'HomeChatThreadList'>
| NavigationRoute<'BackgroundChatThreadList'>,
+filterThreads: (threadItem: ThreadInfo) => boolean,
+emptyItem?: React.ComponentType<{||}>,
|};
type Props = {|
...BaseProps,
// Redux state
+chatListData: $ReadOnlyArray,
+viewerID: ?string,
+threadSearchIndex: SearchIndex,
+styles: typeof unboundStyles,
+indicatorStyle: IndicatorStyle,
+usersWithPersonalThread: $ReadOnlySet,
// async functions that hit server APIs
+searchUsers: (usernamePrefix: string) => Promise,
|};
type State = {|
+searchText: string,
+threadsSearchResults: Set,
+usersSearchResults: $ReadOnlyArray,
+openedSwipeableId: string,
|};
type PropsAndState = {| ...Props, ...State |};
class ChatThreadList extends React.PureComponent {
state: State = {
searchText: '',
threadsSearchResults: new Set(),
usersSearchResults: [],
openedSwipeableId: '',
};
searchInput: ?React.ElementRef;
flatList: ?FlatList- ;
scrollPos = 0;
componentDidMount() {
const chatNavigation: ?ChatNavigationProp<
'ChatThreadList',
> = this.props.navigation.dangerouslyGetParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<
'Chat',
> = chatNavigation.dangerouslyGetParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.addListener('tabPress', this.onTabPress);
}
componentWillUnmount() {
const chatNavigation: ?ChatNavigationProp<
'ChatThreadList',
> = this.props.navigation.dangerouslyGetParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<
'Chat',
> = chatNavigation.dangerouslyGetParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.removeListener('tabPress', this.onTabPress);
}
onTabPress = () => {
if (!this.props.navigation.isFocused()) {
return;
}
if (this.scrollPos > 0 && this.flatList) {
this.flatList.scrollToOffset({ offset: 0, animated: true });
} else if (this.props.route.name === BackgroundChatThreadListRouteName) {
this.props.navigation.navigate({ name: HomeChatThreadListRouteName });
}
};
renderItem = (row: { item: Item }) => {
const item = row.item;
if (item.type === 'search') {
return (
);
}
if (item.type === 'empty') {
const EmptyItem = item.emptyItem;
return ;
}
return (
);
};
searchInputRef = (searchInput: ?React.ElementRef) => {
this.searchInput = searchInput;
};
static keyExtractor(item: Item) {
if (item.type === 'chatThreadItem') {
return item.threadInfo.id;
} else if (item.type === 'empty') {
return 'empty';
} else {
return 'search';
}
}
static getItemLayout(data: ?$ReadOnlyArray
- , index: number) {
if (!data) {
return { length: 0, offset: 0, index };
}
const offset = ChatThreadList.heightOfItems(
data.filter((_, i) => i < index),
);
const item = data[index];
const length = item ? ChatThreadList.itemHeight(item) : 0;
return { length, offset, index };
}
static itemHeight(item: Item): number {
if (item.type === 'search') {
return Platform.OS === 'ios' ? 54.5 : 55;
}
// itemHeight for emptyItem might be wrong because of line wrapping
// but we don't care because we'll only ever be rendering this item by itself
// and it should always be on-screen
if (item.type === 'empty') {
return 123;
}
return 60 + item.sidebars.length * 30;
}
static heightOfItems(data: $ReadOnlyArray
- ): number {
return _sum(data.map(ChatThreadList.itemHeight));
}
listDataSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.chatListData,
(propsAndState: PropsAndState) => propsAndState.searchText,
(propsAndState: PropsAndState) => propsAndState.threadsSearchResults,
(propsAndState: PropsAndState) => propsAndState.emptyItem,
(propsAndState: PropsAndState) => propsAndState.usersSearchResults,
(
reduxChatListData: $ReadOnlyArray,
searchText: string,
threadsSearchResults: Set,
emptyItem?: React.ComponentType<{||}>,
usersSearchResults: $ReadOnlyArray,
): Item[] => {
const chatItems = [];
if (!searchText) {
chatItems.push(
...reduxChatListData.filter(
(item) =>
threadIsTopLevel(item.threadInfo) &&
this.props.filterThreads(item.threadInfo),
),
);
} else {
const personalThreads = [];
const nonPersonalThreads = [];
for (const item of reduxChatListData) {
if (!threadsSearchResults.has(item.threadInfo.id)) {
continue;
}
if (item.threadInfo.type === threadTypes.PERSONAL) {
personalThreads.push({ ...item, sidebars: [] });
} else {
nonPersonalThreads.push({ ...item, sidebars: [] });
}
}
chatItems.push(...personalThreads, ...nonPersonalThreads);
const { viewerID } = this.props;
if (viewerID) {
chatItems.push(
...usersSearchResults.map((user) =>
createPendingThreadItem(viewerID, user),
),
);
}
}
if (emptyItem && chatItems.length === 0) {
chatItems.push({ type: 'empty', emptyItem });
}
return [{ type: 'search', searchText }, ...chatItems];
},
);
get listData() {
return this.listDataSelector({ ...this.props, ...this.state });
}
render() {
let floatingAction = null;
if (Platform.OS === 'android') {
floatingAction = (
);
}
// this.props.viewerID is in extraData since it's used by MessagePreview
// within ChatThreadListItem
return (
{floatingAction}
);
}
flatListRef = (flatList: ?FlatList
- ) => {
this.flatList = flatList;
};
onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => {
this.scrollPos = event.nativeEvent.contentOffset.y;
};
async searchUsers(usernamePrefix: string) {
if (usernamePrefix.length === 0) {
return [];
}
const { userInfos } = await this.props.searchUsers(usernamePrefix);
return userInfos.filter(
(info) =>
!this.props.usersWithPersonalThread.has(info.id) &&
info.id !== this.props.viewerID,
);
}
onChangeSearchText = async (searchText: string) => {
const results = this.props.threadSearchIndex.getSearchResults(searchText);
this.setState({ searchText, threadsSearchResults: new Set(results) });
const usersSearchResults = await this.searchUsers(searchText);
this.setState({ usersSearchResults });
};
onPressItem = (
threadInfo: ThreadInfo,
pendingPersonalThreadUserInfo?: UserInfo,
) => {
this.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigation.navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo }, pendingPersonalThreadUserInfo },
+ params: { threadInfo, pendingPersonalThreadUserInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => {
this.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigation.navigate({
name: SidebarListModalRouteName,
params: { threadInfo },
});
};
onSwipeableWillOpen = (threadInfo: ThreadInfo) => {
this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id }));
};
composeThread = () => {
if (this.props.viewerID) {
this.props.navigation.navigate({
name: MessageListRouteName,
params: {
- thread: {
- threadInfo: createPendingThread({
- viewerID: this.props.viewerID,
- threadType: threadTypes.CHAT_SECRET,
- }),
- },
+ threadInfo: createPendingThread({
+ viewerID: this.props.viewerID,
+ threadType: threadTypes.CHAT_SECRET,
+ }),
searching: true,
},
});
}
};
}
const unboundStyles = {
icon: {
fontSize: 28,
},
container: {
flex: 1,
},
search: {
marginBottom: 8,
marginHorizontal: 12,
marginTop: Platform.OS === 'android' ? 10 : 8,
},
flatList: {
flex: 1,
backgroundColor: 'listBackground',
},
};
export default React.memo(function ConnectedChatThreadList(
props: BaseProps,
) {
const boundChatListData = useFlattenedChatListData();
const viewerID = useSelector(
(state) => state.currentUserInfo && state.currentUserInfo.id,
);
const threadSearchIndex = useSelector(threadSearchIndexSelector);
const styles = useStyles(unboundStyles);
const indicatorStyle = useSelector(indicatorStyleSelector);
const callSearchUsers = useServerCall(searchUsers);
const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector);
return (
);
});
diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js
index 20999bc1f..1e78897e0 100644
--- a/native/chat/chat.react.js
+++ b/native/chat/chat.react.js
@@ -1,264 +1,263 @@
// @flow
import {
createMaterialTopTabNavigator,
type MaterialTopTabNavigationProp,
} from '@react-navigation/material-top-tabs';
import {
createNavigatorFactory,
useNavigationBuilder,
type StackNavigationState,
type StackOptions,
type StackNavigationEventMap,
type StackNavigatorProps,
type ExtraStackNavigatorProps,
type StackHeaderProps as CoreStackHeaderProps,
} from '@react-navigation/native';
import { StackView, type StackHeaderProps } from '@react-navigation/stack';
import invariant from 'invariant';
import * as React from 'react';
import { Platform, View } from 'react-native';
import { threadIsPending } from 'lib/shared/thread-utils';
import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react';
import { InputStateContext } from '../input/input-state';
import HeaderBackButton from '../navigation/header-back-button.react';
import {
ComposeThreadRouteName,
DeleteThreadRouteName,
ThreadSettingsRouteName,
MessageListRouteName,
ChatThreadListRouteName,
HomeChatThreadListRouteName,
BackgroundChatThreadListRouteName,
type ScreenParamList,
type ChatParamList,
type ChatTopTabsParamList,
} from '../navigation/route-names';
import { useStyles } from '../themes/colors';
import BackgroundChatThreadList from './background-chat-thread-list.react';
import ChatHeader from './chat-header.react';
import ChatRouter, { type ChatRouterNavigationProp } from './chat-router';
import ComposeThreadButton from './compose-thread-button.react';
import ComposeThread from './compose-thread.react';
import HomeChatThreadList from './home-chat-thread-list.react';
import MessageListContainer from './message-list-container.react';
import MessageListHeaderTitle from './message-list-header-title.react';
import MessageStorePruner from './message-store-pruner.react';
import DeleteThread from './settings/delete-thread.react';
import ThreadSettings from './settings/thread-settings.react';
import ThreadScreenPruner from './thread-screen-pruner.react';
import ThreadSettingsButton from './thread-settings-button.react';
const unboundStyles = {
keyboardAvoidingView: {
flex: 1,
},
view: {
flex: 1,
backgroundColor: 'listBackground',
},
threadListHeaderStyle: {
elevation: 0,
shadowOffset: { width: 0, height: 0 },
borderBottomWidth: 0,
},
};
export type ChatTopTabsNavigationProp<
RouteName: $Keys = $Keys,
> = MaterialTopTabNavigationProp;
const homeChatThreadListOptions = {
title: 'Home',
};
const backgroundChatThreadListOptions = {
title: 'Background',
};
const ChatThreadsTopTab = createMaterialTopTabNavigator();
const ChatThreadsComponent = () => {
return (
);
};
type ChatNavigatorProps = StackNavigatorProps>;
function ChatNavigator({
initialRouteName,
children,
screenOptions,
...rest
}: ChatNavigatorProps) {
const { state, descriptors, navigation } = useNavigationBuilder(ChatRouter, {
initialRouteName,
children,
screenOptions,
});
// Clear ComposeThread screens after each message is sent. If a user goes to
// ComposeThread to create a new thread, but finds an existing one and uses it
// instead, we can assume the intent behind opening ComposeThread is resolved
const inputState = React.useContext(InputStateContext);
invariant(inputState, 'InputState should be set in ChatNavigator');
const clearComposeScreensAfterMessageSend = React.useCallback(() => {
navigation.clearScreens([ComposeThreadRouteName]);
}, [navigation]);
React.useEffect(() => {
inputState.registerSendCallback(clearComposeScreensAfterMessageSend);
return () => {
inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend);
};
}, [inputState, clearComposeScreensAfterMessageSend]);
return (
);
}
const createChatNavigator = createNavigatorFactory<
StackNavigationState,
StackOptions,
StackNavigationEventMap,
ChatRouterNavigationProp<>,
ExtraStackNavigatorProps,
>(ChatNavigator);
const header = (props: CoreStackHeaderProps) => {
// Flow has trouble reconciling identical types between different libdefs,
// and flow-typed has no way for one libdef to depend on another
const castProps: StackHeaderProps = (props: any);
return ;
};
const headerBackButton = (props) => ;
const screenOptions = {
header,
headerLeft: headerBackButton,
gestureEnabled: Platform.OS === 'ios',
animationEnabled:
Platform.OS !== 'web' &&
Platform.OS !== 'windows' &&
Platform.OS !== 'macos',
};
const chatThreadListOptions = ({ navigation }) => ({
headerTitle: 'Threads',
headerRight:
Platform.OS === 'ios'
? () =>
: undefined,
headerBackTitle: 'Back',
headerStyle: unboundStyles.threadListHeaderStyle,
});
const messageListOptions = ({ navigation, route }) => ({
// This is a render prop, not a component
// eslint-disable-next-line react/display-name
headerTitle: () => (
),
headerTitleContainerStyle: {
marginHorizontal: Platform.select({ ios: 80, default: 0 }),
flex: 1,
},
headerRight:
- Platform.OS === 'android' &&
- !threadIsPending(route.params.thread.threadInfo.id)
+ Platform.OS === 'android' && !threadIsPending(route.params.threadInfo.id)
? // This is a render prop, not a component
// eslint-disable-next-line react/display-name
() => (
)
: undefined,
headerBackTitle: 'Back',
});
const composeThreadOptions = {
headerTitle: 'Compose thread',
headerBackTitle: 'Back',
};
const threadSettingsOptions = ({ route }) => ({
headerTitle: route.params.threadInfo.uiName,
headerBackTitle: 'Back',
});
const deleteThreadOptions = {
headerTitle: 'Delete thread',
headerBackTitle: 'Back',
};
export type ChatNavigationProp<
RouteName: $Keys = $Keys,
> = ChatRouterNavigationProp;
const Chat = createChatNavigator<
ScreenParamList,
ChatParamList,
ChatNavigationProp<>,
>();
export default function ChatComponent() {
const styles = useStyles(unboundStyles);
const behavior = Platform.select({
android: 'height',
default: 'padding',
});
return (
);
}
diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js
index ef05bdc9a..74f99f790 100644
--- a/native/chat/compose-thread-button.react.js
+++ b/native/chat/compose-thread-button.react.js
@@ -1,72 +1,70 @@
// @flow
import * as React from 'react';
import { StyleSheet } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { createPendingThread } from 'lib/shared/thread-utils';
import { threadTypes } from 'lib/types/thread-types';
import Button from '../components/button.react';
import { MessageListRouteName } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { type Colors, useColors } from '../themes/colors';
import type { ChatNavigationProp } from './chat.react';
type BaseProps = {|
+navigate: $PropertyType, 'navigate'>,
|};
type Props = {|
...BaseProps,
+colors: Colors,
+viewerID: ?string,
|};
class ComposeThreadButton extends React.PureComponent {
render() {
const { link: linkColor } = this.props.colors;
return (
);
}
onPress = () => {
if (this.props.viewerID) {
this.props.navigate({
name: MessageListRouteName,
params: {
- thread: {
- threadInfo: createPendingThread({
- viewerID: this.props.viewerID,
- threadType: threadTypes.CHAT_SECRET,
- }),
- },
+ threadInfo: createPendingThread({
+ viewerID: this.props.viewerID,
+ threadType: threadTypes.CHAT_SECRET,
+ }),
searching: true,
},
});
}
};
}
const styles = StyleSheet.create({
composeButton: {
paddingHorizontal: 10,
},
});
export default React.memo(function ConnectedComposeThreadButton(
props,
) {
const colors = useColors();
const viewerID = useSelector(
(state) => state.currentUserInfo && state.currentUserInfo.id,
);
return ;
});
diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js
index f3202f8ad..7ed05f0e6 100644
--- a/native/chat/compose-thread.react.js
+++ b/native/chat/compose-thread.react.js
@@ -1,526 +1,526 @@
// @flow
import invariant from 'invariant';
import _filter from 'lodash/fp/filter';
import _flow from 'lodash/fp/flow';
import _sortBy from 'lodash/fp/sortBy';
import PropTypes from 'prop-types';
import * as React from 'react';
import { View, Text, Alert } from 'react-native';
import { createSelector } from 'reselect';
import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors';
import { threadInfoSelector } from 'lib/selectors/thread-selectors';
import {
userInfoSelectorForPotentialMembers,
userSearchIndexForPotentialMembers,
} from 'lib/selectors/user-selectors';
import SearchIndex from 'lib/shared/search-index';
import { getPotentialMemberItems } from 'lib/shared/search-utils';
import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils';
import { loadingStatusPropType } from 'lib/types/loading-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import {
type ThreadInfo,
threadInfoPropType,
type ThreadType,
threadTypes,
threadTypePropType,
type NewThreadRequest,
type NewThreadResult,
} from 'lib/types/thread-types';
import {
type AccountUserInfo,
accountUserInfoPropType,
} from 'lib/types/user-types';
import type { DispatchActionPromise } from 'lib/utils/action-utils';
import {
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils';
import LinkButton from '../components/link-button.react';
import TagInput from '../components/tag-input.react';
import ThreadList from '../components/thread-list.react';
import ThreadVisibility from '../components/thread-visibility.react';
import UserList from '../components/user-list.react';
import type { NavigationRoute } from '../navigation/route-names';
import { MessageListRouteName } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import {
type Colors,
colorsPropType,
useColors,
useStyles,
} from '../themes/colors';
import type { ChatNavigationProp } from './chat.react';
const tagInputProps = {
placeholder: 'username',
autoFocus: true,
returnKeyType: 'go',
};
export type ComposeThreadParams = {|
threadType?: ThreadType,
parentThreadInfo?: ThreadInfo,
|};
type BaseProps = {|
+navigation: ChatNavigationProp<'ComposeThread'>,
+route: NavigationRoute<'ComposeThread'>,
|};
type Props = {|
...BaseProps,
// Redux state
+parentThreadInfo: ?ThreadInfo,
+loadingStatus: LoadingStatus,
+otherUserInfos: { [id: string]: AccountUserInfo },
+userSearchIndex: SearchIndex,
+threadInfos: { [id: string]: ThreadInfo },
+colors: Colors,
+styles: typeof unboundStyles,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+newThread: (request: NewThreadRequest) => Promise,
|};
type State = {|
+usernameInputText: string,
+userInfoInputArray: $ReadOnlyArray,
|};
type PropsAndState = {| ...Props, ...State |};
class ComposeThread extends React.PureComponent {
static propTypes = {
navigation: PropTypes.shape({
setParams: PropTypes.func.isRequired,
setOptions: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
pushNewThread: PropTypes.func.isRequired,
}).isRequired,
route: PropTypes.shape({
key: PropTypes.string.isRequired,
params: PropTypes.shape({
threadType: threadTypePropType,
parentThreadInfo: threadInfoPropType,
}).isRequired,
}).isRequired,
parentThreadInfo: threadInfoPropType,
loadingStatus: loadingStatusPropType.isRequired,
otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired,
userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired,
threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired,
colors: colorsPropType.isRequired,
styles: PropTypes.objectOf(PropTypes.object).isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
newThread: PropTypes.func.isRequired,
};
state: State = {
usernameInputText: '',
userInfoInputArray: [],
};
tagInput: ?TagInput;
createThreadPressed = false;
waitingOnThreadID: ?string;
componentDidMount() {
this.setLinkButton(true);
}
setLinkButton(enabled: boolean) {
this.props.navigation.setOptions({
headerRight: () => (
),
});
}
componentDidUpdate(prevProps: Props) {
const oldReduxParentThreadInfo = prevProps.parentThreadInfo;
const newReduxParentThreadInfo = this.props.parentThreadInfo;
if (
newReduxParentThreadInfo &&
newReduxParentThreadInfo !== oldReduxParentThreadInfo
) {
this.props.navigation.setParams({
parentThreadInfo: newReduxParentThreadInfo,
});
}
if (
this.waitingOnThreadID &&
this.props.threadInfos[this.waitingOnThreadID] &&
!prevProps.threadInfos[this.waitingOnThreadID]
) {
const threadInfo = this.props.threadInfos[this.waitingOnThreadID];
this.props.navigation.pushNewThread(threadInfo);
}
}
static getParentThreadInfo(props: {
route: NavigationRoute<'ComposeThread'>,
}): ?ThreadInfo {
return props.route.params.parentThreadInfo;
}
userSearchResultsSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.usernameInputText,
(propsAndState: PropsAndState) => propsAndState.otherUserInfos,
(propsAndState: PropsAndState) => propsAndState.userSearchIndex,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(propsAndState: PropsAndState) =>
ComposeThread.getParentThreadInfo(propsAndState),
(propsAndState: PropsAndState) => propsAndState.route.params.threadType,
(
text: string,
userInfos: { [id: string]: AccountUserInfo },
searchIndex: SearchIndex,
userInfoInputArray: $ReadOnlyArray,
parentThreadInfo: ?ThreadInfo,
threadType: ?ThreadType,
) =>
getPotentialMemberItems(
text,
userInfos,
searchIndex,
userInfoInputArray.map((userInfo) => userInfo.id),
parentThreadInfo,
threadType,
),
);
get userSearchResults() {
return this.userSearchResultsSelector({ ...this.props, ...this.state });
}
existingThreadsSelector = createSelector(
(propsAndState: PropsAndState) =>
ComposeThread.getParentThreadInfo(propsAndState),
(propsAndState: PropsAndState) => propsAndState.threadInfos,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(
parentThreadInfo: ?ThreadInfo,
threadInfos: { [id: string]: ThreadInfo },
userInfoInputArray: $ReadOnlyArray,
) => {
const userIDs = userInfoInputArray.map((userInfo) => userInfo.id);
if (userIDs.length === 0) {
return [];
}
return _flow(
_filter(
(threadInfo: ThreadInfo) =>
threadInFilterList(threadInfo) &&
(!parentThreadInfo ||
threadInfo.parentThreadID === parentThreadInfo.id) &&
userIDs.every((userID) => userIsMember(threadInfo, userID)),
),
_sortBy(
([
'members.length',
(threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0),
]: $ReadOnlyArray mixed)>),
),
)(threadInfos);
},
);
get existingThreads() {
return this.existingThreadsSelector({ ...this.props, ...this.state });
}
render() {
let existingThreadsSection = null;
const { existingThreads, userSearchResults } = this;
if (existingThreads.length > 0) {
existingThreadsSection = (
Existing threads
);
}
let parentThreadRow = null;
const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props);
if (parentThreadInfo) {
const threadType = this.props.route.params.threadType;
invariant(
threadType !== undefined && threadType !== null,
`no threadType provided for ${parentThreadInfo.id}`,
);
const threadVisibilityColor = this.props.colors.modalForegroundLabel;
parentThreadRow = (
within
{parentThreadInfo.uiName}
);
}
const inputProps = {
...tagInputProps,
onSubmitEditing: this.onPressCreateThread,
};
return (
{parentThreadRow}
To:
{existingThreadsSection}
);
}
tagInputRef = (tagInput: ?TagInput) => {
this.tagInput = tagInput;
};
onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => {
this.setState({ userInfoInputArray });
};
tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
setUsernameInputText = (text: string) => {
this.setState({ usernameInputText: text });
};
onUserSelect = (userID: string) => {
for (let existingUserInfo of this.state.userInfoInputArray) {
if (userID === existingUserInfo.id) {
return;
}
}
const userInfoInputArray = [
...this.state.userInfoInputArray,
this.props.otherUserInfos[userID],
];
this.setState({
userInfoInputArray,
usernameInputText: '',
});
};
onPressCreateThread = () => {
if (this.createThreadPressed) {
return;
}
if (this.state.userInfoInputArray.length === 0) {
Alert.alert(
'Chatting to yourself?',
'Are you sure you want to create a thread containing only yourself?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Confirm', onPress: this.dispatchNewChatThreadAction },
],
{ cancelable: true },
);
} else {
this.dispatchNewChatThreadAction();
}
};
dispatchNewChatThreadAction = async () => {
this.createThreadPressed = true;
this.props.dispatchActionPromise(
newThreadActionTypes,
this.newChatThreadAction(),
);
};
async newChatThreadAction() {
this.setLinkButton(false);
try {
const threadTypeParam = this.props.route.params.threadType;
const threadType = threadTypeParam ?? threadTypes.CHAT_SECRET;
const initialMemberIDs = this.state.userInfoInputArray.map(
(userInfo: AccountUserInfo) => userInfo.id,
);
const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props);
invariant(
threadType !== 5,
'Creating sidebars from thread composer is not yet supported',
);
const result = await this.props.newThread({
type: threadType,
parentThreadID: parentThreadInfo ? parentThreadInfo.id : null,
initialMemberIDs,
color: parentThreadInfo ? parentThreadInfo.color : null,
});
this.waitingOnThreadID = result.newThreadID;
return result;
} catch (e) {
this.createThreadPressed = false;
this.setLinkButton(true);
Alert.alert(
'Unknown error',
'Uhh... try again?',
[{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
throw e;
}
}
onErrorAcknowledged = () => {
invariant(this.tagInput, 'tagInput should be set');
this.tagInput.focus();
};
onUnknownErrorAlertAcknowledged = () => {
this.setState({ usernameInputText: '' }, this.onErrorAcknowledged);
};
onSelectExistingThread = (threadID: string) => {
const threadInfo = this.props.threadInfos[threadID];
this.props.navigation.navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
}
const unboundStyles = {
container: {
flex: 1,
},
existingThreadList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingRight: 12,
},
existingThreads: {
flex: 1,
},
existingThreadsLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
textAlign: 'center',
},
existingThreadsRow: {
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
borderTopWidth: 1,
paddingVertical: 6,
},
listItem: {
color: 'modalForegroundLabel',
},
parentThreadLabel: {
color: 'modalSubtextLabel',
fontSize: 16,
paddingLeft: 6,
},
parentThreadName: {
color: 'modalForegroundLabel',
fontSize: 16,
paddingLeft: 6,
},
parentThreadRow: {
alignItems: 'center',
backgroundColor: 'modalSubtext',
flexDirection: 'row',
paddingLeft: 12,
paddingVertical: 6,
},
tagInputContainer: {
flex: 1,
marginLeft: 8,
paddingRight: 12,
},
tagInputLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
},
userList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingLeft: 35,
paddingRight: 12,
},
userSelectionRow: {
alignItems: 'center',
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
flexDirection: 'row',
paddingVertical: 6,
},
};
export default React.memo(function ConnectedComposeThread(
props: BaseProps,
) {
const parentThreadInfoID = props.route.params.parentThreadInfo?.id;
const reduxParentThreadInfo = useSelector((state) =>
parentThreadInfoID ? threadInfoSelector(state)[parentThreadInfoID] : null,
);
const loadingStatus = useSelector(
createLoadingStatusSelector(newThreadActionTypes),
);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers);
const threadInfos = useSelector(threadInfoSelector);
const colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const callNewThread = useServerCall(newThread);
return (
);
});
diff --git a/native/chat/inline-sidebar.react.js b/native/chat/inline-sidebar.react.js
index 4b277df6b..65b6fb1bc 100644
--- a/native/chat/inline-sidebar.react.js
+++ b/native/chat/inline-sidebar.react.js
@@ -1,128 +1,128 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import * as React from 'react';
import { Text, View } from 'react-native';
import Icon from 'react-native-vector-icons/Feather';
import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors';
import { stringForUser } from 'lib/shared/user-utils';
import type { ThreadInfo } from 'lib/types/thread-types';
import { pluralizeAndTrim } from 'lib/utils/text-utils';
import Button from '../components/button.react';
import { MessageListRouteName } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { useStyles } from '../themes/colors';
import type { ChatNavigationProp } from './chat.react';
type Props = {|
+threadInfo: ThreadInfo,
+positioning: 'left' | 'center' | 'right',
|};
function InlineSidebar(props: Props) {
const { threadInfo } = props;
const navigation: ChatNavigationProp<'MessageList'> = (useNavigation(): any);
const onPress = React.useCallback(() => {
navigation.navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
}, [navigation, threadInfo]);
const styles = useStyles(unboundStyles);
let viewerIcon, nonViewerIcon, alignStyle;
if (props.positioning === 'right') {
viewerIcon = ;
alignStyle = styles.rightAlign;
} else if (props.positioning === 'left') {
nonViewerIcon = (
);
alignStyle = styles.leftAlign;
} else {
nonViewerIcon = (
);
alignStyle = styles.centerAlign;
}
const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null;
const repliesCount = threadInfo.repliesCount || 1;
const repliesText = `${repliesCount} ${
repliesCount > 1 ? 'replies' : 'reply'
}`;
const threadMembers = useSelector(
relativeMemberInfoSelectorForMembersOfThread(threadInfo.id),
);
const sendersText = React.useMemo(() => {
const senders = threadMembers
.filter((member) => member.isSender)
.map(stringForUser);
return senders.length > 0 ? `${pluralizeAndTrim(senders, 25)} sent ` : '';
}, [threadMembers]);
return (
);
}
const inlineSidebarHeight = 20;
const inlineSidebarMarginTop = 5;
const inlineSidebarMarginBottom = 3;
const unboundStyles = {
content: {
flexDirection: 'row',
marginRight: 30,
marginLeft: 10,
flex: 1,
height: inlineSidebarHeight,
},
unread: {
fontWeight: 'bold',
},
sidebar: {
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
},
icon: {
color: 'listForegroundTertiaryLabel',
},
name: {
paddingTop: 1,
color: 'listForegroundTertiaryLabel',
fontSize: 16,
paddingLeft: 4,
paddingRight: 2,
},
leftAlign: {
justifyContent: 'flex-start',
},
rightAlign: {
justifyContent: 'flex-end',
},
centerAlign: {
justifyContent: 'center',
},
};
export {
InlineSidebar,
inlineSidebarHeight,
inlineSidebarMarginTop,
inlineSidebarMarginBottom,
};
diff --git a/native/chat/inner-robotext-message.react.js b/native/chat/inner-robotext-message.react.js
index 822df52f8..344571061 100644
--- a/native/chat/inner-robotext-message.react.js
+++ b/native/chat/inner-robotext-message.react.js
@@ -1,165 +1,165 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { Text, TouchableWithoutFeedback, View } from 'react-native';
import { threadInfoSelector } from 'lib/selectors/thread-selectors';
import {
splitRobotext,
parseRobotextEntity,
robotextToRawString,
} from 'lib/shared/message-utils';
import Markdown from '../markdown/markdown.react';
import { inlineMarkdownRules } from '../markdown/rules.react';
import type { AppNavigationProp } from '../navigation/app-navigator.react';
import { MessageListRouteName } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { useOverlayStyles } from '../themes/colors';
import type { ChatNavigationProp } from './chat.react';
import type { ChatRobotextMessageInfoItemWithHeight } from './robotext-message.react';
function dummyNodeForRobotextMessageHeightMeasurement(robotext: string) {
return (
{robotextToRawString(robotext)}
);
}
type InnerRobotextMessageProps = {|
+item: ChatRobotextMessageInfoItemWithHeight,
+navigation:
| ChatNavigationProp<'MessageList'>
| AppNavigationProp<'RobotextMessageTooltipModal'>,
+onPress: () => void,
+onLongPress?: () => void,
|};
function InnerRobotextMessage(props: InnerRobotextMessageProps) {
const { item, navigation, onLongPress, onPress } = props;
const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme);
const styles = useOverlayStyles(unboundStyles);
const { robotext } = item;
const robotextParts = splitRobotext(robotext);
const textParts = [];
let keyIndex = 0;
for (const splitPart of robotextParts) {
if (splitPart === '') {
continue;
}
if (splitPart.charAt(0) !== '<') {
const darkColor = activeTheme === 'dark';
const key = `text${keyIndex++}`;
textParts.push(
{decodeURI(splitPart)}
,
);
continue;
}
const { rawText, entityType, id } = parseRobotextEntity(splitPart);
if (entityType === 't' && id !== item.messageInfo.threadID) {
textParts.push(
,
);
} else if (entityType === 'c') {
textParts.push();
} else {
textParts.push(rawText);
}
}
const viewStyle = [styles.robotextContainer];
if (!__DEV__) {
// We don't force view height in dev mode because we
// want to measure it in Message to see if it's correct
viewStyle.push({ height: item.contentHeight });
}
return (
{textParts}
);
}
type ThreadEntityProps = {|
+id: string,
+name: string,
+navigation:
| ChatNavigationProp<'MessageList'>
| AppNavigationProp<'RobotextMessageTooltipModal'>,
|};
function ThreadEntity(props: ThreadEntityProps) {
const threadID = props.id;
const threadInfo = useSelector(
(state) => threadInfoSelector(state)[threadID],
);
const styles = useOverlayStyles(unboundStyles);
const { navigate } = props.navigation;
const onPressThread = React.useCallback(() => {
invariant(threadInfo, 'onPressThread should have threadInfo');
navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
}, [threadInfo, navigate]);
if (!threadInfo) {
return {props.name};
}
return (
{props.name}
);
}
function ColorEntity(props: {| +color: string |}) {
const colorStyle = { color: props.color };
return {props.color};
}
const unboundStyles = {
link: {
color: 'link',
},
robotextContainer: {
paddingTop: 6,
paddingBottom: 11,
paddingHorizontal: 24,
},
robotext: {
color: 'listForegroundSecondaryLabel',
fontFamily: 'Arial',
fontSize: 15,
textAlign: 'center',
},
dummyRobotext: {
fontFamily: 'Arial',
fontSize: 15,
textAlign: 'center',
},
};
export { dummyNodeForRobotextMessageHeightMeasurement, InnerRobotextMessage };
diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js
index 55794273a..2a88c0e65 100644
--- a/native/chat/message-list-container.react.js
+++ b/native/chat/message-list-container.react.js
@@ -1,520 +1,515 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View } from 'react-native';
import {
type ChatMessageItem,
messageListData as messageListDataSelector,
messageInfoSelector,
getSourceMessageChatItemForPendingSidebar,
} from 'lib/selectors/chat-selectors';
import {
threadInfoSelector,
threadInfoFromSourceMessageIDSelector,
} from 'lib/selectors/thread-selectors';
import {
userInfoSelectorForPotentialMembers,
userSearchIndexForPotentialMembers,
} from 'lib/selectors/user-selectors';
import { messageID } from 'lib/shared/message-utils';
import { getPotentialMemberItems } from 'lib/shared/search-utils';
import {
createPendingThread,
getCurrentUser,
getPendingThreadKey,
pendingThreadType,
threadHasAdminRole,
threadIsPending,
} from 'lib/shared/thread-utils';
import { messageTypes } from 'lib/types/message-types';
import { type ThreadInfo, threadTypes } from 'lib/types/thread-types';
import type { AccountUserInfo, UserListItem } from 'lib/types/user-types';
import ContentLoading from '../components/content-loading.react';
import NodeHeightMeasurer from '../components/node-height-measurer.react';
import { type InputState, InputStateContext } from '../input/input-state';
import {
OverlayContext,
type OverlayContextType,
} from '../navigation/overlay-context';
import type { NavigationRoute } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { type Colors, useColors, useStyles } from '../themes/colors';
import ChatInputBar from './chat-input-bar.react';
import { chatMessageItemKey } from './chat-list.react';
import type { ChatNavigationProp } from './chat.react';
import { composedMessageMaxWidthSelector } from './composed-message-width';
import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react';
import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react';
import MessageListThreadSearch from './message-list-thread-search.react';
import {
MessageListContext,
useMessageListContext,
} from './message-list-types';
import MessageList from './message-list.react';
import type { ChatMessageInfoItemWithHeight } from './message.react';
import { multimediaMessageContentSizes } from './multimedia-message.react';
export type ChatMessageItemWithHeight =
| {| itemType: 'loader' |}
| ChatMessageInfoItemWithHeight;
type BaseProps = {|
+navigation: ChatNavigationProp<'MessageList'>,
+route: NavigationRoute<'MessageList'>,
|};
type Props = {|
...BaseProps,
// Redux state
+usernameInputText: string,
+updateUsernameInput: (text: string) => void,
+userInfoInputArray: $ReadOnlyArray,
+updateTagInput: (items: $ReadOnlyArray) => void,
+otherUserInfos: { [id: string]: AccountUserInfo },
+userSearchResults: $ReadOnlyArray,
+threadInfo: ThreadInfo,
+messageListData: $ReadOnlyArray,
+composedMessageMaxWidth: number,
+colors: Colors,
+styles: typeof unboundStyles,
// withInputState
+inputState: ?InputState,
// withOverlayContext
+overlayContext: ?OverlayContextType,
|};
type State = {|
+listDataWithHeights: ?$ReadOnlyArray,
|};
class MessageListContainer extends React.PureComponent {
state: State = {
listDataWithHeights: null,
};
pendingListDataWithHeights: ?$ReadOnlyArray;
get frozen() {
const { overlayContext } = this.props;
invariant(
overlayContext,
'MessageListContainer should have OverlayContext',
);
return overlayContext.scrollBlockingModalStatus !== 'closed';
}
componentDidUpdate(prevProps: Props) {
const oldListData = prevProps.messageListData;
const newListData = this.props.messageListData;
if (!newListData && oldListData) {
this.setState({ listDataWithHeights: null });
}
if (!this.frozen && this.pendingListDataWithHeights) {
this.setState({ listDataWithHeights: this.pendingListDataWithHeights });
this.pendingListDataWithHeights = undefined;
}
}
render() {
const { threadInfo, styles } = this.props;
const { listDataWithHeights } = this.state;
const { searching } = this.props.route.params;
let searchComponent = null;
if (searching) {
searchComponent = (
);
}
const showMessageList =
!searching || this.props.userInfoInputArray.length > 0;
let threadContent = null;
if (showMessageList) {
let messageList;
if (listDataWithHeights) {
messageList = (
);
} else {
messageList = (
);
}
threadContent = (
{messageList}
);
}
return (
{searchComponent}
{threadContent}
);
}
heightMeasurerID = (item: ChatMessageItem) => {
return chatMessageItemKey(item);
};
heightMeasurerKey = (item: ChatMessageItem) => {
if (item.itemType !== 'message') {
return null;
}
const { messageInfo } = item;
if (messageInfo.type === messageTypes.TEXT) {
return messageInfo.text;
} else if (item.robotext && typeof item.robotext === 'string') {
return item.robotext;
}
return null;
};
heightMeasurerDummy = (item: ChatMessageItem) => {
invariant(
item.itemType === 'message',
'NodeHeightMeasurer asked for dummy for non-message item',
);
const { messageInfo } = item;
if (messageInfo.type === messageTypes.TEXT) {
return dummyNodeForTextMessageHeightMeasurement(messageInfo.text);
} else if (item.robotext && typeof item.robotext === 'string') {
return dummyNodeForRobotextMessageHeightMeasurement(item.robotext);
}
invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message');
};
heightMeasurerMergeItem = (item: ChatMessageItem, height: ?number) => {
if (item.itemType !== 'message') {
return item;
}
const { messageInfo } = item;
invariant(
messageInfo.type !== messageTypes.SIDEBAR_SOURCE,
'Sidebar source messages should be replaced by sourceMessage before being measured',
);
const { threadInfo } = this.props;
if (
messageInfo.type === messageTypes.IMAGES ||
messageInfo.type === messageTypes.MULTIMEDIA
) {
const { inputState } = this.props;
// Conditional due to Flow...
const localMessageInfo = item.localMessageInfo
? item.localMessageInfo
: null;
const id = messageID(messageInfo);
const pendingUploads =
inputState &&
inputState.pendingUploads &&
inputState.pendingUploads[id];
const sizes = multimediaMessageContentSizes(
messageInfo,
this.props.composedMessageMaxWidth,
);
return {
itemType: 'message',
messageShapeType: 'multimedia',
messageInfo,
localMessageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
pendingUploads,
...sizes,
};
}
invariant(height !== null && height !== undefined, 'height should be set');
if (messageInfo.type === messageTypes.TEXT) {
// Conditional due to Flow...
const localMessageInfo = item.localMessageInfo
? item.localMessageInfo
: null;
return {
itemType: 'message',
messageShapeType: 'text',
messageInfo,
localMessageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
contentHeight: height,
};
} else {
invariant(
typeof item.robotext === 'string',
"Flow can't handle our fancy types :(",
);
return {
itemType: 'message',
messageShapeType: 'robotext',
messageInfo,
threadInfo,
startsConversation: item.startsConversation,
startsCluster: item.startsCluster,
endsCluster: item.endsCluster,
threadCreatedFromMessage: item.threadCreatedFromMessage,
robotext: item.robotext,
contentHeight: height,
};
}
};
allHeightsMeasured = (
listDataWithHeights: $ReadOnlyArray,
) => {
if (this.frozen) {
this.pendingListDataWithHeights = listDataWithHeights;
} else {
this.setState({ listDataWithHeights });
}
};
}
const unboundStyles = {
container: {
backgroundColor: 'listBackground',
flex: 1,
},
threadContent: {
flex: 1,
},
};
export default React.memo(function ConnectedMessageListContainer(
props: BaseProps,
) {
const viewerID = useSelector(
(state) => state.currentUserInfo && state.currentUserInfo.id,
);
const [usernameInputText, setUsernameInputText] = React.useState('');
const [userInfoInputArray, setUserInfoInputArray] = React.useState<
$ReadOnlyArray,
>([]);
const updateTagInput = React.useCallback(
(input: $ReadOnlyArray) => setUserInfoInputArray(input),
[],
);
const updateUsernameInput = React.useCallback(
(text: string) => setUsernameInputText(text),
[],
);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers);
const userSearchResults = React.useMemo(
() =>
getPotentialMemberItems(
usernameInputText,
otherUserInfos,
userSearchIndex,
userInfoInputArray.map((userInfo) => userInfo.id),
),
[usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray],
);
const threadInfos = useSelector(threadInfoSelector);
const userInfos = useSelector((state) => state.userStore.userInfos);
- const threadInfoRef = React.useRef(props.route.params.thread.threadInfo);
+ const threadInfoRef = React.useRef(props.route.params.threadInfo);
const [originalThreadInfo, setOriginalThreadInfo] = React.useState(
- props.route.params.thread.threadInfo,
+ props.route.params.threadInfo,
);
const { searching } = props.route.params;
const inputState = React.useContext(InputStateContext);
const hideSearch = React.useCallback(() => {
setOriginalThreadInfo(threadInfoRef.current);
props.navigation.setParams({
searching: false,
});
}, [props.navigation]);
React.useEffect(() => {
if (!searching) {
return;
}
inputState?.registerSendCallback(hideSearch);
return () => inputState?.unregisterSendCallback(hideSearch);
}, [hideSearch, inputState, searching]);
const threadCandidates = React.useMemo(() => {
const infos = new Map();
for (const threadID in threadInfos) {
const info = threadInfos[threadID];
if (info.parentThreadID || threadHasAdminRole(info)) {
continue;
}
const key = getPendingThreadKey(info.members.map((member) => member.id));
const indexedThread = infos.get(key);
if (!indexedThread || info.creationTime < indexedThread.creationTime) {
infos.set(key, info);
}
}
return infos;
}, [threadInfos]);
- const { sourceMessageID } = props.route.params.thread;
+ const { sourceMessageID } = props.route.params;
const sidebarCandidate = useSelector((state) => {
if (!sourceMessageID) {
return null;
}
return threadInfoFromSourceMessageIDSelector(state)[sourceMessageID];
});
const latestThreadInfo = React.useMemo((): ?ThreadInfo => {
const threadInfoFromParams = originalThreadInfo;
const threadInfoFromStore = threadInfos[threadInfoFromParams.id];
if (threadInfoFromStore) {
return threadInfoFromStore;
} else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) {
return undefined;
}
const pendingThreadMemberIDs = searching
? [...userInfoInputArray.map((user) => user.id), viewerID]
: threadInfoFromParams.members.map((member) => member.id);
const threadKey = getPendingThreadKey(pendingThreadMemberIDs);
if (
threadInfoFromParams.type !== threadTypes.SIDEBAR &&
threadCandidates.get(threadKey)
) {
return threadCandidates.get(threadKey);
}
if (sidebarCandidate) {
return sidebarCandidate;
}
const updatedThread = searching
? createPendingThread({
viewerID,
threadType: pendingThreadType(userInfoInputArray.length),
members: userInfoInputArray,
})
: threadInfoFromParams;
return {
...updatedThread,
currentUser: getCurrentUser(updatedThread, viewerID, userInfos),
};
}, [
originalThreadInfo,
threadInfos,
viewerID,
searching,
userInfoInputArray,
threadCandidates,
sidebarCandidate,
userInfos,
]);
if (latestThreadInfo) {
threadInfoRef.current = latestThreadInfo;
}
const threadInfo = threadInfoRef.current;
const { setParams } = props.navigation;
React.useEffect(() => {
- setParams({
- thread: {
- threadInfo,
- sourceMessageID,
- },
- });
- }, [setParams, sourceMessageID, threadInfo]);
+ setParams({ threadInfo });
+ }, [setParams, threadInfo]);
const threadID = threadInfoRef.current.id;
const boundMessageListData = useSelector(messageListDataSelector(threadID));
const sidebarSourceMessageInfo = useSelector((state) =>
sourceMessageID && !sidebarCandidate
? messageInfoSelector(state)[sourceMessageID]
: null,
);
invariant(
!sidebarSourceMessageInfo ||
sidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE,
'sidebars can not be created from sidebar_source message',
);
const messageListData = React.useMemo(() => {
if (searching && userInfoInputArray.length === 0) {
return [];
} else if (sidebarSourceMessageInfo) {
return [
getSourceMessageChatItemForPendingSidebar(
sidebarSourceMessageInfo,
threadInfos,
),
];
}
return boundMessageListData;
}, [
searching,
userInfoInputArray.length,
sidebarSourceMessageInfo,
boundMessageListData,
threadInfos,
]);
const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector);
const colors = useColors();
const styles = useStyles(unboundStyles);
const overlayContext = React.useContext(OverlayContext);
const messageListContext = useMessageListContext(threadID);
return (
);
});
diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js
index d8ecde9b0..f95358d31 100644
--- a/native/chat/message-list-types.js
+++ b/native/chat/message-list-types.js
@@ -1,58 +1,60 @@
// @flow
import PropTypes from 'prop-types';
import * as React from 'react';
-import { optimisticThreadInfoPropType } from 'lib/types/thread-types';
-import type { OptimisticThreadInfo } from 'lib/types/thread-types';
+import { threadInfoPropType } from 'lib/types/thread-types';
+import type { ThreadInfo } from 'lib/types/thread-types';
import { type UserInfo, userInfoPropType } from 'lib/types/user-types';
import type { MarkdownRules } from '../markdown/rules.react';
import { useTextMessageRulesFunc } from '../markdown/rules.react';
export type MessageListParams = {|
- +thread: OptimisticThreadInfo,
+ +threadInfo: ThreadInfo,
+ +sourceMessageID?: string,
+pendingPersonalThreadUserInfo?: UserInfo,
+searching?: boolean,
|};
const messageListRoutePropType = PropTypes.shape({
key: PropTypes.string.isRequired,
params: PropTypes.shape({
- thread: optimisticThreadInfoPropType.isRequired,
+ threadInfo: threadInfoPropType.isRequired,
+ sourceMessageID: PropTypes.string,
pendingPersonalThreadUserInfo: userInfoPropType,
searching: PropTypes.bool,
}).isRequired,
});
const messageListNavPropType = PropTypes.shape({
navigate: PropTypes.func.isRequired,
setParams: PropTypes.func.isRequired,
setOptions: PropTypes.func.isRequired,
dangerouslyGetParent: PropTypes.func.isRequired,
isFocused: PropTypes.func.isRequired,
popToTop: PropTypes.func.isRequired,
});
export type MessageListContextType = {|
+getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules,
|};
const MessageListContext = React.createContext();
function useMessageListContext(threadID: string) {
const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadID);
return React.useMemo(
() => ({
getTextMessageMarkdownRules,
}),
[getTextMessageMarkdownRules],
);
}
export {
messageListRoutePropType,
messageListNavPropType,
MessageListContext,
useMessageListContext,
};
diff --git a/native/chat/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js
index f59a9074c..143c32c00 100644
--- a/native/chat/settings/thread-settings-child-thread.react.js
+++ b/native/chat/settings/thread-settings-child-thread.react.js
@@ -1,88 +1,88 @@
// @flow
import * as React from 'react';
import { View, Platform } from 'react-native';
import type { ThreadInfo } from 'lib/types/thread-types';
import Button from '../../components/button.react';
import ColorSplotch from '../../components/color-splotch.react';
import { SingleLine } from '../../components/single-line.react';
import ThreadIcon from '../../components/thread-icon.react';
import { MessageListRouteName } from '../../navigation/route-names';
import { useColors, useStyles } from '../../themes/colors';
import type { ThreadSettingsNavigate } from './thread-settings.react';
type Props = {|
+threadInfo: ThreadInfo,
+navigate: ThreadSettingsNavigate,
+firstListItem: boolean,
+lastListItem: boolean,
|};
function ThreadSettingsChildThread(props: Props) {
const { navigate, threadInfo } = props;
const onPress = React.useCallback(() => {
navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
}, [navigate, threadInfo]);
const styles = useStyles(unboundStyles);
const colors = useColors();
const firstItem = props.firstListItem ? null : styles.topBorder;
const lastItem = props.lastListItem ? styles.lastButton : null;
return (
);
}
const unboundStyles = {
button: {
flex: 1,
flexDirection: 'row',
paddingVertical: 8,
paddingLeft: 12,
paddingRight: 10,
alignItems: 'center',
},
topBorder: {
borderColor: 'panelForegroundBorder',
borderTopWidth: 1,
},
container: {
backgroundColor: 'panelForeground',
flex: 1,
paddingHorizontal: 12,
},
lastButton: {
paddingBottom: Platform.OS === 'ios' ? 12 : 10,
paddingTop: 8,
},
leftSide: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
text: {
flex: 1,
color: 'link',
fontSize: 16,
paddingLeft: 8,
},
};
export default ThreadSettingsChildThread;
diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js
index a7749bd4c..d1a3737ad 100644
--- a/native/chat/settings/thread-settings-parent.react.js
+++ b/native/chat/settings/thread-settings-parent.react.js
@@ -1,145 +1,145 @@
// @flow
import invariant from 'invariant';
import PropTypes from 'prop-types';
import * as React from 'react';
import { Text, View, Platform } from 'react-native';
import { threadInfoSelector } from 'lib/selectors/thread-selectors';
import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types';
import { connect } from 'lib/utils/redux-utils';
import Button from '../../components/button.react';
import { SingleLine } from '../../components/single-line.react';
import { MessageListRouteName } from '../../navigation/route-names';
import type { AppState } from '../../redux/redux-setup';
import { styleSelector } from '../../themes/colors';
import type { ThreadSettingsNavigate } from './thread-settings.react';
type Props = {|
threadInfo: ThreadInfo,
navigate: ThreadSettingsNavigate,
// Redux state
parentThreadInfo?: ?ThreadInfo,
styles: typeof styles,
|};
class ThreadSettingsParent extends React.PureComponent {
static propTypes = {
threadInfo: threadInfoPropType.isRequired,
navigate: PropTypes.func.isRequired,
parentThreadInfo: threadInfoPropType,
styles: PropTypes.objectOf(PropTypes.object).isRequired,
};
render() {
let parent;
if (this.props.parentThreadInfo) {
parent = (
);
} else if (this.props.threadInfo.parentThreadID) {
parent = (
Secret parent
);
} else {
parent = (
No parent
);
}
return (
Parent
{parent}
);
}
onPressParentThread = () => {
const threadInfo = this.props.parentThreadInfo;
invariant(threadInfo, 'should be set');
this.props.navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
}
const styles = {
currentValue: {
flex: 1,
paddingLeft: 4,
paddingTop: Platform.OS === 'ios' ? 5 : 4,
},
currentValueText: {
color: 'panelForegroundSecondaryLabel',
fontFamily: 'Arial',
fontSize: 16,
margin: 0,
paddingRight: 0,
},
label: {
color: 'panelForegroundTertiaryLabel',
fontSize: 16,
paddingVertical: 4,
width: 96,
},
noParent: {
fontStyle: 'italic',
paddingLeft: 2,
},
parentThreadLink: {
color: 'link',
},
row: {
backgroundColor: 'panelForeground',
flexDirection: 'row',
paddingHorizontal: 24,
},
};
const stylesSelector = styleSelector(styles);
export default connect(
(state: AppState, ownProps: { threadInfo: ThreadInfo }) => {
const parsedThreadInfos = threadInfoSelector(state);
const parentThreadInfo: ?ThreadInfo = ownProps.threadInfo.parentThreadID
? parsedThreadInfos[ownProps.threadInfo.parentThreadID]
: null;
return {
parentThreadInfo,
styles: stylesSelector(state),
};
},
)(ThreadSettingsParent);
diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js
index 56640bc52..45acf6f52 100644
--- a/native/chat/sidebar-list-modal.react.js
+++ b/native/chat/sidebar-list-modal.react.js
@@ -1,162 +1,162 @@
// @flow
import * as React from 'react';
import { TextInput, FlatList, StyleSheet } from 'react-native';
import { sidebarInfoSelector } from 'lib/selectors/thread-selectors';
import SearchIndex from 'lib/shared/search-index';
import { threadSearchText } from 'lib/shared/thread-utils';
import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types';
import Modal from '../components/modal.react';
import Search from '../components/search.react';
import type { RootNavigationProp } from '../navigation/root-navigator.react';
import type { NavigationRoute } from '../navigation/route-names';
import { MessageListRouteName } from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { useIndicatorStyle } from '../themes/colors';
import { waitForModalInputFocus } from '../utils/timers';
import SidebarItem from './sidebar-item.react';
export type SidebarListModalParams = {|
+threadInfo: ThreadInfo,
|};
function keyExtractor(sidebarInfo: SidebarInfo) {
return sidebarInfo.threadInfo.id;
}
function getItemLayout(data: ?$ReadOnlyArray, index: number) {
return { length: 24, offset: 24 * index, index };
}
type Props = {|
+navigation: RootNavigationProp<'SidebarListModal'>,
+route: NavigationRoute<'SidebarListModal'>,
|};
function SidebarListModal(props: Props) {
const threadID = props.route.params.threadInfo.id;
const sidebarInfos = useSelector(
(state) => sidebarInfoSelector(state)[threadID] ?? [],
);
const [searchState, setSearchState] = React.useState({
text: '',
results: new Set(),
});
const listData = React.useMemo(() => {
if (!searchState.text) {
return sidebarInfos;
}
return sidebarInfos.filter(({ threadInfo }) =>
searchState.results.has(threadInfo.id),
);
}, [sidebarInfos, searchState]);
const userInfos = useSelector((state) => state.userStore.userInfos);
const searchIndex = React.useMemo(() => {
const index = new SearchIndex();
for (const sidebarInfo of sidebarInfos) {
const { threadInfo } = sidebarInfo;
index.addEntry(threadInfo.id, threadSearchText(threadInfo, userInfos));
}
return index;
}, [sidebarInfos, userInfos]);
React.useEffect(() => {
setSearchState((curState) => ({
...curState,
results: new Set(searchIndex.getSearchResults(curState.text)),
}));
}, [searchIndex]);
const onChangeSearchText = React.useCallback(
(searchText: string) =>
setSearchState({
text: searchText,
results: new Set(searchIndex.getSearchResults(searchText)),
}),
[searchIndex],
);
const searchTextInputRef = React.useRef();
const setSearchTextInputRef = React.useCallback(
async (textInput: ?React.ElementRef) => {
searchTextInputRef.current = textInput;
if (!textInput) {
return;
}
await waitForModalInputFocus();
if (searchTextInputRef.current) {
searchTextInputRef.current.focus();
}
},
[],
);
const { navigation } = props;
const { navigate } = navigation;
const onPressItem = React.useCallback(
(threadInfo: ThreadInfo) => {
setSearchState({
text: '',
results: new Set(),
});
if (searchTextInputRef.current) {
searchTextInputRef.current.blur();
}
navigate({
name: MessageListRouteName,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
},
[navigate],
);
const renderItem = React.useCallback(
(row: { item: SidebarInfo, ... }) => {
return (
);
},
[onPressItem],
);
const indicatorStyle = useIndicatorStyle();
return (
);
}
const styles = StyleSheet.create({
search: {
marginBottom: 8,
},
sidebar: {
marginLeft: 0,
marginRight: 5,
},
});
export default SidebarListModal;
diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js
index 450688de4..70d7d8795 100644
--- a/native/chat/sidebar-navigation.js
+++ b/native/chat/sidebar-navigation.js
@@ -1,102 +1,98 @@
// @flow
import invariant from 'invariant';
import { createPendingSidebar } from 'lib/shared/thread-utils';
import type {
DispatchFunctions,
ActionFunc,
BoundServerCall,
} from 'lib/utils/action-utils';
import type { InputState } from '../input/input-state';
import { getDefaultTextMessageRules } from '../markdown/rules.react';
import type { AppNavigationProp } from '../navigation/app-navigator.react';
import { MessageListRouteName } from '../navigation/route-names';
import type { TooltipRoute } from '../navigation/tooltip.react';
function onPressGoToSidebar(
route:
| TooltipRoute<'RobotextMessageTooltipModal'>
| TooltipRoute<'TextMessageTooltipModal'>
| TooltipRoute<'MultimediaTooltipModal'>,
dispatchFunctions: DispatchFunctions,
bindServerCall: (serverCall: ActionFunc) => BoundServerCall,
inputState: ?InputState,
navigation:
| AppNavigationProp<'RobotextMessageTooltipModal'>
| AppNavigationProp<'TextMessageTooltipModal'>
| AppNavigationProp<'MultimediaTooltipModal'>,
) {
let threadCreatedFromMessage;
// Necessary for Flow...
if (route.name === 'RobotextMessageTooltipModal') {
threadCreatedFromMessage = route.params.item.threadCreatedFromMessage;
} else {
threadCreatedFromMessage = route.params.item.threadCreatedFromMessage;
}
invariant(
threadCreatedFromMessage,
'threadCreatedFromMessage should be set in onPressGoToSidebar',
);
navigation.navigate({
name: MessageListRouteName,
params: {
- thread: {
- threadInfo: threadCreatedFromMessage,
- },
+ threadInfo: threadCreatedFromMessage,
},
key: `${MessageListRouteName}${threadCreatedFromMessage.id}`,
});
}
function onPressCreateSidebar(
route:
| TooltipRoute<'RobotextMessageTooltipModal'>
| TooltipRoute<'TextMessageTooltipModal'>
| TooltipRoute<'MultimediaTooltipModal'>,
dispatchFunctions: DispatchFunctions,
bindServerCall: (serverCall: ActionFunc) => BoundServerCall,
inputState: ?InputState,
navigation:
| AppNavigationProp<'RobotextMessageTooltipModal'>
| AppNavigationProp<'TextMessageTooltipModal'>
| AppNavigationProp<'MultimediaTooltipModal'>,
viewerID: ?string,
) {
invariant(
viewerID,
'viewerID should be set in TextMessageTooltipModal.onPressCreateSidebar',
);
let itemFromParams;
// Necessary for Flow...
if (route.name === 'RobotextMessageTooltipModal') {
itemFromParams = route.params.item;
} else {
itemFromParams = route.params.item;
}
const { messageInfo, threadInfo } = itemFromParams;
const pendingSidebarInfo = createPendingSidebar(
messageInfo,
threadInfo,
viewerID,
getDefaultTextMessageRules().simpleMarkdownRules,
);
const sourceMessageID = messageInfo.id;
navigation.navigate({
name: MessageListRouteName,
params: {
- thread: {
- threadInfo: pendingSidebarInfo,
- sourceMessageID,
- },
+ threadInfo: pendingSidebarInfo,
+ sourceMessageID,
},
key: `${MessageListRouteName}${pendingSidebarInfo.id}`,
});
}
export { onPressGoToSidebar, onPressCreateSidebar };
diff --git a/native/navigation/navigation-utils.js b/native/navigation/navigation-utils.js
index 1c8c4ce87..ff6dbc56d 100644
--- a/native/navigation/navigation-utils.js
+++ b/native/navigation/navigation-utils.js
@@ -1,161 +1,160 @@
// @flow
import type {
PossiblyStaleNavigationState,
PossiblyStaleRoute,
StaleLeafRoute,
ScreenParams,
} from '@react-navigation/native';
import invariant from 'invariant';
import {
ComposeThreadRouteName,
AppRouteName,
threadRoutes,
} from './route-names';
function getStateFromNavigatorRoute(
route: PossiblyStaleRoute<>,
): PossiblyStaleNavigationState {
const key = route.key ? route.key : `unkeyed ${route.name}`;
invariant(route.state, `expecting Route for ${key} to be NavigationState`);
return route.state;
}
function getThreadIDFromParams(params: ?ScreenParams): string {
invariant(
params &&
- params.thread &&
- params.thread.threadInfo &&
- typeof params.thread.threadInfo === 'object' &&
- params.thread.threadInfo.id &&
- typeof params.thread.threadInfo.id === 'string',
+ params.threadInfo &&
+ typeof params.threadInfo === 'object' &&
+ params.threadInfo.id &&
+ typeof params.threadInfo.id === 'string',
"there's no way in react-navigation/Flow to type this",
);
- return params.thread.threadInfo.id;
+ return params.threadInfo.id;
}
function getParentThreadIDFromParams(params: ?ScreenParams): ?string {
if (!params) {
return undefined;
}
const { parentThreadInfo } = params;
if (!parentThreadInfo) {
return undefined;
}
invariant(
typeof parentThreadInfo === 'object' &&
parentThreadInfo.id &&
typeof parentThreadInfo.id === 'string',
"there's no way in react-navigation/Flow to type this",
);
return parentThreadInfo.id;
}
function getThreadIDFromRoute(
route: PossiblyStaleRoute<>,
routes?: $ReadOnlyArray = threadRoutes,
) {
if (!routes.includes(route.name)) {
return null;
}
if (route.name === ComposeThreadRouteName) {
return getParentThreadIDFromParams(route.params);
}
return getThreadIDFromParams(route.params);
}
function currentRouteRecurse(route: PossiblyStaleRoute<>): StaleLeafRoute<> {
if (!route.state) {
return route;
}
const state = getStateFromNavigatorRoute(route);
return currentRouteRecurse(state.routes[state.index]);
}
function currentLeafRoute(
state: PossiblyStaleNavigationState,
): StaleLeafRoute<> {
return currentRouteRecurse(state.routes[state.index]);
}
function findRouteIndexWithKey(
state: PossiblyStaleNavigationState,
key: string,
): ?number {
for (let i = 0; i < state.routes.length; i++) {
const route = state.routes[i];
if (route.key === key) {
return i;
}
}
return null;
}
// This function walks from the back of the stack and calls filterFunc on each
// screen until the stack is exhausted or filterFunc returns "break". A screen
// will be removed if and only if filterFunc returns "remove" (not "break").
function removeScreensFromStack<
Route,
State: { +routes: $ReadOnlyArray, +index: number },
>(
state: State,
filterFunc: (route: Route) => 'keep' | 'remove' | 'break',
): State {
const newRoutes = [];
let newIndex = state.index;
let screenRemoved = false;
let breakActivated = false;
for (let i = state.routes.length - 1; i >= 0; i--) {
const route = state.routes[i];
if (breakActivated) {
newRoutes.unshift(route);
continue;
}
const result = filterFunc(route);
if (result === 'break') {
breakActivated = true;
}
if (breakActivated || result === 'keep') {
newRoutes.unshift(route);
continue;
}
screenRemoved = true;
if (newIndex >= i) {
invariant(
newIndex !== 0,
'Attempting to remove current route and all before it',
);
newIndex--;
}
}
if (!screenRemoved) {
return state;
}
return {
...state,
index: newIndex,
routes: newRoutes,
};
}
function validNavState(state: PossiblyStaleNavigationState) {
if (state.routes.length === 0) {
return false;
}
const [firstRoute] = state.routes;
if (firstRoute.name !== AppRouteName) {
return false;
}
return true;
}
export {
getStateFromNavigatorRoute,
getThreadIDFromParams,
getThreadIDFromRoute,
currentLeafRoute,
findRouteIndexWithKey,
removeScreensFromStack,
validNavState,
};
diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js
index 994cbdd02..62799aa08 100644
--- a/native/push/push-handler.react.js
+++ b/native/push/push-handler.react.js
@@ -1,657 +1,657 @@
// @flow
import PropTypes from 'prop-types';
import * as React from 'react';
import { AppRegistry, Platform, Alert, Vibration, LogBox } from 'react-native';
import type { RemoteMessage, NotificationOpen } from 'react-native-firebase';
import {
Notification as InAppNotification,
TapticFeedback,
} from 'react-native-in-app-message';
import NotificationsIOS from 'react-native-notifications';
import { useDispatch } from 'react-redux';
import {
setDeviceTokenActionTypes,
setDeviceToken,
} from 'lib/actions/device-actions';
import {
unreadCount,
threadInfoSelector,
} from 'lib/selectors/thread-selectors';
import { isLoggedIn } from 'lib/selectors/user-selectors';
import { mergePrefixIntoBody } from 'lib/shared/notif-utils';
import type { Dispatch } from 'lib/types/redux-types';
import {
type ConnectionInfo,
connectionInfoPropType,
} from 'lib/types/socket-types';
import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types';
import {
useServerCall,
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/action-utils';
import {
addLifecycleListener,
getCurrentLifecycleState,
} from '../lifecycle/lifecycle';
import { replaceWithThreadActionType } from '../navigation/action-types';
import { activeMessageListSelector } from '../navigation/nav-selectors';
import { NavContext } from '../navigation/navigation-context';
import type { RootNavigationProp } from '../navigation/root-navigator.react';
import { MessageListRouteName } from '../navigation/route-names';
import {
recordNotifPermissionAlertActionType,
clearAndroidNotificationsActionType,
} from '../redux/action-types';
import { useSelector } from '../redux/redux-utils';
import {
RootContext,
type RootContextType,
rootContextPropType,
} from '../root-context';
import { type GlobalTheme, globalThemePropType } from '../types/themes';
import {
type NotifPermissionAlertInfo,
notifPermissionAlertInfoPropType,
} from './alerts';
import {
androidNotificationChannelID,
handleAndroidMessage,
androidBackgroundMessageTask,
} from './android';
import { getFirebase } from './firebase';
import InAppNotif from './in-app-notif.react';
import {
requestIOSPushPermissions,
iosPushPermissionResponseReceived,
} from './ios';
import { saveMessageInfos } from './utils';
LogBox.ignoreLogs([
// react-native-firebase
'Require cycle: ../node_modules/react-native-firebase',
// react-native-in-app-message
'ForceTouchGestureHandler is not available',
]);
const msInDay = 24 * 60 * 60 * 1000;
const supportsTapticFeedback =
Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 10;
type BaseProps = {|
+navigation: RootNavigationProp<'App'>,
|};
type Props = {|
...BaseProps,
// Navigation state
+activeThread: ?string,
// Redux state
+unreadCount: number,
+deviceToken: ?string,
+threadInfos: { [id: string]: ThreadInfo },
+notifPermissionAlertInfo: NotifPermissionAlertInfo,
+connection: ConnectionInfo,
+updatesCurrentAsOf: number,
+activeTheme: ?GlobalTheme,
+loggedIn: boolean,
// Redux dispatch functions
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+setDeviceToken: (deviceToken: string) => Promise,
// withRootContext
+rootContext: ?RootContextType,
|};
type State = {|
+inAppNotifProps: ?{|
customComponent: React.Node,
blurType: ?('xlight' | 'dark'),
onPress: () => void,
|},
|};
class PushHandler extends React.PureComponent {
static propTypes = {
navigation: PropTypes.shape({
navigate: PropTypes.func.isRequired,
}).isRequired,
activeThread: PropTypes.string,
unreadCount: PropTypes.number.isRequired,
deviceToken: PropTypes.string,
threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired,
notifPermissionAlertInfo: notifPermissionAlertInfoPropType.isRequired,
connection: connectionInfoPropType.isRequired,
updatesCurrentAsOf: PropTypes.number.isRequired,
activeTheme: globalThemePropType,
loggedIn: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
setDeviceToken: PropTypes.func.isRequired,
rootContext: rootContextPropType,
};
state: State = {
inAppNotifProps: null,
};
currentState: ?string = getCurrentLifecycleState();
appStarted = 0;
androidTokenListener: ?() => void = null;
androidMessageListener: ?() => void = null;
androidNotifOpenListener: ?() => void = null;
initialAndroidNotifHandled = false;
openThreadOnceReceived: Set = new Set();
lifecycleSubscription: ?{ +remove: () => void };
componentDidMount() {
this.appStarted = Date.now();
this.lifecycleSubscription = addLifecycleListener(
this.handleAppStateChange,
);
this.onForeground();
if (Platform.OS === 'ios') {
NotificationsIOS.addEventListener(
'remoteNotificationsRegistered',
this.registerPushPermissions,
);
NotificationsIOS.addEventListener(
'remoteNotificationsRegistrationFailed',
this.failedToRegisterPushPermissions,
);
NotificationsIOS.addEventListener(
'notificationReceivedForeground',
this.iosForegroundNotificationReceived,
);
NotificationsIOS.addEventListener(
'notificationOpened',
this.iosNotificationOpened,
);
} else if (Platform.OS === 'android') {
const firebase = getFirebase();
const channel = new firebase.notifications.Android.Channel(
androidNotificationChannelID,
'Default',
firebase.notifications.Android.Importance.Max,
).setDescription('SquadCal notifications channel');
firebase.notifications().android.createChannel(channel);
this.androidTokenListener = firebase
.messaging()
.onTokenRefresh(this.handleAndroidDeviceToken);
this.androidMessageListener = firebase
.messaging()
.onMessage(this.androidMessageReceived);
this.androidNotifOpenListener = firebase
.notifications()
.onNotificationOpened(this.androidNotificationOpened);
}
}
componentWillUnmount() {
if (this.lifecycleSubscription) {
this.lifecycleSubscription.remove();
}
if (Platform.OS === 'ios') {
NotificationsIOS.removeEventListener(
'remoteNotificationsRegistered',
this.registerPushPermissions,
);
NotificationsIOS.removeEventListener(
'remoteNotificationsRegistrationFailed',
this.failedToRegisterPushPermissions,
);
NotificationsIOS.removeEventListener(
'notificationReceivedForeground',
this.iosForegroundNotificationReceived,
);
NotificationsIOS.removeEventListener(
'notificationOpened',
this.iosNotificationOpened,
);
} else if (Platform.OS === 'android') {
if (this.androidTokenListener) {
this.androidTokenListener();
this.androidTokenListener = null;
}
if (this.androidMessageListener) {
this.androidMessageListener();
this.androidMessageListener = null;
}
if (this.androidNotifOpenListener) {
this.androidNotifOpenListener();
this.androidNotifOpenListener = null;
}
}
}
handleAppStateChange = (nextState: ?string) => {
if (!nextState || nextState === 'unknown') {
return;
}
const lastState = this.currentState;
this.currentState = nextState;
if (lastState === 'background' && nextState === 'active') {
this.onForeground();
this.clearNotifsOfThread();
}
};
onForeground() {
if (this.props.loggedIn) {
this.ensurePushNotifsEnabled();
} else if (this.props.deviceToken) {
// We do this in case there was a crash, so we can clear deviceToken from
// any other cookies it might be set for
this.setDeviceToken(this.props.deviceToken);
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.props.activeThread !== prevProps.activeThread) {
this.clearNotifsOfThread();
}
if (
this.props.connection.status === 'connected' &&
(prevProps.connection.status !== 'connected' ||
this.props.unreadCount !== prevProps.unreadCount)
) {
this.updateBadgeCount();
}
for (let threadID of this.openThreadOnceReceived) {
const threadInfo = this.props.threadInfos[threadID];
if (threadInfo) {
this.navigateToThread(threadInfo, false);
this.openThreadOnceReceived.clear();
break;
}
}
if (
(this.props.loggedIn && !prevProps.loggedIn) ||
(!this.props.deviceToken && prevProps.deviceToken)
) {
this.ensurePushNotifsEnabled();
}
if (!this.props.loggedIn && prevProps.loggedIn) {
this.clearAllNotifs();
}
if (
this.state.inAppNotifProps &&
this.state.inAppNotifProps !== prevState.inAppNotifProps
) {
if (supportsTapticFeedback) {
TapticFeedback.impact();
} else {
Vibration.vibrate(400);
}
InAppNotification.show();
}
}
updateBadgeCount() {
const curUnreadCount = this.props.unreadCount;
if (Platform.OS === 'ios') {
NotificationsIOS.setBadgesCount(curUnreadCount);
} else if (Platform.OS === 'android') {
getFirebase().notifications().setBadge(curUnreadCount);
}
}
clearAllNotifs() {
if (Platform.OS === 'ios') {
NotificationsIOS.removeAllDeliveredNotifications();
} else if (Platform.OS === 'android') {
getFirebase().notifications().removeAllDeliveredNotifications();
}
}
clearNotifsOfThread() {
const { activeThread } = this.props;
if (!activeThread) {
return;
}
if (Platform.OS === 'ios') {
NotificationsIOS.getDeliveredNotifications((notifications) =>
PushHandler.clearDeliveredIOSNotificationsForThread(
activeThread,
notifications,
),
);
} else if (Platform.OS === 'android') {
this.props.dispatch({
type: clearAndroidNotificationsActionType,
payload: { threadID: activeThread },
});
}
}
static clearDeliveredIOSNotificationsForThread(
threadID: string,
notifications: Object[],
) {
const identifiersToClear = [];
for (let notification of notifications) {
if (notification['thread-id'] === threadID) {
identifiersToClear.push(notification.identifier);
}
}
if (identifiersToClear) {
NotificationsIOS.removeDeliveredNotifications(identifiersToClear);
}
}
async ensurePushNotifsEnabled() {
if (!this.props.loggedIn) {
return;
}
if (Platform.OS === 'ios') {
const missingDeviceToken =
this.props.deviceToken === null || this.props.deviceToken === undefined;
await requestIOSPushPermissions(missingDeviceToken);
} else if (Platform.OS === 'android') {
await this.ensureAndroidPushNotifsEnabled();
}
}
async ensureAndroidPushNotifsEnabled() {
const firebase = getFirebase();
const hasPermission = await firebase.messaging().hasPermission();
if (!hasPermission) {
try {
await firebase.messaging().requestPermission();
} catch {
this.failedToRegisterPushPermissions();
return;
}
}
const fcmToken = await firebase.messaging().getToken();
if (fcmToken) {
await this.handleAndroidDeviceToken(fcmToken);
} else {
this.failedToRegisterPushPermissions();
}
}
handleAndroidDeviceToken = async (deviceToken: string) => {
this.registerPushPermissions(deviceToken);
await this.handleInitialAndroidNotification();
};
async handleInitialAndroidNotification() {
if (this.initialAndroidNotifHandled) {
return;
}
this.initialAndroidNotifHandled = true;
const initialNotif = await getFirebase()
.notifications()
.getInitialNotification();
if (initialNotif) {
await this.androidNotificationOpened(initialNotif);
}
}
registerPushPermissions = (deviceToken: string) => {
const deviceType = Platform.OS;
if (deviceType !== 'android' && deviceType !== 'ios') {
return;
}
if (deviceType === 'ios') {
iosPushPermissionResponseReceived();
}
if (deviceToken !== this.props.deviceToken) {
this.setDeviceToken(deviceToken);
}
};
setDeviceToken(deviceToken: string) {
this.props.dispatchActionPromise(
setDeviceTokenActionTypes,
this.props.setDeviceToken(deviceToken),
undefined,
deviceToken,
);
}
failedToRegisterPushPermissions = () => {
if (!this.props.loggedIn) {
return;
}
const deviceType = Platform.OS;
if (deviceType === 'ios') {
iosPushPermissionResponseReceived();
if (__DEV__) {
// iOS simulator can't handle notifs
return;
}
}
const alertInfo = this.props.notifPermissionAlertInfo;
if (
(alertInfo.totalAlerts > 3 &&
alertInfo.lastAlertTime > Date.now() - msInDay) ||
(alertInfo.totalAlerts > 6 &&
alertInfo.lastAlertTime > Date.now() - msInDay * 3) ||
(alertInfo.totalAlerts > 9 &&
alertInfo.lastAlertTime > Date.now() - msInDay * 7)
) {
return;
}
this.props.dispatch({
type: recordNotifPermissionAlertActionType,
payload: { time: Date.now() },
});
if (deviceType === 'ios') {
Alert.alert(
'Need notif permissions',
'SquadCal needs notification permissions to keep you in the loop! ' +
'Please enable in Settings App -> Notifications -> SquadCal.',
[{ text: 'OK' }],
);
} else if (deviceType === 'android') {
Alert.alert(
'Unable to initialize notifs!',
'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.',
undefined,
{ cancelable: true },
);
}
};
navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) {
if (clearChatRoutes) {
this.props.navigation.dispatch({
type: replaceWithThreadActionType,
payload: { threadInfo },
});
} else {
this.props.navigation.navigate({
name: MessageListRouteName,
key: `${MessageListRouteName}${threadInfo.id}`,
- params: { thread: { threadInfo } },
+ params: { threadInfo },
});
}
}
onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) {
const threadInfo = this.props.threadInfos[threadID];
if (threadInfo) {
this.navigateToThread(threadInfo, clearChatRoutes);
} else {
this.openThreadOnceReceived.add(threadID);
}
}
saveMessageInfos(messageInfosString: string) {
saveMessageInfos(messageInfosString, this.props.updatesCurrentAsOf);
}
iosForegroundNotificationReceived = (notification) => {
if (
notification.getData() &&
notification.getData().managedAps &&
notification.getData().managedAps.action === 'CLEAR'
) {
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
if (Date.now() < this.appStarted + 1500) {
// On iOS, when the app is opened from a notif press, for some reason this
// callback gets triggered before iosNotificationOpened. In fact this
// callback shouldn't be triggered at all. To avoid weirdness we are
// ignoring any foreground notification received within the first second
// of the app being started, since they are most likely to be erroneous.
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const threadID = notification.getData().threadID;
if (!threadID) {
console.log('Notification with missing threadID received!');
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const messageInfos = notification.getData().messageInfos;
if (messageInfos) {
this.saveMessageInfos(messageInfos);
}
let title = null;
let body = notification.getMessage();
if (notification.getData().title) {
({ title, body } = mergePrefixIntoBody(notification.getData()));
}
this.showInAppNotification(threadID, body, title);
notification.finish(NotificationsIOS.FetchResult.NewData);
};
onPushNotifBootsApp() {
if (
this.props.rootContext &&
this.props.rootContext.detectUnsupervisedBackground
) {
this.props.rootContext.detectUnsupervisedBackground(false);
}
}
iosNotificationOpened = (notification) => {
this.onPushNotifBootsApp();
const threadID = notification.getData().threadID;
if (!threadID) {
console.log('Notification with missing threadID received!');
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const messageInfos = notification.getData().messageInfos;
if (messageInfos) {
this.saveMessageInfos(messageInfos);
}
this.onPressNotificationForThread(threadID, true);
notification.finish(NotificationsIOS.FetchResult.NewData);
};
showInAppNotification(threadID: string, message: string, title?: ?string) {
if (threadID === this.props.activeThread) {
return;
}
this.setState({
inAppNotifProps: {
customComponent: (
),
blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark',
onPress: () => {
InAppNotification.hide();
this.onPressNotificationForThread(threadID, false);
},
},
});
}
androidNotificationOpened = async (notificationOpen: NotificationOpen) => {
this.onPushNotifBootsApp();
const { threadID } = notificationOpen.notification.data;
this.onPressNotificationForThread(threadID, true);
};
androidMessageReceived = async (message: RemoteMessage) => {
this.onPushNotifBootsApp();
handleAndroidMessage(
message,
this.props.updatesCurrentAsOf,
this.handleAndroidNotificationIfActive,
);
};
handleAndroidNotificationIfActive = (
threadID: string,
texts: {| body: string, title: ?string |},
) => {
if (this.currentState !== 'active') {
return false;
}
this.showInAppNotification(threadID, texts.body, texts.title);
return true;
};
render() {
return (
);
}
}
AppRegistry.registerHeadlessTask(
'RNFirebaseBackgroundMessage',
() => androidBackgroundMessageTask,
);
export default React.memo(function ConnectedPushHandler(
props: BaseProps,
) {
const navContext = React.useContext(NavContext);
const activeThread = activeMessageListSelector(navContext);
const boundUnreadCount = useSelector(unreadCount);
const deviceToken = useSelector((state) => state.deviceToken);
const threadInfos = useSelector(threadInfoSelector);
const notifPermissionAlertInfo = useSelector(
(state) => state.notifPermissionAlertInfo,
);
const connection = useSelector((state) => state.connection);
const updatesCurrentAsOf = useSelector((state) => state.updatesCurrentAsOf);
const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme);
const loggedIn = useSelector(isLoggedIn);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const boundSetDeviceToken = useServerCall(setDeviceToken);
const rootContext = React.useContext(RootContext);
return (
);
});