Skip to content
188 changes: 102 additions & 86 deletions src/webview/MessageList.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/* @flow strict-local */
import * as React from 'react';
import { useContext } from 'react';
import { Platform, NativeModules } from 'react-native';
import { WebView } from 'react-native-webview';
// $FlowFixMe[untyped-import]
import { useActionSheet } from '@expo/react-native-action-sheet';

import { connectActionSheet } from '../react-native-action-sheet';
import type {
Dispatch,
Fetching,
Expand All @@ -15,16 +17,15 @@ import type {
UserOrBot,
EditMessage,
} from '../types';
import { assumeSecretlyGlobalState } from '../reduxTypes';
import { ThemeContext } from '../styles';
import { connect } from '../react-redux';
import { useSelector, useDispatch, useGlobalSelector } from '../react-redux';
import {
getCurrentTypingUsers,
getDebug,
getFetchingForNarrow,
getGlobalSettings,
} from '../selectors';
import { withGetText } from '../boot/TranslationProvider';
import { TranslationContext } from '../boot/TranslationProvider';
import type { ShowActionSheetWithOptions } from '../action-sheets';
import { getMessageListElementsMemoized } from '../message/messageSelectors';
import type { WebViewInboundEvent } from './generateInboundEvents';
Expand All @@ -40,6 +41,9 @@ import { ensureUnreachable } from '../generics';
import SinglePageWebView from './SinglePageWebView';
import { usePrevious } from '../reactUtils';

/**
* The actual React props for the MessageList component.
*/
type OuterProps = $ReadOnly<{|
narrow: Narrow,
messages: $ReadOnlyArray<Message | Outbox>,
Expand All @@ -48,7 +52,27 @@ type OuterProps = $ReadOnly<{|
startEditMessage: (editMessage: EditMessage) => void,
|}>;

type SelectorProps = {|
/**
* All the data for rendering the message list, and callbacks for its UI actions.
*
* This data gets used for rendering the initial HTML and for computing
* inbound-events to update the page. Then the handlers for the message
* list's numerous UI actions -- both for user interactions inside the page
* as represented by outbound-events, and in action sheets -- use the data
* and callbacks in order to do their jobs.
*
* This can be thought of -- hence the name -- as the React props for a
* notional inner component, like we'd have if we obtained this data through
* HOCs like `connect` and `withGetText`. (Instead, we use Hooks, and don't
* have a separate inner component.)
*/
export type Props = $ReadOnly<{|
...OuterProps,

showActionSheetWithOptions: ShowActionSheetWithOptions,
_: GetText,
dispatch: Dispatch,

// Data independent of the particular narrow or messages we're displaying.
backgroundData: BackgroundData,

Expand All @@ -59,20 +83,80 @@ type SelectorProps = {|
messageListElementsForShownMessages: $ReadOnlyArray<MessageListElement>,
typingUsers: $ReadOnlyArray<UserOrBot>,
doNotMarkMessagesAsRead: boolean,
|};
|}>;

export type Props = $ReadOnly<{|
...OuterProps,
/**
* Whether reading messages in this narrow can mark them as read.
*
* "Can", not "will": other conditions can mean we don't want to mark
* messages as read even when in a narrow where this is true.
*/
const marksMessagesAsRead = (narrow: Narrow): boolean =>
// Generally we want these to agree with the web/desktop app, so that user
// expectations transfer between the different apps.
caseNarrow(narrow, {
// These narrows show one conversation in full. Each message appears
// in its full context, so it makes sense to say the user's read it
// and doesn't need to be shown it as unread again.
topic: () => true,
pm: () => true,

dispatch: Dispatch,
...SelectorProps,
// These narrows show several conversations interleaved. They always
// show entire conversations, so each message still appears in its
// full context and it still makes sense to mark it as read.
stream: () => true,
home: () => true,
allPrivate: () => true,

// From `connectActionSheet`.
showActionSheetWithOptions: ShowActionSheetWithOptions,
// These narrows show selected messages out of context. The user will
// typically still want to see them another way, in context, before
// letting them disappear from their list of unread messages.
search: () => false,
starred: () => false,
mentioned: () => false,
});

// From `withGetText`.
_: GetText,
|}>;
function useMessageListProps(props: OuterProps): Props {
const _ = useContext(TranslationContext);
const showActionSheetWithOptions: ShowActionSheetWithOptions =
useActionSheet().showActionSheetWithOptions;
const dispatch = useDispatch();

const globalSettings = useGlobalSelector(getGlobalSettings);
const debug = useGlobalSelector(getDebug);

return useSelector(state => ({
...props,

showActionSheetWithOptions,
_,
dispatch,

backgroundData: getBackgroundData(state, globalSettings, debug),

fetching: getFetchingForNarrow(state, props.narrow),
messageListElementsForShownMessages: getMessageListElementsMemoized(
props.messages,
props.narrow,
),
typingUsers: getCurrentTypingUsers(state, props.narrow),
doNotMarkMessagesAsRead:
!marksMessagesAsRead(props.narrow)
|| (() => {
switch (globalSettings.markMessagesReadOnScroll) {
case 'always':
return false;
case 'never':
return true;
case 'conversation-views-only':
return !isConversationNarrow(props.narrow);
default:
ensureUnreachable(globalSettings.markMessagesReadOnScroll);
return false;
}
})(),
}));
}

/**
* The URL of the platform-specific assets folder.
Expand Down Expand Up @@ -124,7 +208,7 @@ const webviewAssetsUrl = new URL('webview/', assetsUrl);
*/
const baseUrl = new URL('index.html', webviewAssetsUrl);

function MessageListInner(props: Props) {
export default function MessageList(outerProps: OuterProps): React.Node {
// NOTE: This component has an unusual lifecycle for a React component!
//
// In the React element which this render function returns, the bulk of
Expand Down Expand Up @@ -152,6 +236,8 @@ function MessageListInner(props: Props) {
//
// See also docs/architecture/react.md .

const props = useMessageListProps(outerProps);

const theme = React.useContext(ThemeContext);

const webviewRef = React.useRef<React$ElementRef<typeof WebView> | null>(null);
Expand Down Expand Up @@ -282,73 +368,3 @@ function MessageListInner(props: Props) {
/>
);
}

/**
* Whether reading messages in this narrow can mark them as read.
*
* "Can", not "will": other conditions can mean we don't want to mark
* messages as read even when in a narrow where this is true.
*/
const marksMessagesAsRead = (narrow: Narrow): boolean =>
// Generally we want these to agree with the web/desktop app, so that user
// expectations transfer between the different apps.
caseNarrow(narrow, {
// These narrows show one conversation in full. Each message appears
// in its full context, so it makes sense to say the user's read it
// and doesn't need to be shown it as unread again.
topic: () => true,
pm: () => true,

// These narrows show several conversations interleaved. They always
// show entire conversations, so each message still appears in its
// full context and it still makes sense to mark it as read.
stream: () => true,
home: () => true,
allPrivate: () => true,

// These narrows show selected messages out of context. The user will
// typically still want to see them another way, in context, before
// letting them disappear from their list of unread messages.
search: () => false,
starred: () => false,
mentioned: () => false,
});

// TODO next steps: merge these wrappers into function, one at a time.
const MessageList: React.ComponentType<OuterProps> = connect<SelectorProps, _, _>(
(state, props: OuterProps) => {
// If this were a function component with Hooks, these would be
// useGlobalSelector calls and would coexist perfectly smoothly with
// useSelector calls for the per-account data. As long as it's not,
// they should probably turn into a `connectGlobal` call.
const globalSettings = getGlobalSettings(assumeSecretlyGlobalState(state));
const debug = getDebug(assumeSecretlyGlobalState(state));

return {
backgroundData: getBackgroundData(state, globalSettings, debug),
fetching: getFetchingForNarrow(state, props.narrow),
messageListElementsForShownMessages: getMessageListElementsMemoized(
props.messages,
props.narrow,
),
typingUsers: getCurrentTypingUsers(state, props.narrow),
doNotMarkMessagesAsRead:
!marksMessagesAsRead(props.narrow)
|| (() => {
switch (globalSettings.markMessagesReadOnScroll) {
case 'always':
return false;
case 'never':
return true;
case 'conversation-views-only':
return !isConversationNarrow(props.narrow);
default:
ensureUnreachable(globalSettings.markMessagesReadOnScroll);
return false;
}
})(),
};
},
)(connectActionSheet(withGetText(MessageListInner)));

export default MessageList;