diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Error.tsx | 26 | ||||
-rw-r--r-- | src/components/Lists.tsx | 4 | ||||
-rw-r--r-- | src/components/dms/NewChat.tsx | 233 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessageItem.tsx | 12 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 9 | ||||
-rw-r--r-- | src/screens/Messages/List/index.tsx | 43 | ||||
-rw-r--r-- | src/screens/Messages/Temp/query/query.ts | 79 | ||||
-rw-r--r-- | src/view/screens/Settings/index.tsx | 2 |
8 files changed, 352 insertions, 56 deletions
diff --git a/src/components/Error.tsx b/src/components/Error.tsx index bf689fc07..ee479cca9 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -17,12 +17,14 @@ export function Error({ message, onRetry, onGoBack: onGoBackProp, + hideBackButton, sideBorders = true, }: { title?: string message?: string onRetry?: () => unknown onGoBack?: () => unknown + hideBackButton?: boolean sideBorders?: boolean }) { const navigation = useNavigation<NavigationProp>() @@ -89,17 +91,19 @@ export function Error({ </ButtonText> </Button> )} - <Button - variant="solid" - color={onRetry ? 'secondary' : 'primary'} - label={_(msg`Return to previous page`)} - onPress={onGoBack} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> - <ButtonText> - <Trans>Go Back</Trans> - </ButtonText> - </Button> + {!hideBackButton && ( + <Button + variant="solid" + color={onRetry ? 'secondary' : 'primary'} + label={_(msg`Return to previous page`)} + onPress={onGoBack} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + )} </View> </CenteredView> ) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index b5419697b..721e877be 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({ emptyType = 'page', onRetry, onGoBack, + hideBackButton, sideBorders, }: { isLoading: boolean @@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({ emptyType?: 'page' | 'results' onRetry?: () => Promise<unknown> onGoBack?: () => void + hideBackButton?: boolean sideBorders?: boolean }): React.ReactNode => { const t = useTheme() @@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({ onRetry={onRetry} onGoBack={onGoBack} sideBorders={sideBorders} + hideBackButton={hideBackButton} /> ) } @@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({ } onRetry={onRetry} onGoBack={onGoBack} + hideBackButton={hideBackButton} sideBorders={sideBorders} /> ) diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx new file mode 100644 index 000000000..bbe118f04 --- /dev/null +++ b/src/components/dms/NewChat.tsx @@ -0,0 +1,233 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import {Keyboard, View} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/queries/preferences' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {FAB} from '#/view/com/util/fab/FAB' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query' +import {Button} from '../Button' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' +import {ListMaybePlaceholder} from '../Lists' +import {Text} from '../Typography' + +export function NewChat({onNewChat}: {onNewChat: (chatId: string) => void}) { + const control = Dialog.useDialogControl() + const t = useTheme() + const {_} = useLingui() + + const {mutate: createChat} = useGetChatFromMembers({ + onSuccess: data => { + onNewChat(data.chat.id) + }, + onError: error => { + Toast.show(error.message) + }, + }) + + const onCreateChat = useCallback( + (did: string) => { + control.close(() => createChat([did])) + }, + [control, createChat], + ) + + return ( + <> + <FAB + testID="newChatFAB" + onPress={control.open} + icon={<Envelope size="xl" fill={t.palette.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + <Dialog.Outer + control={control} + testID="newChatDialog" + nativeOptions={{sheet: {snapPoints: ['100%']}}}> + <Dialog.Handle /> + <SearchablePeopleList onCreateChat={onCreateChat} /> + </Dialog.Outer> + </> + ) +} + +function SearchablePeopleList({ + onCreateChat, +}: { + onCreateChat: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const listRef = useRef<BottomSheetFlatListMethods>(null) + + const [searchText, setSearchText] = useState('') + + const { + data: actorAutocompleteData, + isFetching, + isError, + refetch, + } = useActorAutocompleteQuery(searchText, true) + + const renderItem = useCallback( + ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => { + if (!moderationOpts) return null + const moderation = moderateProfile(profile, moderationOpts) + return ( + <Button + label={profile.displayName || sanitizeHandle(profile.handle)} + onPress={() => onCreateChat(profile.did)}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.px_md, + a.py_sm, + a.gap_md, + a.align_center, + a.flex_row, + a.rounded_sm, + pressed + ? t.atoms.bg_contrast_25 + : hovered + ? t.atoms.bg_contrast_50 + : t.atoms.bg, + ]}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + type={profile.associated?.labeler ? 'labeler' : 'user'} + /> + <View style={{flex: 1}}> + <Text + style={[t.atoms.text, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + <Text style={t.atoms.text_contrast_high} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + </View> + )} + </Button> + ) + }, + [ + moderationOpts, + onCreateChat, + t.atoms.bg_contrast_25, + t.atoms.bg_contrast_50, + t.atoms.bg, + t.atoms.text, + t.atoms.text_contrast_high, + ], + ) + + const listHeader = useMemo(() => { + return ( + <View style={[a.relative, a.mb_lg]}> + {/* cover top corners */} + <View + style={[ + a.absolute, + a.inset_0, + { + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + t.atoms.bg, + ]} + /> + <Dialog.Close /> + <Text + style={[ + a.text_2xl, + a.font_bold, + a.leading_tight, + a.pb_lg, + web(a.pt_lg), + ]}> + <Trans>Start a new chat</Trans> + </Text> + <TextField.Root> + <TextField.Icon icon={Search} /> + <TextField.Input + label={_(msg`Search profiles`)} + placeholder={_(msg`Search`)} + value={searchText} + onChangeText={text => { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + returnKeyType="search" + clearButtonMode="while-editing" + maxLength={50} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + control.close() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + /> + </TextField.Root> + </View> + ) + }, [t.atoms.bg, _, control, searchText]) + + return ( + <Dialog.InnerFlatList + ref={listRef} + data={actorAutocompleteData} + renderItem={renderItem} + ListHeaderComponent={ + <> + {listHeader} + {searchText.length > 0 && !actorAutocompleteData?.length && ( + <ListMaybePlaceholder + isLoading={isFetching} + isError={isError} + onRetry={refetch} + hideBackButton={true} + emptyType="results" + sideBorders={false} + emptyMessage={ + isError + ? _(msg`No search results found for "${searchText}".`) + : _(msg`Could not load profiles. Please try again later.`) + } + /> + )} + </> + } + stickyHeaderIndices={[0]} + keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did} + // @ts-expect-error web only + style={isWeb && {minHeight: '100vh'}} + onScrollBeginDrag={() => Keyboard.dismiss()} + /> + ) +} diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx index 74e65488e..822b17804 100644 --- a/src/screens/Messages/Conversation/MessageItem.tsx +++ b/src/screens/Messages/Conversation/MessageItem.tsx @@ -1,12 +1,16 @@ import React from 'react' import {View} from 'react-native' +import {useAgent} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import * as TempDmChatDefs from '#/temp/dm/defs' export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) { const t = useTheme() + const {getAgent} = useAgent() + + const fromMe = item.sender?.did === getAgent().session?.did return ( <View @@ -15,13 +19,17 @@ export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) { a.px_md, a.my_xs, a.rounded_md, + fromMe ? a.self_end : a.self_start, { - backgroundColor: t.palette.primary_500, + backgroundColor: fromMe + ? t.palette.primary_500 + : t.palette.contrast_50, maxWidth: '65%', borderRadius: 17, }, ]}> - <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}> + <Text + style={[a.text_md, a.leading_snug, fromMe && {color: t.palette.white}]}> {item.text} </Text> </View> diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index aafed42af..e3b518f65 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useRef, useState} from 'react' -import {Alert, FlatList, View, ViewToken} from 'react-native' +import {FlatList, View, ViewToken} from 'react-native' +import {Alert} from 'react-native' import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import {isWeb} from 'platform/detection' @@ -64,6 +65,7 @@ export function MessagesList({chatId}: {chatId: string}) { const totalMessages = useRef(10) // TODO later + const [_, setShowSpinner] = useState(false) // Query Data @@ -147,6 +149,8 @@ export function MessagesList({chatId}: {chatId: string}) { }, ) totalMessages.current = filtered.length + + return filtered }, [chat]) return ( @@ -162,7 +166,7 @@ export function MessagesList({chatId}: {chatId: string}) { contentContainerStyle={{paddingHorizontal: 10}} // In the future, we might want to adjust this value. Not very concerning right now as long as we are only // dealing with text. But whenever we have images or other media and things are taller, we will want to lower - // this...probably + // this...probably. initialNumToRender={20} // Same with the max to render per batch. Let's be safe for now though. maxToRenderPerBatch={25} @@ -175,7 +179,6 @@ export function MessagesList({chatId}: {chatId: string}) { maintainVisibleContentPosition={{ minIndexForVisible: 0, }} - // This is actually a header since we are inverted! ListFooterComponent={<MaybeLoader isLoading={false} />} removeClippedSubviews={true} ref={flatListRef} diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index b13ddd291..ff4e8e83e 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import React, {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -20,10 +20,11 @@ import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from ' import {Link} from '#/components/Link' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {Text} from '#/components/Typography' +import {NewChat} from '../../../components/dms/NewChat' import {ClipClopGate} from '../gate' type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> -export function MessagesListScreen({}: Props) { +export function MessagesListScreen({navigation}: Props) { const {_} = useLingui() const t = useTheme() @@ -53,14 +54,14 @@ export function MessagesListScreen({}: Props) { const isError = !!error - const conversations = React.useMemo(() => { + const conversations = useMemo(() => { if (data?.pages) { return data.pages.flat() } return [] }, [data]) - const onRefresh = React.useCallback(async () => { + const onRefresh = useCallback(async () => { setIsPTRing(true) try { await refetch() @@ -70,7 +71,7 @@ export function MessagesListScreen({}: Props) { setIsPTRing(false) }, [refetch, setIsPTRing]) - const onEndReached = React.useCallback(async () => { + const onEndReached = useCallback(async () => { if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() @@ -79,26 +80,35 @@ export function MessagesListScreen({}: Props) { } }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + const onNewChat = useCallback( + (conversation: string) => + navigation.navigate('MessagesConversation', {conversation}), + [navigation], + ) + const gate = useGate() if (!gate('dms')) return <ClipClopGate /> if (conversations.length < 1) { return ( - <ListMaybePlaceholder - isLoading={isLoading} - isError={isError} - emptyType="results" - emptyMessage={_( - msg`You have no messages yet. Start a conversation with someone!`, - )} - errorMessage={cleanError(error)} - onRetry={isError ? refetch : undefined} - /> + <> + <ListMaybePlaceholder + isLoading={isLoading} + isError={isError} + emptyType="results" + emptyMessage={_( + msg`You have no messages yet. Start a conversation with someone!`, + )} + errorMessage={cleanError(error)} + onRetry={isError ? refetch : undefined} + /> + <NewChat onNewChat={onNewChat} /> + </> ) } return ( - <View> + <View style={a.flex_1}> <ViewHeader title={_(msg`Messages`)} showOnDesktop @@ -106,6 +116,7 @@ export function MessagesListScreen({}: Props) { showBorder canGoBack={false} /> + <NewChat onNewChat={onNewChat} /> <List data={conversations} renderItem={({item}) => { diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts index 2477dc569..26f9e625f 100644 --- a/src/screens/Messages/Temp/query/query.ts +++ b/src/screens/Messages/Temp/query/query.ts @@ -1,20 +1,24 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {useSession} from 'state/session' -import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useAgent} from '#/state/session' import * as TempDmChatDefs from '#/temp/dm/defs' import * as TempDmChatGetChat from '#/temp/dm/getChat' +import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers' import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' +import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage' /** * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏 * (and do not try this at home) */ -function createHeaders(did: string) { +const useHeaders = () => { + const {getAgent} = useAgent() return { - Authorization: did, + get Authorization() { + return getAgent().session!.did + }, } } @@ -27,10 +31,8 @@ type Chat = { export function useChat(chatId: string) { const queryClient = useQueryClient() - + const headers = useHeaders() const {serviceUrl} = useDmServiceUrlStorage() - const {currentAccount} = useSession() - const did = currentAccount?.did ?? '' return useQuery({ queryKey: ['chat', chatId], @@ -44,7 +46,7 @@ export function useChat(chatId: string) { const messagesResponse = await fetch( `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`, { - headers: createHeaders(did), + headers, }, ) @@ -56,7 +58,7 @@ export function useChat(chatId: string) { const chatResponse = await fetch( `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, { - headers: createHeaders(did), + headers, }, ) @@ -90,10 +92,8 @@ export function createTempId() { export function useSendMessageMutation(chatId: string) { const queryClient = useQueryClient() - + const headers = useHeaders() const {serviceUrl} = useDmServiceUrlStorage() - const {currentAccount} = useSession() - const did = currentAccount?.did ?? '' return useMutation< TempDmChatDefs.Message, @@ -108,7 +108,7 @@ export function useSendMessageMutation(chatId: string) { { method: 'POST', headers: { - ...createHeaders(did), + ...headers, 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -130,8 +130,10 @@ export function useSendMessageMutation(chatId: string) { ...prev, messages: [ { + $type: 'temp.dm.defs#messageView', id: variables.tempId, text: variables.message, + sender: {did: headers.Authorization}, // TODO a real DID get }, ...prev.messages, ], @@ -165,10 +167,8 @@ export function useSendMessageMutation(chatId: string) { export function useChatLogQuery() { const queryClient = useQueryClient() - + const headers = useHeaders() const {serviceUrl} = useDmServiceUrlStorage() - const {currentAccount} = useSession() - const did = currentAccount?.did ?? '' return useQuery({ queryKey: ['chatLog'], @@ -183,7 +183,7 @@ export function useChatLogQuery() { prevLog?.cursor ?? '' }`, { - headers: createHeaders(did), + headers, }, ) @@ -193,13 +193,10 @@ export function useChatLogQuery() { (await response.json()) as TempDmChatGetChatLog.OutputSchema for (const log of json.logs) { - if (TempDmChatDefs.isLogDeleteMessage(log)) { + if (TempDmChatDefs.isLogCreateMessage(log)) { queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => { - // What to do in this case - if (!prev) return - - // HACK we don't know who the creator of a message is, so just filter by id for now - if (prev.messages.find(m => m.id === log.message.id)) return prev + // TODO hack filter out duplicates + if (prev?.messages.find(m => m.id === log.message.id)) return return { ...prev, @@ -217,3 +214,39 @@ export function useChatLogQuery() { refetchInterval: 5000, }) } + +export function useGetChatFromMembers({ + onSuccess, + onError, +}: { + onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void + onError?: (error: Error) => void +}) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async (members: string[]) => { + const response = await fetch( + `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join( + ',', + )}`, + {headers}, + ) + + if (!response.ok) throw new Error('Failed to fetch chat') + + return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema + }, + onSuccess: data => { + queryClient.setQueryData(['chat', data.chat.id], { + chatId: data.chat.id, + messages: [], + lastRev: data.chat.rev, + }) + onSuccess?.(data) + }, + onError, + }) +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 470bace87..a0e4ff60f 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -791,7 +791,7 @@ export function SettingsScreen({}: Props) { <TextField.Input value={dmServiceUrl} onChangeText={(text: string) => { - if (text.endsWith('/')) { + if (text.length > 9 && text.endsWith('/')) { text = text.slice(0, -1) } setDmServiceUrl(text) |