diff --git a/native/chat/message-store-pruner.react.js b/lib/components/message-store-pruner.react.js similarity index 61% rename from native/chat/message-store-pruner.react.js rename to lib/components/message-store-pruner.react.js index befb05783..f7bcf4804 100644 --- a/native/chat/message-store-pruner.react.js +++ b/lib/components/message-store-pruner.react.js @@ -1,69 +1,72 @@ // @flow import * as React from 'react'; -import { messageStorePruneActionType } from 'lib/actions/message-actions.js'; -import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; -import { useDispatch } from 'lib/utils/redux-utils.js'; - -import { NavContext } from '../navigation/navigation-context.js'; -import { useSelector } from '../redux/redux-utils.js'; +import { messageStorePruneActionType } from '../actions/message-actions.js'; import { nextMessagePruneTimeSelector, pruneThreadIDsSelector, -} from '../selectors/message-selectors.js'; +} from '../selectors/message-selector.js'; +import { useIsAppForegrounded } from '../shared/lifecycle-utils.js'; +import { useDispatch, useSelector } from '../utils/redux-utils.js'; + +type Props = { + +frozen?: boolean, + +activeThreadID: ?string, +}; +function MessageStorePruner(props: Props): null { + const { frozen, activeThreadID } = props; -function MessageStorePruner(): null { const nextMessagePruneTime = useSelector(nextMessagePruneTimeSelector); const prevNextMessagePruneTimeRef = React.useRef(nextMessagePruneTime); const foreground = useIsAppForegrounded(); - const frozen = useSelector(state => state.frozen); - const navContext = React.useContext(NavContext); - const pruneThreadIDs = useSelector(state => - pruneThreadIDsSelector({ - redux: state, - navContext, - }), - ); + const pruneThreadIDs = useSelector(pruneThreadIDsSelector); const prunedRef = React.useRef(false); const dispatch = useDispatch(); React.useEffect(() => { if ( prunedRef.current && nextMessagePruneTime !== prevNextMessagePruneTimeRef.current ) { prunedRef.current = false; } prevNextMessagePruneTimeRef.current = nextMessagePruneTime; if (frozen || prunedRef.current) { return; } if (nextMessagePruneTime === null || nextMessagePruneTime === undefined) { return; } const timeUntilExpiration = nextMessagePruneTime - Date.now(); if (timeUntilExpiration > 0) { return; } - const threadIDs = pruneThreadIDs(); + const threadIDs = pruneThreadIDs(activeThreadID); if (threadIDs.length === 0) { return; } prunedRef.current = true; dispatch({ type: messageStorePruneActionType, payload: { threadIDs }, }); // We include foreground so this effect will be called on foreground - }, [nextMessagePruneTime, frozen, foreground, pruneThreadIDs, dispatch]); + }, [ + nextMessagePruneTime, + frozen, + foreground, + pruneThreadIDs, + dispatch, + activeThreadID, + ]); return null; } export default MessageStorePruner; diff --git a/native/selectors/message-selectors.js b/lib/selectors/message-selector.js similarity index 63% rename from native/selectors/message-selectors.js rename to lib/selectors/message-selector.js index 9e98710e8..d71453b92 100644 --- a/native/selectors/message-selectors.js +++ b/lib/selectors/message-selector.js @@ -1,70 +1,76 @@ // @flow import { createSelector } from 'reselect'; -import { threadIsPending } from 'lib/shared/thread-utils.js'; -import type { ThreadMessageInfo } from 'lib/types/message-types.js'; -import { defaultNumberPerThread } from 'lib/types/message-types.js'; -import type { ThreadActivityStore } from 'lib/types/thread-activity-types.js'; -import type { RawThreadInfos } from 'lib/types/thread-types.js'; - -import { activeThreadSelector } from '../navigation/nav-selectors.js'; -import type { AppState } from '../redux/state-types.js'; -import type { NavPlusRedux } from '../types/selector-types.js'; +import { threadIsPending } from '../shared/thread-utils.js'; +import { + type ThreadMessageInfo, + defaultNumberPerThread, +} from '../types/message-types.js'; +import type { AppState } from '../types/redux-types.js'; +import type { ThreadActivityStore } from '../types/thread-activity-types.js'; +import type { RawThreadInfos } from '../types/thread-types.js'; const msInHour = 60 * 60 * 1000; const nextMessagePruneTimeSelector: (state: AppState) => ?number = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.threadActivityStore, + (state: AppState) => state.messageStore.threads, ( threadInfos: RawThreadInfos, threadActivityStore: ThreadActivityStore, + threadMessageInfos: { +[id: string]: ThreadMessageInfo }, ): ?number => { let nextTime; for (const threadID in threadInfos) { + if ( + !threadMessageInfos[threadID] || + threadMessageInfos[threadID].messageIDs.length <= + defaultNumberPerThread + ) { + continue; + } const threadPruneTime = Math.max( (threadActivityStore?.[threadID]?.lastNavigatedTo ?? 0) + msInHour, (threadActivityStore?.[threadID]?.lastPruned ?? 0) + msInHour * 6, ); if (nextTime === undefined || threadPruneTime < nextTime) { nextTime = threadPruneTime; } } return nextTime; }, ); const pruneThreadIDsSelector: ( - input: NavPlusRedux, -) => () => $ReadOnlyArray = createSelector( - (input: NavPlusRedux): ThreadActivityStore => input.redux.threadActivityStore, - (input: NavPlusRedux) => input.redux.messageStore.threads, - (input: NavPlusRedux) => activeThreadSelector(input.navContext), + state: AppState, +) => (activeThread: ?string) => $ReadOnlyArray = createSelector( + (state: AppState): ThreadActivityStore => state.threadActivityStore, + (state: AppState) => state.messageStore.threads, ( threadActivityStore: ThreadActivityStore, threadMessageInfos: { +[id: string]: ThreadMessageInfo }, - activeThread: ?string, ) => - (): $ReadOnlyArray => { + (activeThread: ?string): $ReadOnlyArray => { const now = Date.now(); const threadIDsToPrune = []; for (const threadID in threadMessageInfos) { if (threadID === activeThread || threadIsPending(threadID)) { continue; } const threadMessageInfo = threadMessageInfos[threadID]; if ( (threadActivityStore?.[threadID]?.lastNavigatedTo ?? 0) + msInHour < now && threadMessageInfo.messageIDs.length > defaultNumberPerThread ) { threadIDsToPrune.push(threadID); } } return threadIDsToPrune; }, ); export { nextMessagePruneTimeSelector, pruneThreadIDsSelector }; diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 623d97fa4..778544514 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,477 +1,483 @@ // @flow import type { MaterialTopTabNavigationProp, StackNavigationState, StackOptions, StackNavigationEventMap, StackNavigatorProps, ExtraStackNavigatorProps, StackHeaderProps, StackNavigationProp, StackNavigationHelpers, ParamListBase, StackRouterOptions, MaterialTopTabNavigationHelpers, HeaderTitleInputProps, StackHeaderLeftButtonProps, } from '@react-navigation/core'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; import { createNavigatorFactory, useNavigationBuilder, } from '@react-navigation/native'; import { StackView } from '@react-navigation/stack'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, View, useWindowDimensions } from 'react-native'; -import { useSelector } from 'react-redux'; +import MessageStorePruner from 'lib/components/message-store-pruner.react.js'; import ThreadDraftUpdater from 'lib/components/thread-draft-updater.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import BackgroundChatThreadList from './background-chat-thread-list.react.js'; import ChatHeader from './chat-header.react.js'; import ChatRouter, { type ChatRouterNavigationHelpers, type ChatRouterNavigationAction, } from './chat-router.js'; import ComposeSubchannel from './compose-subchannel.react.js'; import ComposeThreadButton from './compose-thread-button.react.js'; import FullScreenThreadMediaGallery from './fullscreen-thread-media-gallery.react.js'; import HomeChatThreadList from './home-chat-thread-list.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import MessageListContainer from './message-list-container.react.js'; import MessageListHeaderTitle from './message-list-header-title.react.js'; -import MessageStorePruner from './message-store-pruner.react.js'; import PinnedMessagesScreen from './pinned-messages-screen.react.js'; import DeleteThread from './settings/delete-thread.react.js'; import EmojiThreadAvatarCreation from './settings/emoji-thread-avatar-creation.react.js'; import ThreadSettings from './settings/thread-settings.react.js'; import ThreadScreenPruner from './thread-screen-pruner.react.js'; import ThreadSettingsButton from './thread-settings-button.react.js'; import ThreadSettingsHeaderTitle from './thread-settings-header-title.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import CommunityDrawerButton from '../navigation/community-drawer-button.react.js'; import HeaderBackButton from '../navigation/header-back-button.react.js'; +import { activeThreadSelector } from '../navigation/nav-selectors.js'; +import { NavContext } from '../navigation/navigation-context.js'; import { defaultStackScreenOptions, transitionPreset, } from '../navigation/options.js'; import { ComposeSubchannelRouteName, DeleteThreadRouteName, ThreadSettingsRouteName, EmojiThreadAvatarCreationRouteName, FullScreenThreadMediaGalleryRouteName, PinnedMessagesScreenRouteName, MessageListRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type ScreenParamList, type ChatParamList, type ChatTopTabsParamList, MessageSearchRouteName, ChangeRolesScreenRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; +import { useSelector } from '../redux/redux-utils.js'; import ChangeRolesHeaderLeftButton from '../roles/change-roles-header-left-button.react.js'; import ChangeRolesScreen from '../roles/change-roles-screen.react.js'; import MessageSearch from '../search/message-search.react.js'; import SearchHeader from '../search/search-header.react.js'; import SearchMessagesButton from '../search/search-messages-button.react.js'; import { useColors, useStyles } from '../themes/colors.js'; const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'listBackground', }, threadListHeaderStyle: { elevation: 0, shadowOffset: { width: 0, height: 0 }, borderBottomWidth: 0, backgroundColor: 'tabBarBackground', }, }; export type ChatTopTabsNavigationProp< RouteName: $Keys = $Keys, > = MaterialTopTabNavigationProp; export type ChatTopTabsNavigationHelpers = MaterialTopTabNavigationHelpers; const homeChatThreadListOptions = { title: 'Focused', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const backgroundChatThreadListOptions = { title: 'Background', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const ChatThreadsTopTab = createMaterialTopTabNavigator< ScreenParamList, ChatTopTabsParamList, ChatTopTabsNavigationHelpers, >(); function ChatThreadsComponent(): React.Node { const colors = useColors(); const { tabBarBackground, tabBarAccent } = colors; const screenOptions = React.useMemo( () => ({ tabBarShowIcon: true, tabBarStyle: { backgroundColor: tabBarBackground, }, tabBarItemStyle: { flexDirection: 'row', }, tabBarIndicatorStyle: { borderColor: tabBarAccent, borderBottomWidth: 2, }, }), [tabBarAccent, tabBarBackground], ); return ( ); } export type ChatNavigationHelpers = { ...$Exact>, ...ChatRouterNavigationHelpers, }; type ChatNavigatorProps = StackNavigatorProps>; function ChatNavigator({ initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, id, ...rest }: ChatNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder< StackNavigationState, ChatRouterNavigationAction, StackOptions, StackRouterOptions, ChatNavigationHelpers<>, StackNavigationEventMap, ExtraStackNavigatorProps, >(ChatRouter, { id, initialRouteName, children, screenOptions, defaultScreenOptions, screenListeners, }); // Clear ComposeSubchannel screens after each message is sent. If a user goes // to ComposeSubchannel to create a new thread, but finds an existing one and // uses it instead, we can assume the intent behind opening ComposeSubchannel // is resolved const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set in ChatNavigator'); const clearComposeScreensAfterMessageSend = React.useCallback(() => { navigation.clearScreens([ComposeSubchannelRouteName]); }, [navigation]); React.useEffect(() => { inputState.registerSendCallback(clearComposeScreensAfterMessageSend); return () => { inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend); }; }, [inputState, clearComposeScreensAfterMessageSend]); return ( ); } const createChatNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationHelpers<>, ExtraStackNavigatorProps, >(ChatNavigator); const header = (props: StackHeaderProps) => { // 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 headerRightStyle = { flexDirection: 'row' }; const messageListOptions = ({ navigation, route, }: { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }) => { const isSearchEmpty = !!route.params.searching && route.params.threadInfo.members.length === 1; const areSettingsEnabled = !threadIsPending(route.params.threadInfo.id) && !isSearchEmpty; return { headerTitle: (props: HeaderTitleInputProps) => ( ), headerRight: areSettingsEnabled ? () => ( ) : undefined, headerBackTitleVisible: false, headerTitleAlign: isSearchEmpty ? 'center' : 'left', headerLeftContainerStyle: { width: Platform.OS === 'ios' ? 32 : 40 }, headerTitleStyle: areSettingsEnabled ? { marginRight: 20 } : undefined, }; }; const composeThreadOptions = { headerTitle: 'Compose chat', headerBackTitleVisible: false, }; const threadSettingsOptions = ({ route, }: { +route: NavigationRoute<'ThreadSettings'>, ... }) => ({ headerTitle: (props: HeaderTitleInputProps) => ( ), headerBackTitleVisible: false, }); const emojiAvatarCreationOptions = { headerTitle: 'Emoji avatar selection', headerBackTitleVisible: false, }; const fullScreenThreadMediaGalleryOptions = { headerTitle: 'All Media', headerBackTitleVisible: false, }; const deleteThreadOptions = { headerTitle: 'Delete chat', headerBackTitleVisible: false, }; const messageSearchOptions = { headerTitle: () => , headerBackTitleVisible: false, headerTitleContainerStyle: { width: '100%', }, }; const pinnedMessagesScreenOptions = { headerTitle: 'Pinned Messages', headerBackTitleVisible: false, }; const changeRolesScreenOptions = ({ route, }: { +route: NavigationRoute<'ChangeRolesScreen'>, ... }) => ({ headerLeft: (headerLeftProps: StackHeaderLeftButtonProps) => ( ), headerTitle: 'Change Role', presentation: 'modal', ...transitionPreset, }); export type ChatNavigationProp< RouteName: $Keys = $Keys, > = { ...StackNavigationProp, ...ChatRouterNavigationHelpers, }; const Chat = createChatNavigator< ScreenParamList, ChatParamList, ChatNavigationHelpers, >(); type Props = { +navigation: TabNavigationProp<'Chat'>, ... }; export default function ChatComponent(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const loggedIn = useSelector(isLoggedIn); let draftUpdater = null; if (loggedIn) { draftUpdater = ; } const headerLeftButton = React.useCallback( (headerProps: StackHeaderLeftButtonProps) => { if (headerProps.canGoBack) { return ; } return ; }, [props.navigation], ); const messageEditingContext = React.useContext(MessageEditingContext); const editState = messageEditingContext?.editState; const editMode = !!editState?.editedMessage; const { width: screenWidth } = useWindowDimensions(); const screenOptions = React.useMemo( () => ({ ...defaultStackScreenOptions, header, headerLeft: headerLeftButton, headerStyle: { backgroundColor: colors.tabBarBackground, borderBottomWidth: 1, }, gestureEnabled: true, gestureResponseDistance: editMode ? 0 : screenWidth, }), [colors.tabBarBackground, headerLeftButton, screenWidth, editMode], ); const chatThreadListOptions = React.useCallback( ({ navigation, }: { +navigation: ChatNavigationProp<'ChatThreadList'>, ... }) => ({ headerTitle: 'Inbox', headerRight: Platform.OS === 'ios' ? () => : undefined, headerBackTitleVisible: false, headerStyle: styles.threadListHeaderStyle, }), [styles.threadListHeaderStyle], ); + const frozen = useSelector(state => state.frozen); + const navContext = React.useContext(NavContext); + const activeThreadID = activeThreadSelector(navContext); + return ( - + {draftUpdater} ); } diff --git a/web/chat/chat.react.js b/web/chat/chat.react.js index cf632dd00..6bff235c5 100644 --- a/web/chat/chat.react.js +++ b/web/chat/chat.react.js @@ -1,51 +1,57 @@ // @flow import * as React from 'react'; +import MessageStorePruner from 'lib/components/message-store-pruner.react.js'; import ThreadDraftUpdater from 'lib/components/thread-draft-updater.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import ChatMessageListContainer from './chat-message-list-container.react.js'; import ChatTabs from './chat-tabs.react.js'; import { ThreadListProvider } from './thread-list-provider.js'; import { useSelector } from '../redux/redux-utils.js'; +import { activeThreadSelector } from '../selectors/nav-selectors.js'; function Chat(): React.Node { const loggedIn = useSelector(isLoggedIn); const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const chatModeIsCreate = useSelector( state => state.navInfo.chatMode === 'create', ); const chatList = React.useMemo( () => ( ), [], ); const messageList = React.useMemo(() => { if (!activeChatThreadID && !chatModeIsCreate) { return null; } return ; }, [activeChatThreadID, chatModeIsCreate]); let threadDraftUpdater = null; if (loggedIn) { threadDraftUpdater = ; } + + const activeThreadID = useSelector(activeThreadSelector); + return ( <> {chatList} {messageList} {threadDraftUpdater} + ); } const MemoizedChat: React.ComponentType<{}> = React.memo<{}>(Chat); export default MemoizedChat;