Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Clipclops] New clipclop dialog (#3750)

* add new routes with placeholder screens

* add clops list

* add a clop input

* add some better padding to the clops

* some more adjustments

* add rnkc

* implement rnkc

* implement rnkc

* be a little less weird about it

* rename clop stuff

* rename more clop

* one more

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* replace hailey's types

* use codegen'd types in components

* add FAB

* new chat dialog

* error + default search term

* fix typo

* fix web styles

* optimistically set chat data

* use cursor instead of last rev

* [Clipclops] Temp codegenerated lexicon (#3749)

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* add clop service URL hook

* add dm service url storage

* use context

* use context for service url (temp)

* remove log

* cleanup merge

* fix merge error

* disable hack

* sender-based message styles

* temporary filter

* merge cleanup

* add `hideBackButton`

* rm unneeded return

* tried to be smart

* hide go back button

* use `searchActorTypeahead` instead

---------

Co-authored-by: Hailey <me@haileyok.com>

authored by

Samuel Newman
Hailey
and committed by
GitHub
bcd36780 2b7d796c

+352 -56
+15 -11
src/components/Error.tsx
··· 17 17 message, 18 18 onRetry, 19 19 onGoBack: onGoBackProp, 20 + hideBackButton, 20 21 sideBorders = true, 21 22 }: { 22 23 title?: string 23 24 message?: string 24 25 onRetry?: () => unknown 25 26 onGoBack?: () => unknown 27 + hideBackButton?: boolean 26 28 sideBorders?: boolean 27 29 }) { 28 30 const navigation = useNavigation<NavigationProp>() ··· 89 91 </ButtonText> 90 92 </Button> 91 93 )} 92 - <Button 93 - variant="solid" 94 - color={onRetry ? 'secondary' : 'primary'} 95 - label={_(msg`Return to previous page`)} 96 - onPress={onGoBack} 97 - size="large" 98 - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> 99 - <ButtonText> 100 - <Trans>Go Back</Trans> 101 - </ButtonText> 102 - </Button> 94 + {!hideBackButton && ( 95 + <Button 96 + variant="solid" 97 + color={onRetry ? 'secondary' : 'primary'} 98 + label={_(msg`Return to previous page`)} 99 + onPress={onGoBack} 100 + size="large" 101 + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> 102 + <ButtonText> 103 + <Trans>Go Back</Trans> 104 + </ButtonText> 105 + </Button> 106 + )} 103 107 </View> 104 108 </CenteredView> 105 109 )
+4
src/components/Lists.tsx
··· 134 134 emptyType = 'page', 135 135 onRetry, 136 136 onGoBack, 137 + hideBackButton, 137 138 sideBorders, 138 139 }: { 139 140 isLoading: boolean ··· 146 147 emptyType?: 'page' | 'results' 147 148 onRetry?: () => Promise<unknown> 148 149 onGoBack?: () => void 150 + hideBackButton?: boolean 149 151 sideBorders?: boolean 150 152 }): React.ReactNode => { 151 153 const t = useTheme() ··· 179 181 onRetry={onRetry} 180 182 onGoBack={onGoBack} 181 183 sideBorders={sideBorders} 184 + hideBackButton={hideBackButton} 182 185 /> 183 186 ) 184 187 } ··· 198 201 } 199 202 onRetry={onRetry} 200 203 onGoBack={onGoBack} 204 + hideBackButton={hideBackButton} 201 205 sideBorders={sideBorders} 202 206 /> 203 207 )
+233
src/components/dms/NewChat.tsx
··· 1 + import React, {useCallback, useMemo, useRef, useState} from 'react' 2 + import {Keyboard, View} from 'react-native' 3 + import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 + import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {isWeb} from '#/platform/detection' 11 + import {useModerationOpts} from '#/state/queries/preferences' 12 + import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' 13 + import {FAB} from '#/view/com/util/fab/FAB' 14 + import * as Toast from '#/view/com/util/Toast' 15 + import {UserAvatar} from '#/view/com/util/UserAvatar' 16 + import {atoms as a, useTheme, web} from '#/alf' 17 + import * as Dialog from '#/components/Dialog' 18 + import * as TextField from '#/components/forms/TextField' 19 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 20 + import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query' 21 + import {Button} from '../Button' 22 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' 23 + import {ListMaybePlaceholder} from '../Lists' 24 + import {Text} from '../Typography' 25 + 26 + export function NewChat({onNewChat}: {onNewChat: (chatId: string) => void}) { 27 + const control = Dialog.useDialogControl() 28 + const t = useTheme() 29 + const {_} = useLingui() 30 + 31 + const {mutate: createChat} = useGetChatFromMembers({ 32 + onSuccess: data => { 33 + onNewChat(data.chat.id) 34 + }, 35 + onError: error => { 36 + Toast.show(error.message) 37 + }, 38 + }) 39 + 40 + const onCreateChat = useCallback( 41 + (did: string) => { 42 + control.close(() => createChat([did])) 43 + }, 44 + [control, createChat], 45 + ) 46 + 47 + return ( 48 + <> 49 + <FAB 50 + testID="newChatFAB" 51 + onPress={control.open} 52 + icon={<Envelope size="xl" fill={t.palette.white} />} 53 + accessibilityRole="button" 54 + accessibilityLabel={_(msg`New chat`)} 55 + accessibilityHint="" 56 + /> 57 + 58 + <Dialog.Outer 59 + control={control} 60 + testID="newChatDialog" 61 + nativeOptions={{sheet: {snapPoints: ['100%']}}}> 62 + <Dialog.Handle /> 63 + <SearchablePeopleList onCreateChat={onCreateChat} /> 64 + </Dialog.Outer> 65 + </> 66 + ) 67 + } 68 + 69 + function SearchablePeopleList({ 70 + onCreateChat, 71 + }: { 72 + onCreateChat: (did: string) => void 73 + }) { 74 + const t = useTheme() 75 + const {_} = useLingui() 76 + const moderationOpts = useModerationOpts() 77 + const control = Dialog.useDialogContext() 78 + const listRef = useRef<BottomSheetFlatListMethods>(null) 79 + 80 + const [searchText, setSearchText] = useState('') 81 + 82 + const { 83 + data: actorAutocompleteData, 84 + isFetching, 85 + isError, 86 + refetch, 87 + } = useActorAutocompleteQuery(searchText, true) 88 + 89 + const renderItem = useCallback( 90 + ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => { 91 + if (!moderationOpts) return null 92 + const moderation = moderateProfile(profile, moderationOpts) 93 + return ( 94 + <Button 95 + label={profile.displayName || sanitizeHandle(profile.handle)} 96 + onPress={() => onCreateChat(profile.did)}> 97 + {({hovered, pressed}) => ( 98 + <View 99 + style={[ 100 + a.flex_1, 101 + a.px_md, 102 + a.py_sm, 103 + a.gap_md, 104 + a.align_center, 105 + a.flex_row, 106 + a.rounded_sm, 107 + pressed 108 + ? t.atoms.bg_contrast_25 109 + : hovered 110 + ? t.atoms.bg_contrast_50 111 + : t.atoms.bg, 112 + ]}> 113 + <UserAvatar 114 + size={40} 115 + avatar={profile.avatar} 116 + moderation={moderation.ui('avatar')} 117 + type={profile.associated?.labeler ? 'labeler' : 'user'} 118 + /> 119 + <View style={{flex: 1}}> 120 + <Text 121 + style={[t.atoms.text, a.font_bold, a.leading_snug]} 122 + numberOfLines={1}> 123 + {sanitizeDisplayName( 124 + profile.displayName || sanitizeHandle(profile.handle), 125 + moderation.ui('displayName'), 126 + )} 127 + </Text> 128 + <Text style={t.atoms.text_contrast_high} numberOfLines={1}> 129 + {sanitizeHandle(profile.handle, '@')} 130 + </Text> 131 + </View> 132 + </View> 133 + )} 134 + </Button> 135 + ) 136 + }, 137 + [ 138 + moderationOpts, 139 + onCreateChat, 140 + t.atoms.bg_contrast_25, 141 + t.atoms.bg_contrast_50, 142 + t.atoms.bg, 143 + t.atoms.text, 144 + t.atoms.text_contrast_high, 145 + ], 146 + ) 147 + 148 + const listHeader = useMemo(() => { 149 + return ( 150 + <View style={[a.relative, a.mb_lg]}> 151 + {/* cover top corners */} 152 + <View 153 + style={[ 154 + a.absolute, 155 + a.inset_0, 156 + { 157 + borderBottomLeftRadius: 8, 158 + borderBottomRightRadius: 8, 159 + }, 160 + t.atoms.bg, 161 + ]} 162 + /> 163 + <Dialog.Close /> 164 + <Text 165 + style={[ 166 + a.text_2xl, 167 + a.font_bold, 168 + a.leading_tight, 169 + a.pb_lg, 170 + web(a.pt_lg), 171 + ]}> 172 + <Trans>Start a new chat</Trans> 173 + </Text> 174 + <TextField.Root> 175 + <TextField.Icon icon={Search} /> 176 + <TextField.Input 177 + label={_(msg`Search profiles`)} 178 + placeholder={_(msg`Search`)} 179 + value={searchText} 180 + onChangeText={text => { 181 + setSearchText(text) 182 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 183 + }} 184 + returnKeyType="search" 185 + clearButtonMode="while-editing" 186 + maxLength={50} 187 + onKeyPress={({nativeEvent}) => { 188 + if (nativeEvent.key === 'Escape') { 189 + control.close() 190 + } 191 + }} 192 + autoCorrect={false} 193 + autoComplete="off" 194 + autoCapitalize="none" 195 + /> 196 + </TextField.Root> 197 + </View> 198 + ) 199 + }, [t.atoms.bg, _, control, searchText]) 200 + 201 + return ( 202 + <Dialog.InnerFlatList 203 + ref={listRef} 204 + data={actorAutocompleteData} 205 + renderItem={renderItem} 206 + ListHeaderComponent={ 207 + <> 208 + {listHeader} 209 + {searchText.length > 0 && !actorAutocompleteData?.length && ( 210 + <ListMaybePlaceholder 211 + isLoading={isFetching} 212 + isError={isError} 213 + onRetry={refetch} 214 + hideBackButton={true} 215 + emptyType="results" 216 + sideBorders={false} 217 + emptyMessage={ 218 + isError 219 + ? _(msg`No search results found for "${searchText}".`) 220 + : _(msg`Could not load profiles. Please try again later.`) 221 + } 222 + /> 223 + )} 224 + </> 225 + } 226 + stickyHeaderIndices={[0]} 227 + keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did} 228 + // @ts-expect-error web only 229 + style={isWeb && {minHeight: '100vh'}} 230 + onScrollBeginDrag={() => Keyboard.dismiss()} 231 + /> 232 + ) 233 + }
+10 -2
src/screens/Messages/Conversation/MessageItem.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 4 + import {useAgent} from '#/state/session' 4 5 import {atoms as a, useTheme} from '#/alf' 5 6 import {Text} from '#/components/Typography' 6 7 import * as TempDmChatDefs from '#/temp/dm/defs' 7 8 8 9 export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) { 9 10 const t = useTheme() 11 + const {getAgent} = useAgent() 12 + 13 + const fromMe = item.sender?.did === getAgent().session?.did 10 14 11 15 return ( 12 16 <View ··· 15 19 a.px_md, 16 20 a.my_xs, 17 21 a.rounded_md, 22 + fromMe ? a.self_end : a.self_start, 18 23 { 19 - backgroundColor: t.palette.primary_500, 24 + backgroundColor: fromMe 25 + ? t.palette.primary_500 26 + : t.palette.contrast_50, 20 27 maxWidth: '65%', 21 28 borderRadius: 17, 22 29 }, 23 30 ]}> 24 - <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}> 31 + <Text 32 + style={[a.text_md, a.leading_snug, fromMe && {color: t.palette.white}]}> 25 33 {item.text} 26 34 </Text> 27 35 </View>
+6 -3
src/screens/Messages/Conversation/MessagesList.tsx
··· 1 1 import React, {useCallback, useMemo, useRef, useState} from 'react' 2 - import {Alert, FlatList, View, ViewToken} from 'react-native' 2 + import {FlatList, View, ViewToken} from 'react-native' 3 + import {Alert} from 'react-native' 3 4 import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 4 5 5 6 import {isWeb} from 'platform/detection' ··· 64 65 const totalMessages = useRef(10) 65 66 66 67 // TODO later 68 + 67 69 const [_, setShowSpinner] = useState(false) 68 70 69 71 // Query Data ··· 147 149 }, 148 150 ) 149 151 totalMessages.current = filtered.length 152 + 153 + return filtered 150 154 }, [chat]) 151 155 152 156 return ( ··· 162 166 contentContainerStyle={{paddingHorizontal: 10}} 163 167 // In the future, we might want to adjust this value. Not very concerning right now as long as we are only 164 168 // dealing with text. But whenever we have images or other media and things are taller, we will want to lower 165 - // this...probably 169 + // this...probably. 166 170 initialNumToRender={20} 167 171 // Same with the max to render per batch. Let's be safe for now though. 168 172 maxToRenderPerBatch={25} ··· 175 179 maintainVisibleContentPosition={{ 176 180 minIndexForVisible: 0, 177 181 }} 178 - // This is actually a header since we are inverted! 179 182 ListFooterComponent={<MaybeLoader isLoading={false} />} 180 183 removeClippedSubviews={true} 181 184 ref={flatListRef}
+27 -16
src/screens/Messages/List/index.tsx
··· 1 - import React, {useCallback, useState} from 'react' 1 + import React, {useCallback, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' ··· 20 20 import {Link} from '#/components/Link' 21 21 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 22 22 import {Text} from '#/components/Typography' 23 + import {NewChat} from '../../../components/dms/NewChat' 23 24 import {ClipClopGate} from '../gate' 24 25 25 26 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> 26 - export function MessagesListScreen({}: Props) { 27 + export function MessagesListScreen({navigation}: Props) { 27 28 const {_} = useLingui() 28 29 const t = useTheme() 29 30 ··· 53 54 54 55 const isError = !!error 55 56 56 - const conversations = React.useMemo(() => { 57 + const conversations = useMemo(() => { 57 58 if (data?.pages) { 58 59 return data.pages.flat() 59 60 } 60 61 return [] 61 62 }, [data]) 62 63 63 - const onRefresh = React.useCallback(async () => { 64 + const onRefresh = useCallback(async () => { 64 65 setIsPTRing(true) 65 66 try { 66 67 await refetch() ··· 70 71 setIsPTRing(false) 71 72 }, [refetch, setIsPTRing]) 72 73 73 - const onEndReached = React.useCallback(async () => { 74 + const onEndReached = useCallback(async () => { 74 75 if (isFetchingNextPage || !hasNextPage || isError) return 75 76 try { 76 77 await fetchNextPage() ··· 79 80 } 80 81 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 81 82 83 + const onNewChat = useCallback( 84 + (conversation: string) => 85 + navigation.navigate('MessagesConversation', {conversation}), 86 + [navigation], 87 + ) 88 + 82 89 const gate = useGate() 83 90 if (!gate('dms')) return <ClipClopGate /> 84 91 85 92 if (conversations.length < 1) { 86 93 return ( 87 - <ListMaybePlaceholder 88 - isLoading={isLoading} 89 - isError={isError} 90 - emptyType="results" 91 - emptyMessage={_( 92 - msg`You have no messages yet. Start a conversation with someone!`, 93 - )} 94 - errorMessage={cleanError(error)} 95 - onRetry={isError ? refetch : undefined} 96 - /> 94 + <> 95 + <ListMaybePlaceholder 96 + isLoading={isLoading} 97 + isError={isError} 98 + emptyType="results" 99 + emptyMessage={_( 100 + msg`You have no messages yet. Start a conversation with someone!`, 101 + )} 102 + errorMessage={cleanError(error)} 103 + onRetry={isError ? refetch : undefined} 104 + /> 105 + <NewChat onNewChat={onNewChat} /> 106 + </> 97 107 ) 98 108 } 99 109 100 110 return ( 101 - <View> 111 + <View style={a.flex_1}> 102 112 <ViewHeader 103 113 title={_(msg`Messages`)} 104 114 showOnDesktop ··· 106 116 showBorder 107 117 canGoBack={false} 108 118 /> 119 + <NewChat onNewChat={onNewChat} /> 109 120 <List 110 121 data={conversations} 111 122 renderItem={({item}) => {
+56 -23
src/screens/Messages/Temp/query/query.ts
··· 1 1 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 2 2 3 - import {useSession} from 'state/session' 4 - import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 3 + import {useAgent} from '#/state/session' 5 4 import * as TempDmChatDefs from '#/temp/dm/defs' 6 5 import * as TempDmChatGetChat from '#/temp/dm/getChat' 6 + import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers' 7 7 import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' 8 8 import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' 9 + import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage' 9 10 10 11 /** 11 12 * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏 12 13 * (and do not try this at home) 13 14 */ 14 15 15 - function createHeaders(did: string) { 16 + const useHeaders = () => { 17 + const {getAgent} = useAgent() 16 18 return { 17 - Authorization: did, 19 + get Authorization() { 20 + return getAgent().session!.did 21 + }, 18 22 } 19 23 } 20 24 ··· 27 31 28 32 export function useChat(chatId: string) { 29 33 const queryClient = useQueryClient() 30 - 34 + const headers = useHeaders() 31 35 const {serviceUrl} = useDmServiceUrlStorage() 32 - const {currentAccount} = useSession() 33 - const did = currentAccount?.did ?? '' 34 36 35 37 return useQuery({ 36 38 queryKey: ['chat', chatId], ··· 44 46 const messagesResponse = await fetch( 45 47 `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`, 46 48 { 47 - headers: createHeaders(did), 49 + headers, 48 50 }, 49 51 ) 50 52 ··· 56 58 const chatResponse = await fetch( 57 59 `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, 58 60 { 59 - headers: createHeaders(did), 61 + headers, 60 62 }, 61 63 ) 62 64 ··· 90 92 91 93 export function useSendMessageMutation(chatId: string) { 92 94 const queryClient = useQueryClient() 93 - 95 + const headers = useHeaders() 94 96 const {serviceUrl} = useDmServiceUrlStorage() 95 - const {currentAccount} = useSession() 96 - const did = currentAccount?.did ?? '' 97 97 98 98 return useMutation< 99 99 TempDmChatDefs.Message, ··· 108 108 { 109 109 method: 'POST', 110 110 headers: { 111 - ...createHeaders(did), 111 + ...headers, 112 112 'Content-Type': 'application/json', 113 113 }, 114 114 body: JSON.stringify({ ··· 130 130 ...prev, 131 131 messages: [ 132 132 { 133 + $type: 'temp.dm.defs#messageView', 133 134 id: variables.tempId, 134 135 text: variables.message, 136 + sender: {did: headers.Authorization}, // TODO a real DID get 135 137 }, 136 138 ...prev.messages, 137 139 ], ··· 165 167 166 168 export function useChatLogQuery() { 167 169 const queryClient = useQueryClient() 168 - 170 + const headers = useHeaders() 169 171 const {serviceUrl} = useDmServiceUrlStorage() 170 - const {currentAccount} = useSession() 171 - const did = currentAccount?.did ?? '' 172 172 173 173 return useQuery({ 174 174 queryKey: ['chatLog'], ··· 183 183 prevLog?.cursor ?? '' 184 184 }`, 185 185 { 186 - headers: createHeaders(did), 186 + headers, 187 187 }, 188 188 ) 189 189 ··· 193 193 (await response.json()) as TempDmChatGetChatLog.OutputSchema 194 194 195 195 for (const log of json.logs) { 196 - if (TempDmChatDefs.isLogDeleteMessage(log)) { 196 + if (TempDmChatDefs.isLogCreateMessage(log)) { 197 197 queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => { 198 - // What to do in this case 199 - if (!prev) return 200 - 201 - // HACK we don't know who the creator of a message is, so just filter by id for now 202 - if (prev.messages.find(m => m.id === log.message.id)) return prev 198 + // TODO hack filter out duplicates 199 + if (prev?.messages.find(m => m.id === log.message.id)) return 203 200 204 201 return { 205 202 ...prev, ··· 217 214 refetchInterval: 5000, 218 215 }) 219 216 } 217 + 218 + export function useGetChatFromMembers({ 219 + onSuccess, 220 + onError, 221 + }: { 222 + onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void 223 + onError?: (error: Error) => void 224 + }) { 225 + const queryClient = useQueryClient() 226 + const headers = useHeaders() 227 + const {serviceUrl} = useDmServiceUrlStorage() 228 + 229 + return useMutation({ 230 + mutationFn: async (members: string[]) => { 231 + const response = await fetch( 232 + `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join( 233 + ',', 234 + )}`, 235 + {headers}, 236 + ) 237 + 238 + if (!response.ok) throw new Error('Failed to fetch chat') 239 + 240 + return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema 241 + }, 242 + onSuccess: data => { 243 + queryClient.setQueryData(['chat', data.chat.id], { 244 + chatId: data.chat.id, 245 + messages: [], 246 + lastRev: data.chat.rev, 247 + }) 248 + onSuccess?.(data) 249 + }, 250 + onError, 251 + }) 252 + }
+1 -1
src/view/screens/Settings/index.tsx
··· 791 791 <TextField.Input 792 792 value={dmServiceUrl} 793 793 onChangeText={(text: string) => { 794 - if (text.endsWith('/')) { 794 + if (text.length > 9 && text.endsWith('/')) { 795 795 text = text.slice(0, -1) 796 796 } 797 797 setDmServiceUrl(text)