diff --git a/native/chat/chat-options.js b/native/chat/chat-options.js new file mode 100644 index 000000000..cf8fdaf44 --- /dev/null +++ b/native/chat/chat-options.js @@ -0,0 +1,28 @@ +// @flow + +import * as React from 'react'; + +import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; + +import SWMansionIcon from '../components/swmansion-icon.react.js'; + +const homeChatThreadListOptions: { + +title: string, + +tabBarIcon: ({ +color: string, ... }) => React.Node, +} = { + title: threadSettingsNotificationsCopy.HOME, + tabBarIcon: ({ color }) => ( + + ), +}; +const backgroundChatThreadListOptions: { + +title: string, + +tabBarIcon: ({ +color: string, ... }) => React.Node, +} = { + title: threadSettingsNotificationsCopy.MUTED, + tabBarIcon: ({ color }) => ( + + ), +}; + +export { backgroundChatThreadListOptions, homeChatThreadListOptions }; diff --git a/native/chat/chat-tab-bar.react.js b/native/chat/chat-tab-bar.react.js new file mode 100644 index 000000000..3e6e95724 --- /dev/null +++ b/native/chat/chat-tab-bar.react.js @@ -0,0 +1,73 @@ +// @flow + +import type { + MaterialTopTabBarProps, + Route, + TabBarItemProps, +} from '@react-navigation/core'; +import { MaterialTopTabBar } from '@react-navigation/material-top-tabs'; +import invariant from 'invariant'; +import * as React from 'react'; +import { View } from 'react-native'; +import { TabBarItem } from 'react-native-tab-view'; + +import { + nuxTip, + NUXTipsContext, +} from '../components/nux-tips-context.react.js'; +import { + HomeChatThreadListRouteName, + BackgroundChatThreadListRouteName, +} from '../navigation/route-names.js'; + +const ButtonTitleToTip = Object.freeze({ + [BackgroundChatThreadListRouteName]: nuxTip.MUTED, + [HomeChatThreadListRouteName]: nuxTip.HOME, +}); + +function TabBarButton(props: TabBarItemProps>) { + const tipsContext = React.useContext(NUXTipsContext); + invariant(tipsContext, 'NUXTipsContext should be defined'); + + const viewRef = React.useRef>(); + const onLayout = React.useCallback(() => { + const button = viewRef.current; + if (!button) { + return; + } + + const tipType = ButtonTitleToTip[props.route.name]; + if (!tipType) { + return; + } + button.measure((x, y, width, height, pageX, pageY) => { + tipsContext.registerTipButton(tipType, { + x, + y, + width, + height, + pageX, + pageY, + }); + }); + }, [props.route.name, tipsContext]); + + return ( + + + + ); +} + +export default function TabBarTop( + props: MaterialTopTabBarProps>, +): React.Node { + const renderTabBarItem = React.useCallback( + (innerProps: $ReadOnly<{ ...TabBarItemProps>, key: string }>) => ( + + ), + [], + ); + + return ; +} diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 26e8bede6..5c2248197 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,535 +1,527 @@ // @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 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 { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import { threadIsPending, threadIsSidebar } from 'lib/shared/thread-utils.js'; import BackgroundChatThreadList from './background-chat-thread-list.react.js'; import ChatHeader from './chat-header.react.js'; +import { + backgroundChatThreadListOptions, + homeChatThreadListOptions, +} from './chat-options.js'; import ChatRouter, { type ChatRouterNavigationHelpers, type ChatRouterNavigationAction, } from './chat-router.js'; +import TabBar from './chat-tab-bar.react.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 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 ThreadSettingsNotifications from './settings/thread-settings-notifications.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 { nuxTip, NUXTipsContext, } from '../components/nux-tips-context.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, ThreadSettingsNotificationsRouteName, 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: threadSettingsNotificationsCopy.HOME, - tabBarIcon: ({ color }: { +color: string, ... }) => ( - - ), -}; -const backgroundChatThreadListOptions = { - title: threadSettingsNotificationsCopy.MUTED, - 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 threadSettingsNotificationsOptions = ({ route, }: { +route: NavigationRoute<'ThreadSettingsNotifications'>, ... }) => ({ headerTitle: threadIsSidebar(route.params.threadInfo) ? threadSettingsNotificationsCopy.SIDEBAR_TITLE : threadSettingsNotificationsCopy.CHANNEL_TITLE, 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 communityDrawerButtonRef = React.useRef>(); const tipsContext = React.useContext(NUXTipsContext); invariant(tipsContext, 'NUXTipsContext should be defined'); const { registerTipButton } = tipsContext; const communityDrawerButtonOnLayout = React.useCallback(() => { communityDrawerButtonRef.current?.measure( (x, y, width, height, pageX, pageY) => { registerTipButton(nuxTip.COMMUNITY_DRAWER, { x, y, width, height, pageX, pageY, }); }, ); }, [registerTipButton]); const headerLeftButton = React.useCallback( (headerProps: StackHeaderLeftButtonProps) => { if (headerProps.canGoBack) { return ; } return ( ); }, [communityDrawerButtonOnLayout, 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/native/components/nux-tips-context.react.js b/native/components/nux-tips-context.react.js index 8ef520e6e..4a714f115 100644 --- a/native/components/nux-tips-context.react.js +++ b/native/components/nux-tips-context.react.js @@ -1,97 +1,98 @@ // @flow import * as React from 'react'; import { values } from 'lib/utils/objects.js'; import type { NUXTipRouteNames } from '../navigation/route-names.js'; const nuxTip = Object.freeze({ COMMUNITY_DRAWER: 'community_drawer', MUTED: 'muted', + HOME: 'home', }); export type NUXTip = $Values; type NUXTipParams = { +nextTip: ?NUXTip, +tooltipLocation: 'below' | 'above', +nextRouteName: ?NUXTipRouteNames, }; const nuxTipParams: { [NUXTip]: NUXTipParams } = { [nuxTip.COMMUNITY_DRAWER]: { nextTip: nuxTip.MUTED, tooltipLocation: 'below', nextRouteName: undefined, //TODO: update to the next screens name }, [nuxTip.MUTED]: { nextTip: undefined, nextRouteName: undefined, tooltipLocation: 'below', }, }; function getNUXTipParams(currentTipKey: NUXTip): NUXTipParams { return nuxTipParams[currentTipKey]; } type TipProps = { +x: number, +y: number, +width: number, +height: number, +pageX: number, +pageY: number, }; export type NUXTipsContextType = { +registerTipButton: (type: NUXTip, tipProps: ?TipProps) => void, +getTipsProps: () => ?{ +[type: NUXTip]: TipProps }, }; const NUXTipsContext: React.Context = React.createContext(); type Props = { +children: React.Node, }; function NUXTipsContextProvider(props: Props): React.Node { const { children } = props; const tipsProps = React.useRef<{ [tip: NUXTip]: ?TipProps }>({}); const registerTipButton = React.useCallback( (type: NUXTip, tipProps: ?TipProps) => { tipsProps.current[type] = tipProps; }, [], ); const getTipsProps = React.useCallback(() => { const result: { [tip: NUXTip]: TipProps } = {}; for (const type of values(nuxTip)) { if (!tipsProps.current[type]) { return null; } result[type] = tipsProps.current[type]; } return result; }, []); const value = React.useMemo( () => ({ registerTipButton, getTipsProps, }), [getTipsProps, registerTipButton], ); return ( {children} ); } export { NUXTipsContext, NUXTipsContextProvider, nuxTip, getNUXTipParams };