Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

[Clipclops] Add screen to view and send clip clops (#3754)

* 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

* [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

* nits

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Hailey
Samuel Newman
and committed by
GitHub
eb8bfd11 5d19f270

+1295 -7
+1
package.json
··· 168 168 "react-native-get-random-values": "~1.11.0", 169 169 "react-native-image-crop-picker": "^0.38.1", 170 170 "react-native-ios-context-menu": "^1.15.3", 171 + "react-native-keyboard-controller": "^1.11.7", 171 172 "react-native-pager-view": "6.2.3", 172 173 "react-native-picker-select": "^8.1.0", 173 174 "react-native-progress": "bluesky-social/react-native-progress",
+4 -1
src/App.native.tsx
··· 4 4 5 5 import React, {useEffect, useState} from 'react' 6 6 import {GestureHandlerRootView} from 'react-native-gesture-handler' 7 + import {KeyboardProvider} from 'react-native-keyboard-controller' 7 8 import {RootSiblingParent} from 'react-native-root-siblings' 8 9 import { 9 10 initialWindowMetrics, ··· 137 138 <LightboxStateProvider> 138 139 <I18nProvider> 139 140 <PortalProvider> 140 - <InnerApp /> 141 + <KeyboardProvider> 142 + <InnerApp /> 143 + </KeyboardProvider> 141 144 </PortalProvider> 142 145 </I18nProvider> 143 146 </LightboxStateProvider>
+65
src/screens/Messages/Conversation/MessageInput.tsx
··· 1 + import React from 'react' 2 + import {Pressable, TextInput, View} from 'react-native' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Text} from '#/components/Typography' 6 + 7 + export function MessageInput({ 8 + onSendMessage, 9 + onFocus, 10 + onBlur, 11 + }: { 12 + onSendMessage: (message: string) => void 13 + onFocus: () => void 14 + onBlur: () => void 15 + }) { 16 + const t = useTheme() 17 + const [message, setMessage] = React.useState('') 18 + 19 + const inputRef = React.useRef<TextInput>(null) 20 + 21 + const onSubmit = React.useCallback(() => { 22 + onSendMessage(message) 23 + setMessage('') 24 + setTimeout(() => { 25 + inputRef.current?.focus() 26 + }, 100) 27 + }, [message, onSendMessage]) 28 + 29 + return ( 30 + <View 31 + style={[ 32 + a.flex_row, 33 + a.py_sm, 34 + a.px_sm, 35 + a.rounded_full, 36 + a.mt_sm, 37 + t.atoms.bg_contrast_25, 38 + ]}> 39 + <TextInput 40 + accessibilityLabel="Text input field" 41 + accessibilityHint="Write a message" 42 + value={message} 43 + onChangeText={setMessage} 44 + placeholder="Write a message" 45 + style={[a.flex_1, a.text_sm, a.px_sm]} 46 + onSubmitEditing={onSubmit} 47 + onFocus={onFocus} 48 + onBlur={onBlur} 49 + placeholderTextColor={t.palette.contrast_500} 50 + ref={inputRef} 51 + /> 52 + <Pressable 53 + accessibilityRole="button" 54 + style={[ 55 + a.rounded_full, 56 + a.align_center, 57 + a.justify_center, 58 + {height: 30, width: 30, backgroundColor: t.palette.primary_500}, 59 + ]} 60 + onPress={onSubmit}> 61 + <Text style={a.text_md}>🐴</Text> 62 + </Pressable> 63 + </View> 64 + ) 65 + }
+29
src/screens/Messages/Conversation/MessageItem.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Text} from '#/components/Typography' 6 + import * as TempDmChatDefs from '#/temp/dm/defs' 7 + 8 + export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) { 9 + const t = useTheme() 10 + 11 + return ( 12 + <View 13 + style={[ 14 + a.py_sm, 15 + a.px_md, 16 + a.my_xs, 17 + a.rounded_md, 18 + { 19 + backgroundColor: t.palette.primary_500, 20 + maxWidth: '65%', 21 + borderRadius: 17, 22 + }, 23 + ]}> 24 + <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}> 25 + {item.text} 26 + </Text> 27 + </View> 28 + ) 29 + }
+193
src/screens/Messages/Conversation/MessagesList.tsx
··· 1 + import React, {useCallback, useMemo, useRef, useState} from 'react' 2 + import {Alert, FlatList, View, ViewToken} from 'react-native' 3 + import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 4 + 5 + import {isWeb} from 'platform/detection' 6 + import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' 7 + import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' 8 + import { 9 + useChat, 10 + useChatLogQuery, 11 + useSendMessageMutation, 12 + } from '#/screens/Messages/Temp/query/query' 13 + import {Loader} from '#/components/Loader' 14 + import {Text} from '#/components/Typography' 15 + import * as TempDmChatDefs from '#/temp/dm/defs' 16 + 17 + function MaybeLoader({isLoading}: {isLoading: boolean}) { 18 + return ( 19 + <View 20 + style={{ 21 + height: 50, 22 + width: '100%', 23 + alignItems: 'center', 24 + justifyContent: 'center', 25 + }}> 26 + {isLoading && <Loader size="xl" />} 27 + </View> 28 + ) 29 + } 30 + 31 + function renderItem({ 32 + item, 33 + }: { 34 + item: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage 35 + }) { 36 + if (TempDmChatDefs.isMessageView(item)) return <MessageItem item={item} /> 37 + 38 + if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text> 39 + 40 + return null 41 + } 42 + 43 + // TODO rm 44 + // TEMP: This is a temporary function to generate unique keys for mutation placeholders 45 + const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}` 46 + 47 + function onScrollToEndFailed() { 48 + // Placeholder function. You have to give FlatList something or else it will error. 49 + } 50 + 51 + export function MessagesList({chatId}: {chatId: string}) { 52 + const flatListRef = useRef<FlatList>(null) 53 + 54 + // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true 55 + // once the request for new posts starts. Then, we will change it back to false after the content size changes. 56 + const isFetching = useRef(false) 57 + 58 + // We use this to know if we should scroll after a new clop is added to the list 59 + const isAtBottom = useRef(false) 60 + 61 + // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the 62 + // total number of clops 63 + // TODO this needs to be set to whatever the initial number of messages is 64 + const totalMessages = useRef(10) 65 + 66 + // TODO later 67 + const [_, setShowSpinner] = useState(false) 68 + 69 + // Query Data 70 + const {data: chat} = useChat(chatId) 71 + const {mutate: sendMessage} = useSendMessageMutation(chatId) 72 + useChatLogQuery() 73 + 74 + const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => { 75 + return [ 76 + (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => { 77 + const firstVisibleIndex = info.viewableItems[0]?.index 78 + 79 + isAtBottom.current = Number(firstVisibleIndex) < 2 80 + }, 81 + { 82 + itemVisiblePercentThreshold: 50, 83 + minimumViewTime: 10, 84 + }, 85 + ] 86 + }, []) 87 + 88 + const onContentSizeChange = useCallback(() => { 89 + if (isAtBottom.current) { 90 + flatListRef.current?.scrollToOffset({offset: 0, animated: true}) 91 + } 92 + 93 + isFetching.current = false 94 + setShowSpinner(false) 95 + }, []) 96 + 97 + const onEndReached = useCallback(() => { 98 + if (isFetching.current) return 99 + isFetching.current = true 100 + setShowSpinner(true) 101 + 102 + // Eventually we will add more here when we hit the top through RQuery 103 + // We wouldn't actually use a timeout, but there would be a delay while loading 104 + setTimeout(() => { 105 + // Do something 106 + setShowSpinner(false) 107 + }, 1000) 108 + }, []) 109 + 110 + const onInputFocus = useCallback(() => { 111 + if (!isAtBottom.current) { 112 + flatListRef.current?.scrollToOffset({offset: 0, animated: true}) 113 + } 114 + }, []) 115 + 116 + const onSendMessage = useCallback( 117 + async (message: string) => { 118 + if (!message) return 119 + 120 + try { 121 + sendMessage({ 122 + message, 123 + tempId: generateUniqueKey(), 124 + }) 125 + } catch (e: any) { 126 + Alert.alert(e.toString()) 127 + } 128 + }, 129 + [sendMessage], 130 + ) 131 + 132 + const onInputBlur = useCallback(() => {}, []) 133 + 134 + const messages = useMemo(() => { 135 + if (!chat) return [] 136 + 137 + const filtered = chat.messages.filter( 138 + ( 139 + message, 140 + ): message is 141 + | TempDmChatDefs.MessageView 142 + | TempDmChatDefs.DeletedMessage => { 143 + return ( 144 + TempDmChatDefs.isMessageView(message) || 145 + TempDmChatDefs.isDeletedMessage(message) 146 + ) 147 + }, 148 + ) 149 + totalMessages.current = filtered.length 150 + }, [chat]) 151 + 152 + return ( 153 + <KeyboardAvoidingView 154 + style={{flex: 1, marginBottom: isWeb ? 20 : 85}} 155 + behavior="padding" 156 + keyboardVerticalOffset={70} 157 + contentContainerStyle={{flex: 1}}> 158 + <FlatList 159 + data={messages} 160 + keyExtractor={item => item.id} 161 + renderItem={renderItem} 162 + contentContainerStyle={{paddingHorizontal: 10}} 163 + // In the future, we might want to adjust this value. Not very concerning right now as long as we are only 164 + // dealing with text. But whenever we have images or other media and things are taller, we will want to lower 165 + // this...probably 166 + initialNumToRender={20} 167 + // Same with the max to render per batch. Let's be safe for now though. 168 + maxToRenderPerBatch={25} 169 + inverted={true} 170 + onEndReached={onEndReached} 171 + onScrollToIndexFailed={onScrollToEndFailed} 172 + onContentSizeChange={onContentSizeChange} 173 + onViewableItemsChanged={onViewableItemsChanged} 174 + viewabilityConfig={viewabilityConfig} 175 + maintainVisibleContentPosition={{ 176 + minIndexForVisible: 0, 177 + }} 178 + // This is actually a header since we are inverted! 179 + ListFooterComponent={<MaybeLoader isLoading={false} />} 180 + removeClippedSubviews={true} 181 + ref={flatListRef} 182 + keyboardDismissMode="none" 183 + /> 184 + <View style={{paddingHorizontal: 10}}> 185 + <MessageInput 186 + onSendMessage={onSendMessage} 187 + onFocus={onInputFocus} 188 + onBlur={onInputBlur} 189 + /> 190 + </View> 191 + </KeyboardAvoidingView> 192 + ) 193 + }
+6 -4
src/screens/Messages/Conversation/index.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 3 2 import {msg} from '@lingui/macro' 4 3 import {useLingui} from '@lingui/react' 5 4 import {NativeStackScreenProps} from '@react-navigation/native-stack' ··· 7 6 import {CommonNavigatorParams} from '#/lib/routes/types' 8 7 import {useGate} from '#/lib/statsig/statsig' 9 8 import {ViewHeader} from '#/view/com/util/ViewHeader' 9 + import {CenteredView} from 'view/com/util/Views' 10 + import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' 10 11 import {ClipClopGate} from '../gate' 11 12 12 13 type Props = NativeStackScreenProps< ··· 16 17 export function MessagesConversationScreen({route}: Props) { 17 18 const chatId = route.params.conversation 18 19 const {_} = useLingui() 19 - 20 20 const gate = useGate() 21 + 21 22 if (!gate('dms')) return <ClipClopGate /> 22 23 23 24 return ( 24 - <View> 25 + <CenteredView style={{flex: 1}} sideBorders> 25 26 <ViewHeader 26 27 title={_(msg`Chat with ${chatId}`)} 27 28 showOnDesktop 28 29 showBorder 29 30 /> 30 - </View> 31 + <MessagesList chatId={chatId} /> 32 + </CenteredView> 31 33 ) 32 34 }
+1 -1
src/screens/Messages/List/index.tsx
··· 111 111 renderItem={({item}) => { 112 112 return ( 113 113 <Link 114 - to={`/messages/${item.profile.handle}`} 114 + to={`/messages/3kqzb4mytxk2v`} 115 115 style={[a.flex_1, a.pl_md, a.py_sm, a.gap_md, a.pr_2xl]}> 116 116 <PreviewableUserAvatar profile={item.profile} size={44} /> 117 117 <View style={[a.flex_1]}>
+219
src/screens/Messages/Temp/query/query.ts
··· 1 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 2 + 3 + import {useSession} from 'state/session' 4 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 + import * as TempDmChatDefs from '#/temp/dm/defs' 6 + import * as TempDmChatGetChat from '#/temp/dm/getChat' 7 + import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' 8 + import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' 9 + 10 + /** 11 + * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏 12 + * (and do not try this at home) 13 + */ 14 + 15 + function createHeaders(did: string) { 16 + return { 17 + Authorization: did, 18 + } 19 + } 20 + 21 + type Chat = { 22 + chatId: string 23 + messages: TempDmChatGetChatMessages.OutputSchema['messages'] 24 + lastCursor?: string 25 + lastRev?: string 26 + } 27 + 28 + export function useChat(chatId: string) { 29 + const queryClient = useQueryClient() 30 + 31 + const {serviceUrl} = useDmServiceUrlStorage() 32 + const {currentAccount} = useSession() 33 + const did = currentAccount?.did ?? '' 34 + 35 + return useQuery({ 36 + queryKey: ['chat', chatId], 37 + queryFn: async () => { 38 + const currentChat = queryClient.getQueryData(['chat', chatId]) 39 + 40 + if (currentChat) { 41 + return currentChat as Chat 42 + } 43 + 44 + const messagesResponse = await fetch( 45 + `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`, 46 + { 47 + headers: createHeaders(did), 48 + }, 49 + ) 50 + 51 + if (!messagesResponse.ok) throw new Error('Failed to fetch messages') 52 + 53 + const messagesJson = 54 + (await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema 55 + 56 + const chatResponse = await fetch( 57 + `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, 58 + { 59 + headers: createHeaders(did), 60 + }, 61 + ) 62 + 63 + if (!chatResponse.ok) throw new Error('Failed to fetch chat') 64 + 65 + const chatJson = 66 + (await chatResponse.json()) as TempDmChatGetChat.OutputSchema 67 + 68 + const newChat = { 69 + chatId, 70 + messages: messagesJson.messages, 71 + lastCursor: messagesJson.cursor, 72 + lastRev: chatJson.chat.rev, 73 + } satisfies Chat 74 + 75 + queryClient.setQueryData(['chat', chatId], newChat) 76 + 77 + return newChat 78 + }, 79 + }) 80 + } 81 + 82 + interface SendMessageMutationVariables { 83 + message: string 84 + tempId: string 85 + } 86 + 87 + export function createTempId() { 88 + return Math.random().toString(36).substring(7).toString() 89 + } 90 + 91 + export function useSendMessageMutation(chatId: string) { 92 + const queryClient = useQueryClient() 93 + 94 + const {serviceUrl} = useDmServiceUrlStorage() 95 + const {currentAccount} = useSession() 96 + const did = currentAccount?.did ?? '' 97 + 98 + return useMutation< 99 + TempDmChatDefs.Message, 100 + Error, 101 + SendMessageMutationVariables, 102 + unknown 103 + >({ 104 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 105 + mutationFn: async ({message, tempId}) => { 106 + const response = await fetch( 107 + `${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`, 108 + { 109 + method: 'POST', 110 + headers: { 111 + ...createHeaders(did), 112 + 'Content-Type': 'application/json', 113 + }, 114 + body: JSON.stringify({ 115 + chatId, 116 + message: { 117 + text: message, 118 + }, 119 + }), 120 + }, 121 + ) 122 + 123 + if (!response.ok) throw new Error('Failed to send message') 124 + 125 + return response.json() 126 + }, 127 + onMutate: async variables => { 128 + queryClient.setQueryData(['chat', chatId], (prev: Chat) => { 129 + return { 130 + ...prev, 131 + messages: [ 132 + { 133 + id: variables.tempId, 134 + text: variables.message, 135 + }, 136 + ...prev.messages, 137 + ], 138 + } 139 + }) 140 + }, 141 + onSuccess: (result, variables) => { 142 + queryClient.setQueryData(['chat', chatId], (prev: Chat) => { 143 + return { 144 + ...prev, 145 + messages: prev.messages.map(m => 146 + m.id === variables.tempId 147 + ? { 148 + ...m, 149 + id: result.id, 150 + } 151 + : m, 152 + ), 153 + } 154 + }) 155 + }, 156 + onError: (_, variables) => { 157 + console.log(_) 158 + queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({ 159 + ...prev, 160 + messages: prev.messages.filter(m => m.id !== variables.tempId), 161 + })) 162 + }, 163 + }) 164 + } 165 + 166 + export function useChatLogQuery() { 167 + const queryClient = useQueryClient() 168 + 169 + const {serviceUrl} = useDmServiceUrlStorage() 170 + const {currentAccount} = useSession() 171 + const did = currentAccount?.did ?? '' 172 + 173 + return useQuery({ 174 + queryKey: ['chatLog'], 175 + queryFn: async () => { 176 + const prevLog = queryClient.getQueryData([ 177 + 'chatLog', 178 + ]) as TempDmChatGetChatLog.OutputSchema 179 + 180 + try { 181 + const response = await fetch( 182 + `${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${ 183 + prevLog?.cursor ?? '' 184 + }`, 185 + { 186 + headers: createHeaders(did), 187 + }, 188 + ) 189 + 190 + if (!response.ok) throw new Error('Failed to fetch chat log') 191 + 192 + const json = 193 + (await response.json()) as TempDmChatGetChatLog.OutputSchema 194 + 195 + for (const log of json.logs) { 196 + if (TempDmChatDefs.isLogDeleteMessage(log)) { 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 203 + 204 + return { 205 + ...prev, 206 + messages: [log.message, ...prev.messages], 207 + } 208 + }) 209 + } 210 + } 211 + 212 + return json 213 + } catch (e) { 214 + console.log(e) 215 + } 216 + }, 217 + refetchInterval: 5000, 218 + }) 219 + }
+64
src/screens/Messages/Temp/useDmServiceUrlStorage.tsx
··· 1 + import React from 'react' 2 + import {useAsyncStorage} from '@react-native-async-storage/async-storage' 3 + 4 + /** 5 + * TEMP: REMOVE BEFORE RELEASE 6 + * 7 + * Clip clop trivia: 8 + * 9 + * A little known fact about the term "clip clop" is that it may refer to a unit of time. It is unknown what the exact 10 + * length of a clip clop is, but it is generally agreed that it is approximately 9 minutes and 30 seconds, or 570 11 + * seconds. 12 + * 13 + * The term "clip clop" may also be used in other contexts, although it is unknown what all of these contexts may be. 14 + * Recently, the term has been used among many young adults to refer to a type of social media functionality, although 15 + * the exact nature of this functionality is also unknown. It is believed that the term may have originated from a 16 + * popular video game, but this has not been confirmed. 17 + * 18 + */ 19 + 20 + const DmServiceUrlStorageContext = React.createContext<{ 21 + serviceUrl: string 22 + setServiceUrl: (value: string) => void 23 + }>({ 24 + serviceUrl: '', 25 + setServiceUrl: () => {}, 26 + }) 27 + 28 + export const useDmServiceUrlStorage = () => 29 + React.useContext(DmServiceUrlStorageContext) 30 + 31 + export function DmServiceUrlProvider({children}: {children: React.ReactNode}) { 32 + const [serviceUrl, setServiceUrl] = React.useState<string>('') 33 + const {getItem, setItem: setItemInner} = useAsyncStorage('dmServiceUrl') 34 + 35 + React.useEffect(() => { 36 + ;(async () => { 37 + const v = await getItem() 38 + console.log(v) 39 + setServiceUrl(v ?? '') 40 + })() 41 + }, [getItem]) 42 + 43 + const setItem = React.useCallback( 44 + (v: string) => { 45 + setItemInner(v) 46 + setServiceUrl(v) 47 + }, 48 + [setItemInner], 49 + ) 50 + 51 + const value = React.useMemo( 52 + () => ({ 53 + serviceUrl, 54 + setServiceUrl: setItem, 55 + }), 56 + [serviceUrl, setItem], 57 + ) 58 + 59 + return ( 60 + <DmServiceUrlStorageContext.Provider value={value}> 61 + {children} 62 + </DmServiceUrlStorageContext.Provider> 63 + ) 64 + }
+4 -1
src/state/preferences/index.tsx
··· 1 1 import React from 'react' 2 2 3 + import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 3 4 import {Provider as AltTextRequiredProvider} from './alt-text-required' 4 5 import {Provider as AutoplayProvider} from './autoplay' 5 6 import {Provider as DisableHapticsProvider} from './disable-haptics' ··· 30 31 <HiddenPostsProvider> 31 32 <InAppBrowserProvider> 32 33 <DisableHapticsProvider> 33 - <AutoplayProvider>{children}</AutoplayProvider> 34 + <AutoplayProvider> 35 + <DmServiceUrlProvider>{children}</DmServiceUrlProvider> 36 + </AutoplayProvider> 34 37 </DisableHapticsProvider> 35 38 </InAppBrowserProvider> 36 39 </HiddenPostsProvider>
+195
src/temp/dm/defs.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyEmbedRecord, 4 + AppBskyRichtextFacet, 5 + } from '@atproto/api' 6 + import {ValidationResult} from '@atproto/lexicon' 7 + 8 + export interface Message { 9 + id?: string 10 + text: string 11 + /** Annotations of text (mentions, URLs, hashtags, etc) */ 12 + facets?: AppBskyRichtextFacet.Main[] 13 + embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} 14 + [k: string]: unknown 15 + } 16 + 17 + export function isMessage(v: unknown): v is Message { 18 + return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message' 19 + } 20 + 21 + export function validateMessage(v: unknown): ValidationResult { 22 + return { 23 + success: true, 24 + value: v, 25 + } 26 + } 27 + 28 + export interface MessageView { 29 + id: string 30 + rev: string 31 + text: string 32 + /** Annotations of text (mentions, URLs, hashtags, etc) */ 33 + facets?: AppBskyRichtextFacet.Main[] 34 + embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} 35 + sender?: MessageViewSender 36 + sentAt: string 37 + [k: string]: unknown 38 + } 39 + 40 + export function isMessageView(v: unknown): v is MessageView { 41 + return ( 42 + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView' 43 + ) 44 + } 45 + 46 + export function validateMessageView(v: unknown): ValidationResult { 47 + return { 48 + success: true, 49 + value: v, 50 + } 51 + } 52 + 53 + export interface DeletedMessage { 54 + id: string 55 + rev?: string 56 + sender?: MessageViewSender 57 + sentAt: string 58 + [k: string]: unknown 59 + } 60 + 61 + export function isDeletedMessage(v: unknown): v is DeletedMessage { 62 + return ( 63 + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage' 64 + ) 65 + } 66 + 67 + export function validateDeletedMessage(v: unknown): ValidationResult { 68 + return { 69 + success: true, 70 + value: v, 71 + } 72 + } 73 + 74 + export interface MessageViewSender { 75 + did: string 76 + [k: string]: unknown 77 + } 78 + 79 + export function isMessageViewSender(v: unknown): v is MessageViewSender { 80 + return ( 81 + isObj(v) && 82 + hasProp(v, '$type') && 83 + v.$type === 'temp.dm.defs#messageViewSender' 84 + ) 85 + } 86 + 87 + export function validateMessageViewSender(v: unknown): ValidationResult { 88 + return { 89 + success: true, 90 + value: v, 91 + } 92 + } 93 + 94 + export interface ChatView { 95 + id: string 96 + rev: string 97 + members: AppBskyActorDefs.ProfileViewBasic[] 98 + lastMessage?: 99 + | MessageView 100 + | DeletedMessage 101 + | {$type: string; [k: string]: unknown} 102 + unreadCount: number 103 + [k: string]: unknown 104 + } 105 + 106 + export function isChatView(v: unknown): v is ChatView { 107 + return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView' 108 + } 109 + 110 + export function validateChatView(v: unknown): ValidationResult { 111 + return { 112 + success: true, 113 + value: v, 114 + } 115 + } 116 + 117 + export type IncomingMessageSetting = 118 + | 'all' 119 + | 'none' 120 + | 'following' 121 + | (string & {}) 122 + 123 + export interface LogBeginChat { 124 + rev: string 125 + chatId: string 126 + [k: string]: unknown 127 + } 128 + 129 + export function isLogBeginChat(v: unknown): v is LogBeginChat { 130 + return ( 131 + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat' 132 + ) 133 + } 134 + 135 + export function validateLogBeginChat(v: unknown): ValidationResult { 136 + return { 137 + success: true, 138 + value: v, 139 + } 140 + } 141 + 142 + export interface LogCreateMessage { 143 + rev: string 144 + chatId: string 145 + message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} 146 + [k: string]: unknown 147 + } 148 + 149 + export function isLogCreateMessage(v: unknown): v is LogCreateMessage { 150 + return ( 151 + isObj(v) && 152 + hasProp(v, '$type') && 153 + v.$type === 'temp.dm.defs#logCreateMessage' 154 + ) 155 + } 156 + 157 + export function validateLogCreateMessage(v: unknown): ValidationResult { 158 + return { 159 + success: true, 160 + value: v, 161 + } 162 + } 163 + 164 + export interface LogDeleteMessage { 165 + rev: string 166 + chatId: string 167 + message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} 168 + [k: string]: unknown 169 + } 170 + 171 + export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage { 172 + return ( 173 + isObj(v) && 174 + hasProp(v, '$type') && 175 + v.$type === 'temp.dm.defs#logDeleteMessage' 176 + ) 177 + } 178 + 179 + export function validateLogDeleteMessage(v: unknown): ValidationResult { 180 + return { 181 + success: true, 182 + value: v, 183 + } 184 + } 185 + 186 + export function isObj(v: unknown): v is Record<string, unknown> { 187 + return typeof v === 'object' && v !== null 188 + } 189 + 190 + export function hasProp<K extends PropertyKey>( 191 + data: object, 192 + prop: K, 193 + ): data is Record<K, unknown> { 194 + return prop in data 195 + }
+31
src/temp/dm/deleteMessage.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams {} 6 + 7 + export interface InputSchema { 8 + chatId: string 9 + messageId: string 10 + [k: string]: unknown 11 + } 12 + 13 + export type OutputSchema = TempDmDefs.DeletedMessage 14 + 15 + export interface CallOptions { 16 + headers?: Headers 17 + qp?: QueryParams 18 + encoding: 'application/json' 19 + } 20 + 21 + export interface Response { 22 + success: boolean 23 + headers: Headers 24 + data: OutputSchema 25 + } 26 + 27 + export function toKnownErr(e: any) { 28 + if (e instanceof XRPCError) { 29 + } 30 + return e 31 + }
+30
src/temp/dm/getChat.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams { 6 + chatId: string 7 + } 8 + 9 + export type InputSchema = undefined 10 + 11 + export interface OutputSchema { 12 + chat: TempDmDefs.ChatView 13 + [k: string]: unknown 14 + } 15 + 16 + export interface CallOptions { 17 + headers?: Headers 18 + } 19 + 20 + export interface Response { 21 + success: boolean 22 + headers: Headers 23 + data: OutputSchema 24 + } 25 + 26 + export function toKnownErr(e: any) { 27 + if (e instanceof XRPCError) { 28 + } 29 + return e 30 + }
+30
src/temp/dm/getChatForMembers.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams { 6 + members: string[] 7 + } 8 + 9 + export type InputSchema = undefined 10 + 11 + export interface OutputSchema { 12 + chat: TempDmDefs.ChatView 13 + [k: string]: unknown 14 + } 15 + 16 + export interface CallOptions { 17 + headers?: Headers 18 + } 19 + 20 + export interface Response { 21 + success: boolean 22 + headers: Headers 23 + data: OutputSchema 24 + } 25 + 26 + export function toKnownErr(e: any) { 27 + if (e instanceof XRPCError) { 28 + } 29 + return e 30 + }
+36
src/temp/dm/getChatLog.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams { 6 + cursor?: string 7 + } 8 + 9 + export type InputSchema = undefined 10 + 11 + export interface OutputSchema { 12 + cursor?: string 13 + logs: ( 14 + | TempDmDefs.LogBeginChat 15 + | TempDmDefs.LogCreateMessage 16 + | TempDmDefs.LogDeleteMessage 17 + | {$type: string; [k: string]: unknown} 18 + )[] 19 + [k: string]: unknown 20 + } 21 + 22 + export interface CallOptions { 23 + headers?: Headers 24 + } 25 + 26 + export interface Response { 27 + success: boolean 28 + headers: Headers 29 + data: OutputSchema 30 + } 31 + 32 + export function toKnownErr(e: any) { 33 + if (e instanceof XRPCError) { 34 + } 35 + return e 36 + }
+37
src/temp/dm/getChatMessages.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams { 6 + chatId: string 7 + limit?: number 8 + cursor?: string 9 + } 10 + 11 + export type InputSchema = undefined 12 + 13 + export interface OutputSchema { 14 + cursor?: string 15 + messages: ( 16 + | TempDmDefs.MessageView 17 + | TempDmDefs.DeletedMessage 18 + | {$type: string; [k: string]: unknown} 19 + )[] 20 + [k: string]: unknown 21 + } 22 + 23 + export interface CallOptions { 24 + headers?: Headers 25 + } 26 + 27 + export interface Response { 28 + success: boolean 29 + headers: Headers 30 + data: OutputSchema 31 + } 32 + 33 + export function toKnownErr(e: any) { 34 + if (e instanceof XRPCError) { 35 + } 36 + return e 37 + }
+28
src/temp/dm/getUserSettings.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams {} 6 + 7 + export type InputSchema = undefined 8 + 9 + export interface OutputSchema { 10 + allowIncoming: TempDmDefs.IncomingMessageSetting 11 + [k: string]: unknown 12 + } 13 + 14 + export interface CallOptions { 15 + headers?: Headers 16 + } 17 + 18 + export interface Response { 19 + success: boolean 20 + headers: Headers 21 + data: OutputSchema 22 + } 23 + 24 + export function toKnownErr(e: any) { 25 + if (e instanceof XRPCError) { 26 + } 27 + return e 28 + }
+30
src/temp/dm/leaveChat.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + export interface QueryParams {} 4 + 5 + export interface InputSchema { 6 + chatId: string 7 + [k: string]: unknown 8 + } 9 + 10 + export interface OutputSchema { 11 + [k: string]: unknown 12 + } 13 + 14 + export interface CallOptions { 15 + headers?: Headers 16 + qp?: QueryParams 17 + encoding: 'application/json' 18 + } 19 + 20 + export interface Response { 21 + success: boolean 22 + headers: Headers 23 + data: OutputSchema 24 + } 25 + 26 + export function toKnownErr(e: any) { 27 + if (e instanceof XRPCError) { 28 + } 29 + return e 30 + }
+32
src/temp/dm/listChats.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams { 6 + limit?: number 7 + cursor?: string 8 + } 9 + 10 + export type InputSchema = undefined 11 + 12 + export interface OutputSchema { 13 + cursor?: string 14 + chats: TempDmDefs.ChatView[] 15 + [k: string]: unknown 16 + } 17 + 18 + export interface CallOptions { 19 + headers?: Headers 20 + } 21 + 22 + export interface Response { 23 + success: boolean 24 + headers: Headers 25 + data: OutputSchema 26 + } 27 + 28 + export function toKnownErr(e: any) { 29 + if (e instanceof XRPCError) { 30 + } 31 + return e 32 + }
+30
src/temp/dm/muteChat.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + export interface QueryParams {} 4 + 5 + export interface InputSchema { 6 + chatId: string 7 + [k: string]: unknown 8 + } 9 + 10 + export interface OutputSchema { 11 + [k: string]: unknown 12 + } 13 + 14 + export interface CallOptions { 15 + headers?: Headers 16 + qp?: QueryParams 17 + encoding: 'application/json' 18 + } 19 + 20 + export interface Response { 21 + success: boolean 22 + headers: Headers 23 + data: OutputSchema 24 + } 25 + 26 + export function toKnownErr(e: any) { 27 + if (e instanceof XRPCError) { 28 + } 29 + return e 30 + }
+31
src/temp/dm/sendMessage.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams {} 6 + 7 + export interface InputSchema { 8 + chatId: string 9 + message: TempDmDefs.Message 10 + [k: string]: unknown 11 + } 12 + 13 + export type OutputSchema = TempDmDefs.MessageView 14 + 15 + export interface CallOptions { 16 + headers?: Headers 17 + qp?: QueryParams 18 + encoding: 'application/json' 19 + } 20 + 21 + export interface Response { 22 + success: boolean 23 + headers: Headers 24 + data: OutputSchema 25 + } 26 + 27 + export function toKnownErr(e: any) { 28 + if (e instanceof XRPCError) { 29 + } 30 + return e 31 + }
+66
src/temp/dm/sendMessageBatch.ts
··· 1 + import {ValidationResult} from '@atproto/lexicon' 2 + import {Headers, XRPCError} from '@atproto/xrpc' 3 + 4 + import * as TempDmDefs from './defs' 5 + 6 + export interface QueryParams {} 7 + 8 + export interface InputSchema { 9 + items: BatchItem[] 10 + [k: string]: unknown 11 + } 12 + 13 + export interface OutputSchema { 14 + items: TempDmDefs.MessageView[] 15 + [k: string]: unknown 16 + } 17 + 18 + export interface CallOptions { 19 + headers?: Headers 20 + qp?: QueryParams 21 + encoding: 'application/json' 22 + } 23 + 24 + export interface Response { 25 + success: boolean 26 + headers: Headers 27 + data: OutputSchema 28 + } 29 + 30 + export function toKnownErr(e: any) { 31 + if (e instanceof XRPCError) { 32 + } 33 + return e 34 + } 35 + 36 + export interface BatchItem { 37 + chatId: string 38 + message: TempDmDefs.Message 39 + [k: string]: unknown 40 + } 41 + 42 + export function isBatchItem(v: unknown): v is BatchItem { 43 + return ( 44 + isObj(v) && 45 + hasProp(v, '$type') && 46 + v.$type === 'temp.dm.sendMessageBatch#batchItem' 47 + ) 48 + } 49 + 50 + export function validateBatchItem(v: unknown): ValidationResult { 51 + return { 52 + success: true, 53 + value: v, 54 + } 55 + } 56 + 57 + export function isObj(v: unknown): v is Record<string, unknown> { 58 + return typeof v === 'object' && v !== null 59 + } 60 + 61 + export function hasProp<K extends PropertyKey>( 62 + data: object, 63 + prop: K, 64 + ): data is Record<K, unknown> { 65 + return prop in data 66 + }
+30
src/temp/dm/unmuteChat.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + export interface QueryParams {} 4 + 5 + export interface InputSchema { 6 + chatId: string 7 + [k: string]: unknown 8 + } 9 + 10 + export interface OutputSchema { 11 + [k: string]: unknown 12 + } 13 + 14 + export interface CallOptions { 15 + headers?: Headers 16 + qp?: QueryParams 17 + encoding: 'application/json' 18 + } 19 + 20 + export interface Response { 21 + success: boolean 22 + headers: Headers 23 + data: OutputSchema 24 + } 25 + 26 + export function toKnownErr(e: any) { 27 + if (e instanceof XRPCError) { 28 + } 29 + return e 30 + }
+31
src/temp/dm/updateChatRead.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams {} 6 + 7 + export interface InputSchema { 8 + chatId: string 9 + messageId?: string 10 + [k: string]: unknown 11 + } 12 + 13 + export type OutputSchema = TempDmDefs.ChatView 14 + 15 + export interface CallOptions { 16 + headers?: Headers 17 + qp?: QueryParams 18 + encoding: 'application/json' 19 + } 20 + 21 + export interface Response { 22 + success: boolean 23 + headers: Headers 24 + data: OutputSchema 25 + } 26 + 27 + export function toKnownErr(e: any) { 28 + if (e instanceof XRPCError) { 29 + } 30 + return e 31 + }
+33
src/temp/dm/updateUserSettings.ts
··· 1 + import {Headers, XRPCError} from '@atproto/xrpc' 2 + 3 + import * as TempDmDefs from './defs' 4 + 5 + export interface QueryParams {} 6 + 7 + export interface InputSchema { 8 + allowIncoming?: TempDmDefs.IncomingMessageSetting 9 + [k: string]: unknown 10 + } 11 + 12 + export interface OutputSchema { 13 + allowIncoming: TempDmDefs.IncomingMessageSetting 14 + [k: string]: unknown 15 + } 16 + 17 + export interface CallOptions { 18 + headers?: Headers 19 + qp?: QueryParams 20 + encoding: 'application/json' 21 + } 22 + 23 + export interface Response { 24 + success: boolean 25 + headers: Headers 26 + data: OutputSchema 27 + } 28 + 29 + export function toKnownErr(e: any) { 30 + if (e instanceof XRPCError) { 31 + } 32 + return e 33 + }
+24
src/view/screens/Settings/index.tsx
··· 51 51 import {makeProfileLink} from 'lib/routes/links' 52 52 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 53 53 import {NavigationProp} from 'lib/routes/types' 54 + import {useGate} from 'lib/statsig/statsig' 54 55 import {colors, s} from 'lib/styles' 55 56 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' 56 57 import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' ··· 61 62 import * as Toast from 'view/com/util/Toast' 62 63 import {UserAvatar} from 'view/com/util/UserAvatar' 63 64 import {ScrollView} from 'view/com/util/Views' 65 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 64 66 import {useDialogControl} from '#/components/Dialog' 65 67 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 68 + import * as TextField from '#/components/forms/TextField' 66 69 import {navigate, resetToTab} from '#/Navigation' 67 70 import {Email2FAToggle} from './Email2FAToggle' 68 71 import {ExportCarDialog} from './ExportCarDialog' ··· 168 171 const closeAllActiveElements = useCloseAllActiveElements() 169 172 const exportCarControl = useDialogControl() 170 173 const birthdayControl = useDialogControl() 174 + 175 + // TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED 176 + const gate = useGate() 177 + const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} = 178 + useDmServiceUrlStorage() 171 179 172 180 // const primaryBg = useCustomPalette<ViewStyle>({ 173 181 // light: {backgroundColor: colors.blue0}, ··· 778 786 <Trans>System log</Trans> 779 787 </Text> 780 788 </TouchableOpacity> 789 + {gate('dms') && ( 790 + <TextField.Root> 791 + <TextField.Input 792 + value={dmServiceUrl} 793 + onChangeText={(text: string) => { 794 + if (text.endsWith('/')) { 795 + text = text.slice(0, -1) 796 + } 797 + setDmServiceUrl(text) 798 + }} 799 + autoCapitalize="none" 800 + keyboardType="url" 801 + label="🐴" 802 + /> 803 + </TextField.Root> 804 + )} 781 805 {__DEV__ ? ( 782 806 <> 783 807 <TouchableOpacity
+10
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 122 122 ) 123 123 }} 124 124 </NavItem> 125 + <NavItem routeName="Messages" href="/messages"> 126 + {() => { 127 + return ( 128 + <Envelope 129 + size="lg" 130 + style={[styles.ctrlIcon, pal.text, styles.messagesIcon]} 131 + /> 132 + ) 133 + }} 134 + </NavItem> 125 135 {gate('dms') && ( 126 136 <NavItem routeName="Messages" href="/messages"> 127 137 {({isActive}) => {
+5
yarn.lock
··· 18695 18695 dependencies: 18696 18696 "@dominicstop/ts-event-emitter" "^1.1.0" 18697 18697 18698 + react-native-keyboard-controller@^1.11.7: 18699 + version "1.11.7" 18700 + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.7.tgz#85640374e4c3627c3b667256a1d308698ff80393" 18701 + integrity sha512-K2zlqVyWX4QO7r+dHMQgZT41G2dSEWtDYgBdht1WVyTaMQmwTMalZcHCWBVOnzyGaJq/hMKhF1kSPqJP1xqSFA== 18702 + 18698 18703 react-native-pager-view@6.2.3: 18699 18704 version "6.2.3" 18700 18705 resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"