From 73d91e2eb77faf10d307a1aa38cb5bcc6f6f318a Mon Sep 17 00:00:00 2001 From: Dan Brewster Date: Tue, 3 Dec 2024 12:15:44 -0500 Subject: [PATCH] flatten single channel nav (#4233) * rough flattened nav * fix thread crash * omit use of skiptoken * only flatten single-channel groups * fix build * fix android crash --- .../src/components/AuthenticatedApp.tsx | 10 +- .../src/hooks/useNotificationListener.ts | 18 +-- .../channels/ChannelMembersScreen.tsx | 2 +- packages/app/features/top/ChannelScreen.tsx | 12 -- .../app/features/top/ChannelSearchScreen.tsx | 4 +- packages/app/features/top/ChatListScreen.tsx | 65 ++-------- .../app/features/top/GroupChannelsScreen.tsx | 13 +- packages/app/features/top/PostScreen.tsx | 67 ++++++----- packages/app/hooks/useChannelNavigation.ts | 3 +- packages/app/hooks/useGroupActions.tsx | 6 +- packages/app/hooks/useGroupNavigation.ts | 4 +- packages/app/navigation/types.ts | 7 +- packages/app/navigation/utils.ts | 44 ++++++- packages/shared/src/db/keyValue.ts | 27 ++--- packages/shared/src/db/queries.ts | 10 +- packages/shared/src/store/dbHooks.ts | 43 +++---- .../shared/src/store/useChannelContext.ts | 6 +- .../src/components/Channel/BaubleHeader.tsx | 18 +-- .../src/components/Channel/ChannelHeader.tsx | 20 ++-- packages/ui/src/components/Channel/index.tsx | 1 + .../src/components/ChannelSwitcherSheet.tsx | 38 +++--- packages/ui/src/components/ChatList.tsx | 18 ++- .../ui/src/components/ChatOptionsSheet.tsx | 71 +++-------- .../components/GroupChannelsScreenView.tsx | 40 ++----- packages/ui/src/components/Pressable.tsx | 2 +- packages/ui/src/contexts/chatOptions.tsx | 111 +++++++++++++----- packages/ui/src/utils/channelUtils.tsx | 2 +- 27 files changed, 309 insertions(+), 353 deletions(-) diff --git a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx index c60cab2bd4..8bf201dffb 100644 --- a/apps/tlon-mobile/src/components/AuthenticatedApp.tsx +++ b/apps/tlon-mobile/src/components/AuthenticatedApp.tsx @@ -9,7 +9,7 @@ import { useUpdatePresentedNotifications } from '@tloncorp/app/lib/notifications import { RootStack } from '@tloncorp/app/navigation/RootStack'; import { AppDataProvider } from '@tloncorp/app/provider/AppDataProvider'; import { sync } from '@tloncorp/shared'; -import { ZStack } from '@tloncorp/ui'; +import { PortalProvider, ZStack } from '@tloncorp/ui'; import { useCallback, useEffect } from 'react'; import { AppStateStatus } from 'react-native'; @@ -58,7 +58,13 @@ function AuthenticatedApp() { export default function ConnectedAuthenticatedApp() { return ( - + {/* + This portal provider overrides the root portal provider + to ensure that sheets have access to `AppDataContext` + */} + + + ); } diff --git a/apps/tlon-mobile/src/hooks/useNotificationListener.ts b/apps/tlon-mobile/src/hooks/useNotificationListener.ts index 5f0af562e9..4b49dc5a57 100644 --- a/apps/tlon-mobile/src/hooks/useNotificationListener.ts +++ b/apps/tlon-mobile/src/hooks/useNotificationListener.ts @@ -6,6 +6,7 @@ import { connectNotifications } from '@tloncorp/app/lib/notifications'; import { RootStackParamList } from '@tloncorp/app/navigation/types'; import { createTypedReset, + getMainGroupRoute, screenNameFromChannelId, } from '@tloncorp/app/navigation/utils'; import * as posthog from '@tloncorp/app/utils/posthog'; @@ -154,17 +155,18 @@ export default function useNotificationListener() { } const routeStack: RouteStack = [{ name: 'ChatList' }]; - if (channel.groupId && !channelSwitcherEnabled) { + if (channel.groupId) { + const mainGroupRoute = await getMainGroupRoute(channel.groupId); + routeStack.push(mainGroupRoute); + } + // Only push the channel if it wasn't already handled by the main group stack + if (routeStack[routeStack.length - 1].name !== 'Channel') { + const screenName = screenNameFromChannelId(channelId); routeStack.push({ - name: 'GroupChannels', - params: { groupId: channel.groupId }, + name: screenName, + params: { channelId: channel.id }, }); } - const screenName = screenNameFromChannelId(channelId); - routeStack.push({ - name: screenName, - params: { channelId: channel.id }, - }); // if we have a post id, try to navigate to the thread if (postInfo) { diff --git a/packages/app/features/channels/ChannelMembersScreen.tsx b/packages/app/features/channels/ChannelMembersScreen.tsx index baef4a6fbd..7910155d57 100644 --- a/packages/app/features/channels/ChannelMembersScreen.tsx +++ b/packages/app/features/channels/ChannelMembersScreen.tsx @@ -8,7 +8,7 @@ type Props = NativeStackScreenProps; export function ChannelMembersScreen(props: Props) { const { channelId } = props.route.params; - const channelQuery = store.useChannelWithRelations({ + const channelQuery = store.useChannel({ id: channelId, }); diff --git a/packages/app/features/top/ChannelScreen.tsx b/packages/app/features/top/ChannelScreen.tsx index c5e49946b7..148237ba04 100644 --- a/packages/app/features/top/ChannelScreen.tsx +++ b/packages/app/features/top/ChannelScreen.tsx @@ -319,16 +319,6 @@ export default function ChannelScreen(props: Props) { const canUpload = useCanUpload(); - const isFocused = useIsFocused(); - - const { data: pins } = store.usePins({ - enabled: isFocused, - }); - - const pinnedItems = useMemo(() => { - return pins ?? []; - }, [pins]); - const chatOptionsNavProps = useChatSettingsNavigation(); const handleGoToUserProfile = useCallback( @@ -350,8 +340,6 @@ export default function ChannelScreen(props: Props) { return ( { setInviteSheetGroup(group); diff --git a/packages/app/features/top/ChannelSearchScreen.tsx b/packages/app/features/top/ChannelSearchScreen.tsx index 32c2bf4d1b..3e8d89ce52 100644 --- a/packages/app/features/top/ChannelSearchScreen.tsx +++ b/packages/app/features/top/ChannelSearchScreen.tsx @@ -1,5 +1,5 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { useChannelSearch, useChannelWithRelations } from '@tloncorp/shared'; +import { useChannel, useChannelSearch } from '@tloncorp/shared'; import type * as db from '@tloncorp/shared/db'; import { Button, SearchBar, SearchResults, XStack, YStack } from '@tloncorp/ui'; import { useCallback, useState } from 'react'; @@ -13,7 +13,7 @@ type Props = NativeStackScreenProps; export default function ChannelSearchScreen(props: Props) { const channelId = props.route.params.channelId; const groupId = props.route.params.groupId; - const channelQuery = useChannelWithRelations({ + const channelQuery = useChannel({ id: channelId, }); diff --git a/packages/app/features/top/ChatListScreen.tsx b/packages/app/features/top/ChatListScreen.tsx index de48a4cca7..dabd2a5873 100644 --- a/packages/app/features/top/ChatListScreen.tsx +++ b/packages/app/features/top/ChatListScreen.tsx @@ -6,14 +6,11 @@ import { import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { createDevLogger } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; -import * as logic from '@tloncorp/shared/logic'; import * as store from '@tloncorp/shared/store'; import { AddGroupSheet, ChatList, ChatOptionsProvider, - ChatOptionsSheet, - ChatOptionsSheetMethods, GroupPreviewAction, GroupPreviewSheet, InviteUsersSheet, @@ -29,9 +26,11 @@ import { TLON_EMPLOYEE_GROUP } from '../../constants'; import { useChatSettingsNavigation } from '../../hooks/useChatSettingsNavigation'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useGroupActions } from '../../hooks/useGroupActions'; -import { useFeatureFlag } from '../../lib/featureFlags'; import type { RootStackParamList } from '../../navigation/types'; -import { screenNameFromChannelId } from '../../navigation/utils'; +import { + screenNameFromChannelId, + useNavigateToGroup, +} from '../../navigation/utils'; import { identifyTlonEmployee } from '../../utils/posthog'; import { isSplashDismissed, setSplashDismissed } from '../../utils/splash'; @@ -53,19 +52,6 @@ export function ChatListScreenView({ const [addGroupOpen, setAddGroupOpen] = useState(false); const [screenTitle, setScreenTitle] = useState('Home'); const [inviteSheetGroup, setInviteSheetGroup] = useState(); - const chatOptionsSheetRef = useRef(null); - const [longPressedChat, setLongPressedChat] = useState(null); - const chatOptionsGroupId = useMemo(() => { - return longPressedChat?.type === 'group' - ? longPressedChat.group.id - : undefined; - }, [longPressedChat]); - - const chatOptionsChannelId = useMemo(() => { - return longPressedChat?.type === 'channel' - ? longPressedChat.channel.id - : undefined; - }, [longPressedChat]); const [activeTab, setActiveTab] = useState<'all' | 'groups' | 'messages'>( 'all' @@ -77,10 +63,6 @@ export function ChatListScreenView({ const [showSearchInput, setShowSearchInput] = useState(false); const isFocused = useIsFocused(); - const { data: pins } = store.usePins({ - enabled: isFocused, - }); - const pinned = useMemo(() => pins ?? [], [pins]); const { data: chats } = store.useCurrentChats({ enabled: isFocused, @@ -172,22 +154,16 @@ export function ChatListScreenView({ } }, []); - const [isChannelSwitcherEnabled] = useFeatureFlag('channelSwitcher'); + const navigateToGroup = useNavigateToGroup(); const onPressChat = useCallback( - (item: db.Chat) => { - if (item.type === 'group' && item.isPending) { - setSelectedGroupId(item.id); - } else if (item.type === 'group' && !isChannelSwitcherEnabled) { - navigation.navigate('GroupChannels', { groupId: item.group.id }); - } else if (item.type === 'group') { - if (!item.group.channels?.length) { - throw new Error('cant open group with no channels'); + async (item: db.Chat) => { + if (item.type === 'group') { + if (item.isPending) { + setSelectedGroupId(item.id); + } else { + navigateToGroup(item.group.id); } - navigation.navigate('Channel', { - channelId: item.group.channels[0].id, - groupId: item.group.id, - }); } else { const screenName = screenNameFromChannelId(item.id); navigation.navigate(screenName, { @@ -195,21 +171,9 @@ export function ChatListScreenView({ }); } }, - [isChannelSwitcherEnabled, navigation] + [navigateToGroup, navigation] ); - const onLongPressChat = useCallback((item: db.Chat) => { - if (item.isPending) { - return; - } - setLongPressedChat(item); - chatOptionsSheetRef.current?.open( - item.id, - item.type === 'channel' ? item.channel.type : 'group', - item.unreadCount - ); - }, []); - const handleGroupPreviewSheetOpenChange = useCallback((open: boolean) => { if (!open) { setSelectedGroupId(null); @@ -298,9 +262,6 @@ export function ChatListScreenView({ useGroup={store.useGroupPreview} > { setInviteSheetGroup(group); @@ -329,7 +290,6 @@ export function ChatListScreenView({ pinned={resolvedChats.pinned} unpinned={resolvedChats.unpinned} pending={resolvedChats.pending} - onLongPressItem={onLongPressChat} onPressItem={onPressChat} onSectionChange={handleSectionChange} showSearchInput={showSearchInput} @@ -343,7 +303,6 @@ export function ChatListScreenView({ open={splashVisible} onOpenChange={handleWelcomeOpenChange} /> - >(); - const isFocused = useIsFocused(); - const { data: pins } = store.usePins({ - enabled: isFocused, - }); const [inviteSheetGroup, setInviteSheetGroup] = useState( null ); @@ -43,10 +39,6 @@ export function GroupChannelsScreenContent({ group?.id ?? '' ); - const pinnedItems = useMemo(() => { - return pins ?? []; - }, [pins]); - const handleChannelSelected = useCallback( (channel: db.Channel) => { navigation.navigate('Channel', { @@ -79,9 +71,6 @@ export function GroupChannelsScreenContent({ return ( { setInviteSheetGroup(group); }} diff --git a/packages/app/features/top/PostScreen.tsx b/packages/app/features/top/PostScreen.tsx index 51e5659fdd..178151584a 100644 --- a/packages/app/features/top/PostScreen.tsx +++ b/packages/app/features/top/PostScreen.tsx @@ -3,10 +3,15 @@ import { useChannelContext } from '@tloncorp/shared'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import * as urbit from '@tloncorp/shared/urbit'; -import { PostScreenView, useCurrentUserId } from '@tloncorp/ui'; +import { + ChatOptionsProvider, + PostScreenView, + useCurrentUserId, +} from '@tloncorp/ui'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useChannelNavigation } from '../../hooks/useChannelNavigation'; +import { useChatSettingsNavigation } from '../../hooks/useChatSettingsNavigation'; import { useGroupActions } from '../../hooks/useGroupActions'; import { useFeatureFlag } from '../../lib/featureFlags'; import type { RootStackParamList } from '../../navigation/types'; @@ -134,34 +139,38 @@ export default function PostScreen(props: Props) { [props.navigation] ); + const chatOptionsNavProps = useChatSettingsNavigation(); + return currentUserId && channel && post ? ( - + + + ) : null; } diff --git a/packages/app/hooks/useChannelNavigation.ts b/packages/app/hooks/useChannelNavigation.ts index 455b385c89..ca761f2db1 100644 --- a/packages/app/hooks/useChannelNavigation.ts +++ b/packages/app/hooks/useChannelNavigation.ts @@ -7,8 +7,7 @@ import { useCallback } from 'react'; import { RootStackParamList } from '../navigation/types'; export const useChannelNavigation = ({ channelId }: { channelId: string }) => { - // Model context - const channelQuery = store.useChannelWithRelations({ + const channelQuery = store.useChannel({ id: channelId, }); diff --git a/packages/app/hooks/useGroupActions.tsx b/packages/app/hooks/useGroupActions.tsx index f00ae8945a..506c3c0cb4 100644 --- a/packages/app/hooks/useGroupActions.tsx +++ b/packages/app/hooks/useGroupActions.tsx @@ -5,19 +5,19 @@ import { useCallback } from 'react'; import { useGroupNavigation } from './useGroupNavigation'; export const useGroupActions = () => { - const { goToHome, goToGroupChannels } = useGroupNavigation(); + const { goToHome, goToGroup } = useGroupNavigation(); const performGroupAction = useCallback( async (action: GroupPreviewAction, updatedGroup: db.Group) => { if (action === 'goTo') { - goToGroupChannels(updatedGroup.id); + goToGroup(updatedGroup.id); } if (action === 'joined') { goToHome(); } }, - [goToGroupChannels, goToHome] + [goToGroup, goToHome] ); return { diff --git a/packages/app/hooks/useGroupNavigation.ts b/packages/app/hooks/useGroupNavigation.ts index e62d7998c4..df7597c72f 100644 --- a/packages/app/hooks/useGroupNavigation.ts +++ b/packages/app/hooks/useGroupNavigation.ts @@ -26,7 +26,7 @@ export const useGroupNavigation = () => { [navigation] ); - const goToGroupChannels = useCallback( + const goToGroup = useCallback( async (groupId: string) => { resetToGroup(groupId); }, @@ -48,6 +48,6 @@ export const useGroupNavigation = () => { goToChannel, goToHome, goToContactHostedGroups, - goToGroupChannels, + goToGroup, }; }; diff --git a/packages/app/navigation/types.ts b/packages/app/navigation/types.ts index 2340cc3917..73bbc1aa16 100644 --- a/packages/app/navigation/types.ts +++ b/packages/app/navigation/types.ts @@ -1,4 +1,7 @@ -import type { NavigatorScreenParams } from '@react-navigation/native'; +import type { + NavigationProp, + NavigatorScreenParams, +} from '@react-navigation/native'; export type RootStackParamList = { Contacts: undefined; @@ -66,6 +69,8 @@ export type RootStackParamList = { }; }; +export type RootStackNavigationProp = NavigationProp; + export type RootDrawerParamList = { Home: NavigatorScreenParams; } & Pick; diff --git a/packages/app/navigation/utils.ts b/packages/app/navigation/utils.ts index 13348316aa..e6f0c493e0 100644 --- a/packages/app/navigation/utils.ts +++ b/packages/app/navigation/utils.ts @@ -3,10 +3,13 @@ import { NavigationProp, useNavigation, } from '@react-navigation/native'; +import * as db from '@tloncorp/shared/db'; import * as logic from '@tloncorp/shared/logic'; import * as store from '@tloncorp/shared/store'; +import { useCallback } from 'react'; -import { RootStackParamList } from './types'; +import { useFeatureFlagStore } from '../lib/featureFlags'; +import { RootStackNavigationProp, RootStackParamList } from './types'; type ResetRouteConfig> = { name: Extract; @@ -83,14 +86,43 @@ export function useResetToDm() { export function useResetToGroup() { const reset = useTypedReset(); - return function resetToGroup(groupId: string) { - reset([ - { name: 'ChatList' }, - { name: 'GroupChannels', params: { groupId } }, - ]); + return async function resetToGroup(groupId: string) { + reset([{ name: 'ChatList' }, await getMainGroupRoute(groupId)]); }; } +export function useNavigateToGroup() { + const navigation = useNavigation(); + const navigationRef = logic.useMutableRef(navigation); + return useCallback( + async (groupId: string) => { + navigationRef.current.navigate(await getMainGroupRoute(groupId)); + }, + [navigationRef] + ); +} + +export async function getMainGroupRoute(groupId: string) { + const group = await db.getGroup({ id: groupId }); + const channelSwitcherEnabled = + useFeatureFlagStore.getState().flags.channelSwitcher; + if ( + group && + group.channels && + (group.channels.length === 1 || channelSwitcherEnabled) + ) { + return { + name: 'Channel', + params: { channelId: group.channels[0].id, groupId }, + } as const; + } else { + return { + name: 'GroupChannels', + params: { groupId }, + } as const; + } +} + export function screenNameFromChannelId(channelId: string) { return logic.isDmChannelId(channelId) ? 'DM' diff --git a/packages/shared/src/db/keyValue.ts b/packages/shared/src/db/keyValue.ts index 2ca38c6472..77da3c9889 100644 --- a/packages/shared/src/db/keyValue.ts +++ b/packages/shared/src/db/keyValue.ts @@ -30,26 +30,6 @@ export const BASE_VOLUME_SETTING_QUERY_KEY = ['volume', 'base']; export const SHOW_BENEFITS_SHEET_QUERY_KEY = ['showBenefitsSheet']; export const THEME_STORAGE_KEY = '@user_theme'; -export type ChannelSortPreference = 'recency' | 'arranged'; -export async function storeChannelSortPreference( - sortPreference: ChannelSortPreference -) { - try { - await AsyncStorage.setItem('channelSortPreference', sortPreference); - } catch (error) { - logger.error('storeChannelSortPreference', error); - } -} - -export async function getChannelSortPreference() { - try { - const value = await AsyncStorage.getItem('channelSortPreference'); - return (value ?? 'recency') as ChannelSortPreference; - } catch (error) { - logger.error('getChannelSortPreference', error); - } -} - export async function getActivitySeenMarker() { const marker = await AsyncStorage.getItem('activitySeenMarker'); return Number(marker) ?? 1; @@ -312,3 +292,10 @@ export const themeSettings = createStorageItem({ key: THEME_STORAGE_KEY, defaultValue: null, }); + +export type ChannelSortPreference = 'recency' | 'arranged'; + +export const channelSortPreference = createStorageItem({ + key: 'channelSortPreference', + defaultValue: 'recency', +}); diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index cce64ed748..4a7f93516a 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -1429,16 +1429,9 @@ export const getAllSingleDms = createReadQuery( [] ); -export interface GetChannelWithRelations { - id: string; -} - export const getChannelWithRelations = createReadQuery( 'getChannelWithRelations', - async ( - { id }: GetChannelWithRelations, - ctx: QueryCtx - ): Promise => { + async ({ id }: { id: string }, ctx: QueryCtx): Promise => { const result = await ctx.db.query.channels.findFirst({ where: eq($channels.id, id), with: { @@ -2609,6 +2602,7 @@ export const getGroup = createReadQuery( .findFirst({ where: (groups, { eq }) => eq(groups.id, id), with: { + unread: true, pin: true, channels: { where: (channels, { eq }) => eq(channels.currentUserIsMember, true), diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index e357e5f33b..a42e8659d4 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -312,18 +312,15 @@ export const useGroups = (options: db.GetGroupsOptions) => { }); }; -export const useGroup = (options: { id?: string }) => { +export const useGroup = ({ id }: { id?: string }) => { return useQuery({ - enabled: !!options.id, - queryKey: [['group', options], useKeyFromQueryDeps(db.getGroup, options)], + enabled: !!id, + queryKey: [['group', { id }], useKeyFromQueryDeps(db.getGroup, { id })], queryFn: () => { - if (!options.id) { - // This should never actually get thrown as the query is disabled if id - // is missing - throw new Error('missing id'); + if (!id) { + throw new Error('missing group id'); } - const enabledOptions = options as { id: string }; - return db.getGroup(enabledOptions); + return db.getGroup({ id }); }, }); }; @@ -444,26 +441,24 @@ export const useChannelSearchResults = ( }); }; -export const useChannelWithRelations = ( - options: db.GetChannelWithRelations -) => { - const tableDeps = useKeyFromQueryDeps(db.getChannelWithRelations); +export const useChannel = (options: { id?: string }) => { + const { id } = options; return useQuery({ - queryKey: ['channelWithRelations', tableDeps, options], - queryFn: async () => { - const channel = await db.getChannelWithRelations(options); - return channel ?? null; + enabled: !!id, + queryKey: [ + 'channelWithRelations', + useKeyFromQueryDeps(db.getChannelWithRelations), + options, + ], + queryFn: () => { + if (!id) { + throw new Error('missing channel id'); + } + return db.getChannelWithRelations({ id }); }, }); }; -export const useChannel = (options: { id: string }) => { - return useQuery({ - queryKey: [['channel', options]], - queryFn: () => db.getChannelWithRelations(options), - }); -}; - export const usePostWithThreadUnreads = (options: { id: string }) => { const tableDeps = useKeyFromQueryDeps(db.getPostWithRelations); return useQuery({ diff --git a/packages/shared/src/store/useChannelContext.ts b/packages/shared/src/store/useChannelContext.ts index 20444f21db..45d4424865 100644 --- a/packages/shared/src/store/useChannelContext.ts +++ b/packages/shared/src/store/useChannelContext.ts @@ -19,12 +19,10 @@ export const useChannelContext = ({ // need to populate this from feature flags :( isChannelSwitcherEnabled: boolean; }) => { - // const storage = useStorageUnsafelyUnwrapped(); - - // Model context - const channelQuery = dbHooks.useChannelWithRelations({ + const channelQuery = dbHooks.useChannel({ id: channelId, }); + const groupQuery = dbHooks.useGroup({ id: channelQuery.data?.groupId ?? '', }); diff --git a/packages/ui/src/components/Channel/BaubleHeader.tsx b/packages/ui/src/components/Channel/BaubleHeader.tsx index 317dde5ea6..bfd260208e 100644 --- a/packages/ui/src/components/Channel/BaubleHeader.tsx +++ b/packages/ui/src/components/Channel/BaubleHeader.tsx @@ -1,7 +1,7 @@ import { LinearGradient } from '@tamagui/linear-gradient'; import * as db from '@tloncorp/shared/db'; import { BlurView } from 'expo-blur'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { OpaqueColorValue } from 'react-native'; import Animated, { Easing, @@ -21,7 +21,6 @@ import { Spinner, Text, View } from 'tamagui'; import { useChatOptions } from '../../contexts/chatOptions'; import { useScrollContext } from '../../contexts/scroll'; import { ContactAvatar } from '../Avatar'; -import { ChatOptionsSheet, ChatOptionsSheetMethods } from '../ChatOptionsSheet'; import { Icon } from '../Icon'; import { Image } from '../Image'; import Pressable from '../Pressable'; @@ -37,12 +36,10 @@ export function BaubleHeader({ showSpinner?: boolean; group?: db.Group | null; }) { + const chatOptions = useChatOptions(); const [scrollValue] = useScrollContext(); const insets = useSafeAreaInsets(); const frame = useSafeAreaFrame(); - const groupOptions = useChatOptions(); - const isGroupContext = !!group && !!groupOptions; - const chatOptionsSheetRef = useRef(null); const easedValue = useDerivedValue( () => Easing.ease(scrollValue.value), @@ -67,12 +64,12 @@ export function BaubleHeader({ }, [easedValue, insets.top]); const handlePress = useCallback(() => { - if (group && groupOptions) { - chatOptionsSheetRef.current?.open(group.id, 'group'); + if (group) { + chatOptions.open(group.id, 'group'); } else { - chatOptionsSheetRef.current?.open(channel.id, channel.type); + chatOptions.open(channel.id, 'channel'); } - }, [channel.id, channel.type, group, groupOptions]); + }, [channel.id, group, chatOptions]); return ( )} - {isGroupContext && groupOptions && ( - - )} ); } diff --git a/packages/ui/src/components/Channel/ChannelHeader.tsx b/packages/ui/src/components/Channel/ChannelHeader.tsx index 5c8cc1c846..1b36626f18 100644 --- a/packages/ui/src/components/Channel/ChannelHeader.tsx +++ b/packages/ui/src/components/Channel/ChannelHeader.tsx @@ -4,12 +4,11 @@ import { useCallback, useContext, useEffect, - useRef, useState, } from 'react'; -import useIsWindowNarrow from '../../hooks/useIsWindowNarrow'; -import { ChatOptionsSheet, ChatOptionsSheetMethods } from '../ChatOptionsSheet'; +import { useChatOptions } from '../../contexts'; +import Pressable from '../Pressable'; import { ScreenHeader } from '../ScreenHeader'; import { BaubleHeader } from './BaubleHeader'; @@ -80,6 +79,7 @@ export function ChannelHeader({ goBack, goToSearch, goToEdit, + goToChannels, showSpinner, showSearchButton = true, showMenuButton = false, @@ -92,17 +92,18 @@ export function ChannelHeader({ goBack?: () => void; goToSearch?: () => void; goToEdit?: () => void; + goToChannels?: () => void; showSpinner?: boolean; showSearchButton?: boolean; showMenuButton?: boolean; showEditButton?: boolean; post?: db.Post; }) { - const chatOptionsSheetRef = useRef(null); + const chatOptions = useChatOptions(); const handlePressOverflowMenu = useCallback(() => { - chatOptionsSheetRef.current?.open(channel.id, channel.type); - }, [channel.id, channel.type]); + chatOptions.open(channel.id, 'channel'); + }, [channel.id, chatOptions]); const contextItems = useContext(ChannelHeaderItemsContext)?.items ?? []; @@ -125,7 +126,11 @@ export function ChannelHeader({ return ( <> + {title} + + } titleWidth={titleWidth()} showSessionStatus isLoading={showSpinner} @@ -150,7 +155,6 @@ export function ChannelHeader({ } /> - ); } diff --git a/packages/ui/src/components/Channel/index.tsx b/packages/ui/src/components/Channel/index.tsx index ebc496fb47..aa4b3bf12c 100644 --- a/packages/ui/src/components/Channel/index.tsx +++ b/packages/ui/src/components/Channel/index.tsx @@ -337,6 +337,7 @@ export function Channel({ } showSearchButton={isChatChannel} goToSearch={goToSearch} + goToChannels={goToChannels} showSpinner={isLoadingPosts} showMenuButton={true} /> diff --git a/packages/ui/src/components/ChannelSwitcherSheet.tsx b/packages/ui/src/components/ChannelSwitcherSheet.tsx index 5f0147a325..f61989ed03 100644 --- a/packages/ui/src/components/ChannelSwitcherSheet.tsx +++ b/packages/ui/src/components/ChannelSwitcherSheet.tsx @@ -4,8 +4,10 @@ import { TouchableOpacity } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SizableText, Text, XStack } from 'tamagui'; +import { useChatOptions } from '../contexts'; import ChannelNavSections from './ChannelNavSections'; import { Icon } from './Icon'; +import Pressable from './Pressable'; import { Sheet } from './Sheet'; interface Props { @@ -24,29 +26,23 @@ export function ChannelSwitcherSheet({ onSelect, }: Props) { const [hasOpened, setHasOpened] = useState(open); - const [sortBy, setSortBy] = useState('recency'); + const chatOptions = useChatOptions(); + const sortBy = db.channelSortPreference.useValue(); const { bottom } = useSafeAreaInsets(); useEffect(() => { setHasOpened(open); }, [open]); - useEffect(() => { - const getSortByPreference = async () => { - const preference = await db.getChannelSortPreference(); - setSortBy(preference ?? 'recency'); - }; - - getSortByPreference(); - }, [setSortBy]); + const handleSortByToggled = useCallback(() => { + const newSortBy = sortBy === 'recency' ? 'arranged' : 'recency'; + db.channelSortPreference.setValue(newSortBy); + }, [sortBy]); - const handleSortByChanged = useCallback( - (newSortBy: 'recency' | 'arranged') => { - setSortBy(newSortBy); - db.storeChannelSortPreference(newSortBy); - }, - [] - ); + const handlePressSettings = useCallback(() => { + onOpenChange(false); + chatOptions.open(group.id, 'group'); + }, [chatOptions, group.id, onOpenChange]); return ( {group?.title} - { - const newSortBy = sortBy === 'recency' ? 'arranged' : 'recency'; - handleSortByChanged(newSortBy); - }} - > + + + + {hasOpened && ( void; - onLongPressItem?: (chat: db.Chat) => void; onSectionChange?: (title: string) => void; activeTab: TabName; setActiveTab: (tab: TabName) => void; @@ -72,6 +70,14 @@ export const ChatList = React.memo(function ChatListComponent({ [displayData] ); + const chatOptions = useChatOptions(); + const handleLongPress = useCallback( + (item: db.Chat) => { + chatOptions.open(item.id, item.type); + }, + [chatOptions] + ); + // removed the use of useStyle here because it was causing FlashList to // peg the CPU and freeze the app on web // see: https://github.com/Shopify/flash-list/pull/852 @@ -93,7 +99,7 @@ export const ChatList = React.memo(function ChatListComponent({ ); } else { @@ -101,12 +107,12 @@ export const ChatList = React.memo(function ChatListComponent({ ); } }, - [onPressItem, onLongPressItem] + [onPressItem, handleLongPress] ); const handlePressTryAll = useCallback(() => { diff --git a/packages/ui/src/components/ChatOptionsSheet.tsx b/packages/ui/src/components/ChatOptionsSheet.tsx index caf158f489..4d017b52b5 100644 --- a/packages/ui/src/components/ChatOptionsSheet.tsx +++ b/packages/ui/src/components/ChatOptionsSheet.tsx @@ -8,7 +8,6 @@ import React, { ReactElement, useCallback, useEffect, - useImperativeHandle, useMemo, useState, } from 'react'; @@ -23,42 +22,20 @@ import { Action, ActionGroup, ActionSheet } from './ActionSheet'; import { IconButton } from './IconButton'; import { ListItem } from './ListItem'; -export type ChatType = 'group' | db.ChannelType; - -export type ChatOptionsSheetMethods = { - open: (chatId: string, chatType: ChatType, unreadCount?: number) => void; -}; - -export type ChatOptionsSheetRef = React.Ref; - type ChatOptionsSheetProps = { - // We pass in setSortBy from GroupChannelsScreenView to live-update the sort - // preference in the channel list. - setSortBy?: (sortBy: db.ChannelSortPreference) => void; -}; - -const ChatOptionsSheetComponent = React.forwardRef< - ChatOptionsSheetMethods, - ChatOptionsSheetProps ->(function ChatOptionsSheetImpl(props, ref) { - const [open, setOpen] = useState(false); - const [chat, setChat] = useState<{ - type: ChatType; + open: boolean; + onOpenChange: (open: boolean) => void; + chat?: { + type: 'group' | 'channel'; id: string; - unreadCount?: number; - } | null>(null); - - useImperativeHandle( - ref, - () => ({ - open: (chatId, chatType, unreadCount) => { - setOpen(true); - setChat({ id: chatId, type: chatType, unreadCount }); - }, - }), - [] - ); + } | null; +}; +export const ChatOptionsSheet = React.memo(function ChatOptionsSheet({ + open, + onOpenChange, + chat, +}: ChatOptionsSheetProps) { if (!chat || !open) { return null; } @@ -68,9 +45,7 @@ const ChatOptionsSheetComponent = React.forwardRef< ); } @@ -79,25 +54,19 @@ const ChatOptionsSheetComponent = React.forwardRef< ); }); -export const ChatOptionsSheet = React.memo(ChatOptionsSheetComponent); - export function GroupOptionsSheetLoader({ groupId, open, onOpenChange, - setSortBy, - unreadCount, }: { groupId: string; open: boolean; onOpenChange: (open: boolean) => void; - setSortBy?: (sortBy: db.ChannelSortPreference) => void; - unreadCount?: number; }) { const groupQuery = store.useGroup({ id: groupId }); const [pane, setPane] = useState< @@ -119,9 +88,8 @@ export function GroupOptionsSheetLoader({ group={groupQuery.data} pane={pane} setPane={setPane} - setSortBy={setSortBy} onOpenChange={onOpenChange} - unreadCount={unreadCount} + unreadCount={groupQuery.data.unread?.count} /> ) : null; @@ -131,16 +99,14 @@ export function GroupOptions({ group, pane, setPane, - setSortBy, onOpenChange, unreadCount, }: { group: db.Group; pane: 'initial' | 'edit' | 'notifications' | 'sort'; setPane: (pane: 'initial' | 'edit' | 'notifications' | 'sort') => void; - setSortBy?: (sortBy: db.ChannelSortPreference) => void; onOpenChange: (open: boolean) => void; - unreadCount?: number; + unreadCount?: number | null; }) { const currentUser = useCurrentUserId(); const { data: currentVolumeLevel } = store.useGroupVolumeLevel(group.id); @@ -419,7 +385,6 @@ export function GroupOptions({ title: 'Sort by recency', action: () => { onSelectSort?.('recency'); - setSortBy?.('recency'); onOpenChange(false); }, }, @@ -427,14 +392,13 @@ export function GroupOptions({ title: 'Sort by arrangement', action: () => { onSelectSort?.('arranged'); - setSortBy?.('arranged'); onOpenChange(false); }, }, ], }, ]; - }, [onSelectSort, setSortBy, onOpenChange]); + }, [onSelectSort, onOpenChange]); const memberCount = group?.members?.length ? group.members.length.toLocaleString() @@ -504,7 +468,7 @@ export function ChannelOptionsSheetLoader({ onOpenChange: (open: boolean) => void; }) { const [pane, setPane] = useState<'initial' | 'notifications'>('initial'); - const channelQuery = store.useChannelWithRelations({ + const channelQuery = store.useChannel({ id: channelId, }); @@ -551,7 +515,6 @@ export function ChannelOptions({ onPressChannelMeta, onPressManageChannels, onPressInvite, - onPressLeave, } = useChatOptions() ?? {}; const currentUserIsHost = useMemo( diff --git a/packages/ui/src/components/GroupChannelsScreenView.tsx b/packages/ui/src/components/GroupChannelsScreenView.tsx index 596b91fbdc..7b61f2102a 100644 --- a/packages/ui/src/components/GroupChannelsScreenView.tsx +++ b/packages/ui/src/components/GroupChannelsScreenView.tsx @@ -1,20 +1,12 @@ import * as db from '@tloncorp/shared/db'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - Button, - ScrollView, - View, - YStack, - getVariableValue, - useTheme, -} from 'tamagui'; +import { ScrollView, View, YStack, getVariableValue, useTheme } from 'tamagui'; -import { useCurrentUserId } from '../contexts'; +import { useChatOptions, useCurrentUserId } from '../contexts'; import { useIsAdmin } from '../utils/channelUtils'; import { Badge } from './Badge'; import ChannelNavSections from './ChannelNavSections'; -import { ChatOptionsSheet, ChatOptionsSheetMethods } from './ChatOptionsSheet'; import { ChannelListItem } from './ListItem/ChannelListItem'; import { LoadingSpinner } from './LoadingSpinner'; import { CreateChannelSheet } from './ManageChannels/CreateChannelSheet'; @@ -38,39 +30,30 @@ export function GroupChannelsScreenView({ onBackPressed, enableCustomChannels = false, }: GroupChannelsScreenViewProps) { - const chatOptionsSheetRef = useRef(null); const [showCreateChannel, setShowCreateChannel] = useState(false); - const [sortBy, setSortBy] = useState('recency'); + const sortBy = db.channelSortPreference.useValue(); const insets = useSafeAreaInsets(); const userId = useCurrentUserId(); const isGroupAdmin = useIsAdmin(group?.id ?? '', userId); - useEffect(() => { - const getSortByPreference = async () => { - const preference = await db.getChannelSortPreference(); - setSortBy(preference ?? 'recency'); - }; - - getSortByPreference(); - }, [setSortBy]); - + const chatOptions = useChatOptions(); const handlePressOverflowButton = useCallback(() => { if (group) { - chatOptionsSheetRef.current?.open(group.id, 'group'); + chatOptions.open(group.id, 'group'); } - }, [group]); - - const title = group ? group?.title ?? 'Untitled' : ''; + }, [group, chatOptions]); const handleOpenChannelOptions = useCallback( (channel: db.Channel) => { if (group) { - chatOptionsSheetRef.current?.open(channel.id, channel.type); + chatOptions.open(channel.id, 'channel'); } }, - [group] + [group, chatOptions] ); + const title = group ? group?.title ?? 'Untitled' : ''; + const titleWidth = useCallback(() => { if (isGroupAdmin) { return 55; @@ -165,7 +148,6 @@ export function GroupChannelsScreenView({ enableCustomChannels={enableCustomChannels} /> )} - ); } diff --git a/packages/ui/src/components/Pressable.tsx b/packages/ui/src/components/Pressable.tsx index c2386575fd..54ccf64cf8 100644 --- a/packages/ui/src/components/Pressable.tsx +++ b/packages/ui/src/components/Pressable.tsx @@ -23,7 +23,7 @@ const StackComponent = ({ return ( void; onPressGroupMembers: (groupId: string) => void; @@ -24,19 +26,22 @@ export type ChatOptionsContextValue = { onTogglePinned: () => void; onPressLeave: () => Promise; onSelectSort?: (sortBy: 'recency' | 'arranged') => void; + open: (chatId: string, chatType: 'group' | 'channel') => void; } | null; const ChatOptionsContext = createContext(null); export const useChatOptions = () => { - return useContext(ChatOptionsContext); + const value = useContext(ChatOptionsContext); + if (!value) { + throw new Error('useChatOptions used outside of ChatOptions context'); + } + return value; }; type ChatOptionsProviderProps = { children: ReactNode; - groupId?: string; - channelId?: string; - pinned: db.Pin[]; + useChannel?: typeof store.useChannel; useGroup?: typeof store.useGroup; onPressGroupMeta: (groupId: string) => void; onPressGroupMembers: (groupId: string) => void; @@ -47,13 +52,12 @@ type ChatOptionsProviderProps = { onPressChannelMeta: (channelId: string) => void; onPressRoles: (groupId: string) => void; onSelectSort?: (sortBy: 'recency' | 'arranged') => void; - navigateOnLeave?: () => void; + onLeaveGroup?: () => void; }; export const ChatOptionsProvider = ({ children, - groupId, - pinned = [], + useChannel = store.useChannel, useGroup = store.useGroup, onPressGroupMeta, onPressGroupMembers, @@ -63,17 +67,30 @@ export const ChatOptionsProvider = ({ onPressChannelMembers, onPressChannelMeta, onPressRoles, - navigateOnLeave, + onLeaveGroup: navigateOnLeave, }: ChatOptionsProviderProps) => { - const groupQuery = useGroup({ id: groupId ?? '' }); - const group = groupId ? groupQuery.data ?? null : null; + const [sheetOpen, setSheetOpen] = useState(false); + const [chat, setChat] = useState<{ + id: string; + type: 'group' | 'channel'; + } | null>(null); + + const isChannel = chat?.type === 'channel'; + const isGroup = chat?.type === 'group'; + + const { data: channel } = useChannel({ + id: isChannel ? chat.id : undefined, + }); + const { data: group } = useGroup({ + id: isGroup ? chat.id : channel?.groupId ?? undefined, + }); const groupChannels = useMemo(() => { return group?.channels ?? []; }, [group?.channels]); const onTogglePinned = useCallback(() => { - if (group && group.channels[0]) { + if (group && group.channels?.[0]) { group.pin ? store.unpinItem(group.pin) : store.pinGroup(group); } }, [group]); @@ -86,30 +103,62 @@ export const ChatOptionsProvider = ({ }, [group, navigateOnLeave]); const onSelectSort = useCallback((sortBy: 'recency' | 'arranged') => { - db.storeChannelSortPreference(sortBy); + db.channelSortPreference.setValue(sortBy); }, []); - const contextValue: ChatOptionsContextValue = { - pinned, - useGroup, - group, - groupChannels, - onPressGroupMeta, - onPressGroupMembers, - onPressManageChannels, - onPressInvite, - onPressGroupPrivacy, - onPressRoles, - onPressLeave, - onTogglePinned, - onPressChannelMembers, - onPressChannelMeta, - onSelectSort, - }; + const open = useCallback((chatId: string, chatType: 'group' | 'channel') => { + setChat({ + id: chatId, + type: chatType, + }); + setSheetOpen(true); + }, []); + + const contextValue: ChatOptionsContextValue = useMemo( + () => ({ + useGroup, + group, + groupChannels, + onPressGroupMeta, + onPressGroupMembers, + onPressManageChannels, + onPressInvite, + onPressGroupPrivacy, + onPressRoles, + onPressLeave, + onTogglePinned, + onPressChannelMembers, + onPressChannelMeta, + onSelectSort, + open, + }), + [ + group, + groupChannels, + onPressChannelMembers, + onPressChannelMeta, + onPressGroupMembers, + onPressGroupMeta, + onPressGroupPrivacy, + onPressInvite, + onPressLeave, + onPressManageChannels, + onPressRoles, + onSelectSort, + onTogglePinned, + open, + useGroup, + ] + ); return ( {children} + ); }; diff --git a/packages/ui/src/utils/channelUtils.tsx b/packages/ui/src/utils/channelUtils.tsx index dc72b2bbab..811758c391 100644 --- a/packages/ui/src/utils/channelUtils.tsx +++ b/packages/ui/src/utils/channelUtils.tsx @@ -4,7 +4,7 @@ import { useMemberRoles } from '@tloncorp/shared/store'; import { useMemo } from 'react'; import type { IconType } from '../components/Icon'; -import { useCalm } from '../contexts'; +import { useCalm } from '../contexts/appDataContext'; export function getChannelMemberName( member: db.ChatMember,