|
| 1 | +/* @flow strict-local */ |
| 2 | + |
| 3 | +import React, { useCallback, useContext, useMemo } from 'react'; |
| 4 | +import type { Node } from 'react'; |
| 5 | +import { View, FlatList } from 'react-native'; |
| 6 | +import { SafeAreaView } from 'react-native-safe-area-context'; |
| 7 | + |
| 8 | +import useFetchedDataWithRefresh from '../common/useFetchedDataWithRefresh'; |
| 9 | +import ZulipTextIntl from '../common/ZulipTextIntl'; |
| 10 | +import { useGlobalSelector, useSelector } from '../react-redux'; |
| 11 | +import * as api from '../api'; |
| 12 | +import type { AppNavigationProp } from '../nav/AppNavigator'; |
| 13 | +import type { RouteProp } from '../react-navigation'; |
| 14 | +import Screen from '../common/Screen'; |
| 15 | +import { useConditionalEffect, useHasNotChangedForMs, useHasStayedTrueForMs } from '../reactUtils'; |
| 16 | +import { getAuth, getZulipFeatureLevel } from '../account/accountsSelectors'; |
| 17 | +import { showToast } from '../utils/info'; |
| 18 | +import { TranslationContext } from '../boot/TranslationProvider'; |
| 19 | +import UserItem from '../users/UserItem'; |
| 20 | +import { tryGetUserForId } from '../users/userSelectors'; |
| 21 | +import type { UserId } from '../api/idTypes'; |
| 22 | +import { getGlobalSettings } from '../directSelectors'; |
| 23 | +import type { UserOrBot } from '../api/modelTypes'; |
| 24 | +import LoadingIndicator from '../common/LoadingIndicator'; |
| 25 | +import WebLink from '../common/WebLink'; |
| 26 | +import { createStyleSheet } from '../styles'; |
| 27 | +import ZulipText from '../common/ZulipText'; |
| 28 | + |
| 29 | +type Props = $ReadOnly<{| |
| 30 | + navigation: AppNavigationProp<'read-receipts'>, |
| 31 | + route: RouteProp<'read-receipts', {| +messageId: number |}>, |
| 32 | +|}>; |
| 33 | + |
| 34 | +export default function ReadReceiptsScreen(props: Props): Node { |
| 35 | + const { navigation } = props; |
| 36 | + const { messageId } = props.route.params; |
| 37 | + |
| 38 | + const auth = useSelector(getAuth); |
| 39 | + const zulipFeatureLevel = useSelector(getZulipFeatureLevel); |
| 40 | + const language = useGlobalSelector(state => getGlobalSettings(state).language); |
| 41 | + const _ = useContext(TranslationContext); |
| 42 | + |
| 43 | + const callApiMethod = useCallback( |
| 44 | + () => api.getReadReceipts(auth, { message_id: messageId }, zulipFeatureLevel), |
| 45 | + [auth, messageId, zulipFeatureLevel], |
| 46 | + ); |
| 47 | + |
| 48 | + const { latestResult, latestSuccessResult } = useFetchedDataWithRefresh(callApiMethod, 15_000); |
| 49 | + |
| 50 | + // TODO: These vanishing toasts are easy to miss. Instead, show |
| 51 | + // latestResultIsError, isFirstLoadLate, and haveStaleData with |
| 52 | + // something more persistent. A Material Design Snackbar that stays |
| 53 | + // until the user dismisses it or the problem resolves? |
| 54 | + // https://callstack.github.io/react-native-paper/snackbar.html |
| 55 | + |
| 56 | + const latestResultIsError = latestResult?.type === 'error'; |
| 57 | + useConditionalEffect( |
| 58 | + useCallback(() => { |
| 59 | + showToast(_('Could not load data.')); |
| 60 | + }, [_]), |
| 61 | + latestResultIsError, |
| 62 | + ); |
| 63 | + |
| 64 | + const isFirstLoadLate = useHasStayedTrueForMs(latestSuccessResult === null, 10_000); |
| 65 | + useConditionalEffect( |
| 66 | + useCallback(() => showToast(_('Still working…')), [_]), |
| 67 | + isFirstLoadLate |
| 68 | + // If the latest result was an error, we would've shown a "Could not |
| 69 | + // load data" message, which sounds final. Reduce confusion by |
| 70 | + // suppressing a "Still working…" message, even as the Hook continues |
| 71 | + // to try to load the data. Confusion, and also false hope: if the |
| 72 | + // last fetch failed, we're not optimistic about this one. |
| 73 | + && !latestResultIsError, |
| 74 | + ); |
| 75 | + |
| 76 | + const haveStaleData = |
| 77 | + useHasNotChangedForMs(latestSuccessResult, 40_000) && latestSuccessResult !== null; |
| 78 | + useConditionalEffect( |
| 79 | + useCallback(() => showToast(_('Updates may be delayed.')), [_]), |
| 80 | + haveStaleData, |
| 81 | + ); |
| 82 | + |
| 83 | + const onPressUser = useCallback( |
| 84 | + (user: UserOrBot) => { |
| 85 | + navigation.push('account-details', { userId: user.user_id }); |
| 86 | + }, |
| 87 | + [navigation], |
| 88 | + ); |
| 89 | + |
| 90 | + // The web app tries Intl.Collator too, with a fallback for browsers that |
| 91 | + // don't support it. See `strcmp` in static/js/util.js in the web app. Our |
| 92 | + // platforms should support it: |
| 93 | + // - MDN shows that our simple usage here is supported since iOS 10: |
| 94 | + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator |
| 95 | + // And we desupported iOS 10 a long time ago. |
| 96 | + // - On Android, I don't get an error that suggests an API is missing. |
| 97 | + // And it looks like Hermes, which we hope to switch to soon, supports it: |
| 98 | + // https://github.com/facebook/hermes/issues/23#issuecomment-1156832485 |
| 99 | + const userSorter = useCallback( |
| 100 | + (a, b) => Intl.Collator(language).compare(a.full_name, b.full_name), |
| 101 | + [language], |
| 102 | + ); |
| 103 | + |
| 104 | + const displayUserIds = useSelector(state => { |
| 105 | + const userIds: $ReadOnlyArray<UserId> = latestSuccessResult?.data ?? []; |
| 106 | + const result = []; |
| 107 | + |
| 108 | + userIds.forEach(userId => { |
| 109 | + const user = tryGetUserForId(state, userId); |
| 110 | + if (!user) { |
| 111 | + // E.g., data out of sync because we're working outside the event |
| 112 | + // system. Shrug, drop this one. |
| 113 | + return; |
| 114 | + } |
| 115 | + result.push(user); |
| 116 | + }); |
| 117 | + result.sort(userSorter); |
| 118 | + |
| 119 | + return result.map(user => user.user_id); |
| 120 | + }); |
| 121 | + |
| 122 | + const renderItem = useCallback( |
| 123 | + ({ item }) => <UserItem key={item} userId={item} onPress={onPressUser} />, |
| 124 | + [onPressUser], |
| 125 | + ); |
| 126 | + |
| 127 | + const localizableSummaryText = useMemo( |
| 128 | + () => |
| 129 | + displayUserIds.length > 0 |
| 130 | + ? { |
| 131 | + // This is actually the same string as in the web app; see where |
| 132 | + // that's set in static/js/read_receipts.js |
| 133 | + text: `\ |
| 134 | +{num_of_people, plural, |
| 135 | + one {This message has been <z-link>read</z-link> by {num_of_people} person:} |
| 136 | + other {This message has been <z-link>read</z-link> by {num_of_people} people:}\ |
| 137 | +}`, |
| 138 | + values: { |
| 139 | + num_of_people: displayUserIds.length, |
| 140 | + 'z-link': chunks => ( |
| 141 | + <WebLink url={new URL('/help/read-receipts', auth.realm)}> |
| 142 | + {chunks.map(chunk => ( |
| 143 | + <ZulipText>{chunk}</ZulipText> |
| 144 | + ))} |
| 145 | + </WebLink> |
| 146 | + ), |
| 147 | + }, |
| 148 | + } |
| 149 | + : 'No one has read this message yet.', |
| 150 | + [auth.realm, displayUserIds.length], |
| 151 | + ); |
| 152 | + |
| 153 | + const styles = useMemo( |
| 154 | + () => |
| 155 | + createStyleSheet({ |
| 156 | + summaryTextWrapper: { padding: 16 }, |
| 157 | + flex1: { flex: 1 }, |
| 158 | + }), |
| 159 | + [], |
| 160 | + ); |
| 161 | + |
| 162 | + return ( |
| 163 | + <Screen title="Read receipts" scrollEnabled={false}> |
| 164 | + {latestSuccessResult ? ( |
| 165 | + <View style={styles.flex1}> |
| 166 | + <SafeAreaView mode="padding" edges={['right', 'left']} style={styles.summaryTextWrapper}> |
| 167 | + <ZulipTextIntl text={localizableSummaryText} /> |
| 168 | + </SafeAreaView> |
| 169 | + <FlatList style={styles.flex1} data={displayUserIds} renderItem={renderItem} /> |
| 170 | + </View> |
| 171 | + ) : ( |
| 172 | + <LoadingIndicator size={48} /> |
| 173 | + )} |
| 174 | + </Screen> |
| 175 | + ); |
| 176 | +} |
0 commit comments