forked from zulip/zulip-mobile
-
Notifications
You must be signed in to change notification settings - Fork 0
/
MessageList.js
385 lines (353 loc) · 14.7 KB
/
MessageList.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/* @flow strict-local */
import * as React from 'react';
import { useContext, useState } 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 type {
Dispatch,
Fetching,
GetText,
Message,
Narrow,
Outbox,
MessageListElement,
UserOrBot,
EditMessage,
} from '../types';
import { ThemeContext } from '../styles';
import { useSelector, useDispatch, useGlobalSelector } from '../react-redux';
import { useNavigation } from '../react-navigation';
import {
getCurrentTypingUsers,
getDebug,
getFetchingForNarrow,
getGlobalSettings,
} from '../selectors';
import { TranslationContext } from '../boot/TranslationProvider';
import type { ShowActionSheetWithOptions } from '../action-sheets';
import { getMessageListElementsMemoized } from '../message/messageSelectors';
import type { WebViewInboundEvent } from './generateInboundEvents';
import type { WebViewOutboundEvent } from './handleOutboundEvents';
import getHtml from './html/html';
import messageListElementHtml from './html/messageListElementHtml';
import generateInboundEvents from './generateInboundEvents';
import { handleWebViewOutboundEvent } from './handleOutboundEvents';
import { base64Utf8Encode } from '../utils/encoding';
import { caseNarrow, isConversationNarrow } from '../utils/narrow';
import { type BackgroundData, getBackgroundData } from './backgroundData';
import { ensureUnreachable } from '../generics';
import SinglePageWebView from './SinglePageWebView';
import { usePrevious } from '../reactUtils';
import { type ImperativeHandle as ComposeBoxImperativeHandle } from '../compose/ComposeBox';
/**
* The actual React props for the MessageList component.
*/
type OuterProps = $ReadOnly<{|
narrow: Narrow,
messages: $ReadOnlyArray<Message | Outbox>,
initialScrollMessageId: number | null,
showMessagePlaceholders: boolean,
startEditMessage: (editMessage: EditMessage) => void,
// Careful: We expect this prop to be mutable, which is unusual.
composeBoxRef: {| current: ComposeBoxImperativeHandle | null |},
|}>;
/**
* 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,
// These props contain data specific to the particular narrow or
// particular messages we're displaying. Data that's independent of those
// should go in `BackgroundData`, above.
fetching: Fetching,
messageListElementsForShownMessages: $ReadOnlyArray<MessageListElement>,
typingUsers: $ReadOnlyArray<UserOrBot>,
// Local state, and setters.
doNotMarkMessagesAsRead: boolean,
setDoNotMarkMessagesAsRead: boolean => void,
|}>;
/**
* 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,
});
function useMessageListProps(props: OuterProps): Props {
const _ = useContext(TranslationContext);
const showActionSheetWithOptions: ShowActionSheetWithOptions =
useActionSheet().showActionSheetWithOptions;
const dispatch = useDispatch();
const globalSettings = useGlobalSelector(getGlobalSettings);
const debug = useGlobalSelector(getDebug);
const [stateDoNotMarkMessagesAsRead, setDoNotMarkMessagesAsRead] = useState(null);
const doNotMarkMessagesAsRead =
stateDoNotMarkMessagesAsRead
?? (!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;
}
})());
return {
...props,
showActionSheetWithOptions,
_,
dispatch,
backgroundData: useSelector(state => getBackgroundData(state, globalSettings, debug)),
fetching: useSelector(state => getFetchingForNarrow(state, props.narrow)),
messageListElementsForShownMessages: getMessageListElementsMemoized(
props.messages,
props.narrow,
),
typingUsers: useSelector(state => getCurrentTypingUsers(state, props.narrow)),
doNotMarkMessagesAsRead,
setDoNotMarkMessagesAsRead,
};
}
/**
* The URL of the platform-specific assets folder.
*
* This will be a `file:` URL.
*/
// It could be perfectly reasonable for this to be an `http:` or `https:`
// URL instead, at least in development. We'd then just need to adjust
// the `originWhitelist` we pass to `WebView`.
//
// - On iOS: We can't easily hardcode this because it includes UUIDs.
// So we bring it over the React Native bridge in ZLPConstants.m.
// It's a file URL because the app bundle's `resourceURL` is:
// https://developer.apple.com/documentation/foundation/bundle/1414821-resourceurl
//
// - On Android: Different apps' WebViews see different (virtual) root
// directories as `file:///`, and in particular the WebView provides
// the APK's `assets/` directory as `file:///android_asset/`. [1]
// We can easily hardcode that, so we do.
//
// [1] Oddly, this essential feature doesn't seem to be documented! It's
// widely described in how-tos across the web and StackOverflow answers.
// It's assumed in some related docs which mention it in passing, and
// treated matter-of-factly in some Chromium bug threads. Details at:
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/android.20filesystem/near/796440
const assetsUrl =
Platform.OS === 'ios'
? new URL(NativeModules.ZLPConstants.resourceURL)
: new URL('file:///android_asset/');
/**
* The URL of the webview-assets folder.
*
* This is the folder populated at build time by `tools/build-webview`.
*/
const webviewAssetsUrl = new URL('webview/', assetsUrl);
/**
* Effective URL of the MessageList webview.
*
* It points to `index.html` in the webview-assets folder, which
* doesn't exist.
*
* It doesn't need to exist because we provide all HTML at
* creation (or refresh) time. This serves only as a placeholder,
* so that relative URLs (e.g., to `base.css`, which does exist)
* and cross-domain security restrictions have somewhere to
* believe that this document originates from.
*/
const baseUrl = new URL('index.html', webviewAssetsUrl);
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
// the interesting content is in one string, an HTML document, which will
// get passed to a WebView.
//
// That WebView is a leaf of the tree that React sees. But there's a lot
// of structure inside that HTML string, and there's UI state (in
// particular, the scroll position) in the resulting page in the browser.
// So when the content that would go in that HTML changes, we don't want
// to just replace the entire HTML document. We want to use the structure
// to make localized updates to the page in the browser, much as React
// does automatically for changes in its tree.
//
// This is important not only for performance (computing all the HTML for
// a long list of messages is expensive), but for correct behavior: if we
// did change the HTML string passed to the WebView, the user would find
// themself suddenly scrolled back to the bottom.
//
// So:
// * We compute the HTML document just once and then always re-use that
// initial value.
// * When the props change, we compute a set of events describing the
// changes, and send them to our code inside the webview to execute.
//
// See also docs/architecture/react.md .
const props = useMessageListProps(outerProps);
const navigation = useNavigation();
const theme = React.useContext(ThemeContext);
const webviewRef = React.useRef<React$ElementRef<typeof WebView> | null>(null);
const sendInboundEventsIsReady = React.useRef<boolean>(false);
const unsentInboundEvents = React.useRef<WebViewInboundEvent[]>([]);
/**
* Send the given inbound-events to the inside-webview code.
*
* See `handleMessageEvent` in the inside-webview code for where these are
* received and processed.
*/
const sendInboundEvents = React.useCallback(
(uevents: $ReadOnlyArray<WebViewInboundEvent>): void => {
if (webviewRef.current !== null && uevents.length > 0) {
/* $FlowFixMe[incompatible-type]: This `postMessage` is undocumented;
tracking as #3572. */
const secretWebView: { postMessage: (string, string) => void, ... } = webviewRef.current;
secretWebView.postMessage(base64Utf8Encode(JSON.stringify(uevents)), '*');
}
},
[],
);
const propsRef = React.useRef(props);
React.useEffect(() => {
const lastProps = propsRef.current;
if (props === lastProps) {
// Nothing to update. (This happens in particular on first render.)
return;
}
propsRef.current = props;
// Account for the new props by sending any needed inbound-events to the
// inside-webview code.
const uevents = generateInboundEvents(lastProps, props);
if (sendInboundEventsIsReady.current) {
sendInboundEvents(uevents);
} else {
unsentInboundEvents.current.push(...uevents);
}
}, [props, sendInboundEvents]);
const handleMessage = React.useCallback(
(event: { +nativeEvent: { +data: string, ... }, ... }) => {
const eventData: WebViewOutboundEvent = JSON.parse(event.nativeEvent.data);
if (eventData.type === 'ready') {
sendInboundEventsIsReady.current = true;
sendInboundEvents([{ type: 'ready' }, ...unsentInboundEvents.current]);
unsentInboundEvents.current = [];
} else {
// Instead of closing over `props` itself, we indirect through
// `propsRef`, which gets updated by the effect above.
//
// That's because unlike in a typical component, the UI this acts as
// a UI callback for isn't based on the current props, but on the
// data we've communicated through inbound-events. (See discussion
// at top of component.) So that's the data we want to refer to
// when interpreting the user's interaction; and `propsRef` is what
// the effect above updates in sync with sending those events.
//
// (The distinction may not matter much here in practice. But a
// nice bonus of this way is that we avoid re-renders of
// SinglePageWebView, potentially a helpful optimization.)
handleWebViewOutboundEvent(propsRef.current, navigation, eventData);
}
},
[sendInboundEvents, navigation],
);
// We compute the page contents as an HTML string just once (*), on this
// MessageList's first render. See discussion at top of function.
//
// Note this means that all changes to props must be handled by
// inbound-events, or they simply won't be handled at all.
//
// (*) The logic below doesn't quite look that way -- what it says is that
// we compute the HTML on first render, and again any time the theme
// changes. Until we implement #5533, this comes to the same thing,
// because the only way to change the theme is for the user to
// navigate out to our settings UI. We write it this way so that it
// won't break with #5533.
//
// On the other hand, this means that if the theme changes we'll get
// the glitch described at top of function, scrolling the user to the
// bottom. Better than mismatched themes, but not ideal. A nice bonus
// followup on #5533 would be to add an inbound-event for changing the
// theme, and then truly compute the HTML just once.
const htmlRef = React.useRef(null);
const prevTheme = usePrevious(theme);
if (htmlRef.current == null || theme !== prevTheme) {
const {
backgroundData,
messageListElementsForShownMessages,
initialScrollMessageId,
showMessagePlaceholders,
doNotMarkMessagesAsRead,
_,
} = props;
const contentHtml = messageListElementsForShownMessages
.map(element => messageListElementHtml({ backgroundData, element, _ }))
.join('');
const { auth } = backgroundData;
htmlRef.current = getHtml(
contentHtml,
theme.themeName,
{
scrollMessageId: initialScrollMessageId,
auth,
showMessagePlaceholders,
doNotMarkMessagesAsRead,
},
backgroundData.serverEmojiData,
);
}
return (
<SinglePageWebView
html={htmlRef.current}
baseUrl={baseUrl}
decelerationRate="normal"
style={React.useMemo(() => ({ backgroundColor: 'transparent' }), [])}
ref={webviewRef}
onMessage={handleMessage}
onError={React.useCallback(event => {
console.error(event); // eslint-disable-line no-console
}, [])}
/>
);
}