diff options
Diffstat (limited to 'src')
26 files changed, 1289 insertions, 7 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index cf96781b7..4cb963fe8 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -4,6 +4,7 @@ import 'view/icons' import React, {useEffect, useState} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' +import {KeyboardProvider} from 'react-native-keyboard-controller' import {RootSiblingParent} from 'react-native-root-siblings' import { initialWindowMetrics, @@ -137,7 +138,9 @@ function App() { <LightboxStateProvider> <I18nProvider> <PortalProvider> - <InnerApp /> + <KeyboardProvider> + <InnerApp /> + </KeyboardProvider> </PortalProvider> </I18nProvider> </LightboxStateProvider> diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx new file mode 100644 index 000000000..bd73594ce --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import {Pressable, TextInput, View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function MessageInput({ + onSendMessage, + onFocus, + onBlur, +}: { + onSendMessage: (message: string) => void + onFocus: () => void + onBlur: () => void +}) { + const t = useTheme() + const [message, setMessage] = React.useState('') + + const inputRef = React.useRef<TextInput>(null) + + const onSubmit = React.useCallback(() => { + onSendMessage(message) + setMessage('') + setTimeout(() => { + inputRef.current?.focus() + }, 100) + }, [message, onSendMessage]) + + return ( + <View + style={[ + a.flex_row, + a.py_sm, + a.px_sm, + a.rounded_full, + a.mt_sm, + t.atoms.bg_contrast_25, + ]}> + <TextInput + accessibilityLabel="Text input field" + accessibilityHint="Write a message" + value={message} + onChangeText={setMessage} + placeholder="Write a message" + style={[a.flex_1, a.text_sm, a.px_sm]} + onSubmitEditing={onSubmit} + onFocus={onFocus} + onBlur={onBlur} + placeholderTextColor={t.palette.contrast_500} + ref={inputRef} + /> + <Pressable + accessibilityRole="button" + style={[ + a.rounded_full, + a.align_center, + a.justify_center, + {height: 30, width: 30, backgroundColor: t.palette.primary_500}, + ]} + onPress={onSubmit}> + <Text style={a.text_md}>🐴</Text> + </Pressable> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx new file mode 100644 index 000000000..74e65488e --- /dev/null +++ b/src/screens/Messages/Conversation/MessageItem.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import {View} from 'react-native' + +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() + + return ( + <View + style={[ + a.py_sm, + a.px_md, + a.my_xs, + a.rounded_md, + { + backgroundColor: t.palette.primary_500, + maxWidth: '65%', + borderRadius: 17, + }, + ]}> + <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}> + {item.text} + </Text> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx new file mode 100644 index 000000000..aafed42af --- /dev/null +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -0,0 +1,193 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import {Alert, FlatList, View, ViewToken} from 'react-native' +import {KeyboardAvoidingView} from 'react-native-keyboard-controller' + +import {isWeb} from 'platform/detection' +import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' +import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' +import { + useChat, + useChatLogQuery, + useSendMessageMutation, +} from '#/screens/Messages/Temp/query/query' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import * as TempDmChatDefs from '#/temp/dm/defs' + +function MaybeLoader({isLoading}: {isLoading: boolean}) { + return ( + <View + style={{ + height: 50, + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }}> + {isLoading && <Loader size="xl" />} + </View> + ) +} + +function renderItem({ + item, +}: { + item: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage +}) { + if (TempDmChatDefs.isMessageView(item)) return <MessageItem item={item} /> + + if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text> + + return null +} + +// TODO rm +// TEMP: This is a temporary function to generate unique keys for mutation placeholders +const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}` + +function onScrollToEndFailed() { + // Placeholder function. You have to give FlatList something or else it will error. +} + +export function MessagesList({chatId}: {chatId: string}) { + const flatListRef = useRef<FlatList>(null) + + // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true + // once the request for new posts starts. Then, we will change it back to false after the content size changes. + const isFetching = useRef(false) + + // We use this to know if we should scroll after a new clop is added to the list + const isAtBottom = useRef(false) + + // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the + // total number of clops + // TODO this needs to be set to whatever the initial number of messages is + const totalMessages = useRef(10) + + // TODO later + const [_, setShowSpinner] = useState(false) + + // Query Data + const {data: chat} = useChat(chatId) + const {mutate: sendMessage} = useSendMessageMutation(chatId) + useChatLogQuery() + + const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => { + return [ + (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => { + const firstVisibleIndex = info.viewableItems[0]?.index + + isAtBottom.current = Number(firstVisibleIndex) < 2 + }, + { + itemVisiblePercentThreshold: 50, + minimumViewTime: 10, + }, + ] + }, []) + + const onContentSizeChange = useCallback(() => { + if (isAtBottom.current) { + flatListRef.current?.scrollToOffset({offset: 0, animated: true}) + } + + isFetching.current = false + setShowSpinner(false) + }, []) + + const onEndReached = useCallback(() => { + if (isFetching.current) return + isFetching.current = true + setShowSpinner(true) + + // Eventually we will add more here when we hit the top through RQuery + // We wouldn't actually use a timeout, but there would be a delay while loading + setTimeout(() => { + // Do something + setShowSpinner(false) + }, 1000) + }, []) + + const onInputFocus = useCallback(() => { + if (!isAtBottom.current) { + flatListRef.current?.scrollToOffset({offset: 0, animated: true}) + } + }, []) + + const onSendMessage = useCallback( + async (message: string) => { + if (!message) return + + try { + sendMessage({ + message, + tempId: generateUniqueKey(), + }) + } catch (e: any) { + Alert.alert(e.toString()) + } + }, + [sendMessage], + ) + + const onInputBlur = useCallback(() => {}, []) + + const messages = useMemo(() => { + if (!chat) return [] + + const filtered = chat.messages.filter( + ( + message, + ): message is + | TempDmChatDefs.MessageView + | TempDmChatDefs.DeletedMessage => { + return ( + TempDmChatDefs.isMessageView(message) || + TempDmChatDefs.isDeletedMessage(message) + ) + }, + ) + totalMessages.current = filtered.length + }, [chat]) + + return ( + <KeyboardAvoidingView + style={{flex: 1, marginBottom: isWeb ? 20 : 85}} + behavior="padding" + keyboardVerticalOffset={70} + contentContainerStyle={{flex: 1}}> + <FlatList + data={messages} + keyExtractor={item => item.id} + renderItem={renderItem} + 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 + initialNumToRender={20} + // Same with the max to render per batch. Let's be safe for now though. + maxToRenderPerBatch={25} + inverted={true} + onEndReached={onEndReached} + onScrollToIndexFailed={onScrollToEndFailed} + onContentSizeChange={onContentSizeChange} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={viewabilityConfig} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} + // This is actually a header since we are inverted! + ListFooterComponent={<MaybeLoader isLoading={false} />} + removeClippedSubviews={true} + ref={flatListRef} + keyboardDismissMode="none" + /> + <View style={{paddingHorizontal: 10}}> + <MessageInput + onSendMessage={onSendMessage} + onFocus={onInputFocus} + onBlur={onInputBlur} + /> + </View> + </KeyboardAvoidingView> + ) +} diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 239425a2f..efa64f5f8 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' @@ -7,6 +6,8 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' import {ClipClopGate} from '../gate' type Props = NativeStackScreenProps< @@ -16,17 +17,18 @@ type Props = NativeStackScreenProps< export function MessagesConversationScreen({route}: Props) { const chatId = route.params.conversation const {_} = useLingui() - const gate = useGate() + if (!gate('dms')) return <ClipClopGate /> return ( - <View> + <CenteredView style={{flex: 1}} sideBorders> <ViewHeader title={_(msg`Chat with ${chatId}`)} showOnDesktop showBorder /> - </View> + <MessagesList chatId={chatId} /> + </CenteredView> ) } diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index c4490aa5c..b13ddd291 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -111,7 +111,7 @@ export function MessagesListScreen({}: Props) { renderItem={({item}) => { return ( <Link - to={`/messages/${item.profile.handle}`} + to={`/messages/3kqzb4mytxk2v`} style={[a.flex_1, a.pl_md, a.py_sm, a.gap_md, a.pr_2xl]}> <PreviewableUserAvatar profile={item.profile} size={44} /> <View style={[a.flex_1]}> diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts new file mode 100644 index 000000000..2477dc569 --- /dev/null +++ b/src/screens/Messages/Temp/query/query.ts @@ -0,0 +1,219 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {useSession} from 'state/session' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import * as TempDmChatDefs from '#/temp/dm/defs' +import * as TempDmChatGetChat from '#/temp/dm/getChat' +import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' +import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' + +/** + * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏 + * (and do not try this at home) + */ + +function createHeaders(did: string) { + return { + Authorization: did, + } +} + +type Chat = { + chatId: string + messages: TempDmChatGetChatMessages.OutputSchema['messages'] + lastCursor?: string + lastRev?: string +} + +export function useChat(chatId: string) { + const queryClient = useQueryClient() + + const {serviceUrl} = useDmServiceUrlStorage() + const {currentAccount} = useSession() + const did = currentAccount?.did ?? '' + + return useQuery({ + queryKey: ['chat', chatId], + queryFn: async () => { + const currentChat = queryClient.getQueryData(['chat', chatId]) + + if (currentChat) { + return currentChat as Chat + } + + const messagesResponse = await fetch( + `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`, + { + headers: createHeaders(did), + }, + ) + + if (!messagesResponse.ok) throw new Error('Failed to fetch messages') + + const messagesJson = + (await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema + + const chatResponse = await fetch( + `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`, + { + headers: createHeaders(did), + }, + ) + + if (!chatResponse.ok) throw new Error('Failed to fetch chat') + + const chatJson = + (await chatResponse.json()) as TempDmChatGetChat.OutputSchema + + const newChat = { + chatId, + messages: messagesJson.messages, + lastCursor: messagesJson.cursor, + lastRev: chatJson.chat.rev, + } satisfies Chat + + queryClient.setQueryData(['chat', chatId], newChat) + + return newChat + }, + }) +} + +interface SendMessageMutationVariables { + message: string + tempId: string +} + +export function createTempId() { + return Math.random().toString(36).substring(7).toString() +} + +export function useSendMessageMutation(chatId: string) { + const queryClient = useQueryClient() + + const {serviceUrl} = useDmServiceUrlStorage() + const {currentAccount} = useSession() + const did = currentAccount?.did ?? '' + + return useMutation< + TempDmChatDefs.Message, + Error, + SendMessageMutationVariables, + unknown + >({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mutationFn: async ({message, tempId}) => { + const response = await fetch( + `${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`, + { + method: 'POST', + headers: { + ...createHeaders(did), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chatId, + message: { + text: message, + }, + }), + }, + ) + + if (!response.ok) throw new Error('Failed to send message') + + return response.json() + }, + onMutate: async variables => { + queryClient.setQueryData(['chat', chatId], (prev: Chat) => { + return { + ...prev, + messages: [ + { + id: variables.tempId, + text: variables.message, + }, + ...prev.messages, + ], + } + }) + }, + onSuccess: (result, variables) => { + queryClient.setQueryData(['chat', chatId], (prev: Chat) => { + return { + ...prev, + messages: prev.messages.map(m => + m.id === variables.tempId + ? { + ...m, + id: result.id, + } + : m, + ), + } + }) + }, + onError: (_, variables) => { + console.log(_) + queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({ + ...prev, + messages: prev.messages.filter(m => m.id !== variables.tempId), + })) + }, + }) +} + +export function useChatLogQuery() { + const queryClient = useQueryClient() + + const {serviceUrl} = useDmServiceUrlStorage() + const {currentAccount} = useSession() + const did = currentAccount?.did ?? '' + + return useQuery({ + queryKey: ['chatLog'], + queryFn: async () => { + const prevLog = queryClient.getQueryData([ + 'chatLog', + ]) as TempDmChatGetChatLog.OutputSchema + + try { + const response = await fetch( + `${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${ + prevLog?.cursor ?? '' + }`, + { + headers: createHeaders(did), + }, + ) + + if (!response.ok) throw new Error('Failed to fetch chat log') + + const json = + (await response.json()) as TempDmChatGetChatLog.OutputSchema + + for (const log of json.logs) { + if (TempDmChatDefs.isLogDeleteMessage(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 + + return { + ...prev, + messages: [log.message, ...prev.messages], + } + }) + } + } + + return json + } catch (e) { + console.log(e) + } + }, + refetchInterval: 5000, + }) +} diff --git a/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx new file mode 100644 index 000000000..3679858f4 --- /dev/null +++ b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {useAsyncStorage} from '@react-native-async-storage/async-storage' + +/** + * TEMP: REMOVE BEFORE RELEASE + * + * Clip clop trivia: + * + * 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 + * length of a clip clop is, but it is generally agreed that it is approximately 9 minutes and 30 seconds, or 570 + * seconds. + * + * The term "clip clop" may also be used in other contexts, although it is unknown what all of these contexts may be. + * Recently, the term has been used among many young adults to refer to a type of social media functionality, although + * the exact nature of this functionality is also unknown. It is believed that the term may have originated from a + * popular video game, but this has not been confirmed. + * + */ + +const DmServiceUrlStorageContext = React.createContext<{ + serviceUrl: string + setServiceUrl: (value: string) => void +}>({ + serviceUrl: '', + setServiceUrl: () => {}, +}) + +export const useDmServiceUrlStorage = () => + React.useContext(DmServiceUrlStorageContext) + +export function DmServiceUrlProvider({children}: {children: React.ReactNode}) { + const [serviceUrl, setServiceUrl] = React.useState<string>('') + const {getItem, setItem: setItemInner} = useAsyncStorage('dmServiceUrl') + + React.useEffect(() => { + ;(async () => { + const v = await getItem() + console.log(v) + setServiceUrl(v ?? '') + })() + }, [getItem]) + + const setItem = React.useCallback( + (v: string) => { + setItemInner(v) + setServiceUrl(v) + }, + [setItemInner], + ) + + const value = React.useMemo( + () => ({ + serviceUrl, + setServiceUrl: setItem, + }), + [serviceUrl, setItem], + ) + + return ( + <DmServiceUrlStorageContext.Provider value={value}> + {children} + </DmServiceUrlStorageContext.Provider> + ) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 5c8fab2ad..820358518 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,5 +1,6 @@ import React from 'react' +import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' import {Provider as DisableHapticsProvider} from './disable-haptics' @@ -30,7 +31,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <HiddenPostsProvider> <InAppBrowserProvider> <DisableHapticsProvider> - <AutoplayProvider>{children}</AutoplayProvider> + <AutoplayProvider> + <DmServiceUrlProvider>{children}</DmServiceUrlProvider> + </AutoplayProvider> </DisableHapticsProvider> </InAppBrowserProvider> </HiddenPostsProvider> diff --git a/src/temp/dm/defs.ts b/src/temp/dm/defs.ts new file mode 100644 index 000000000..91f68365c --- /dev/null +++ b/src/temp/dm/defs.ts @@ -0,0 +1,195 @@ +import { + AppBskyActorDefs, + AppBskyEmbedRecord, + AppBskyRichtextFacet, +} from '@atproto/api' +import {ValidationResult} from '@atproto/lexicon' + +export interface Message { + id?: string + text: string + /** Annotations of text (mentions, URLs, hashtags, etc) */ + facets?: AppBskyRichtextFacet.Main[] + embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} + [k: string]: unknown +} + +export function isMessage(v: unknown): v is Message { + return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message' +} + +export function validateMessage(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface MessageView { + id: string + rev: string + text: string + /** Annotations of text (mentions, URLs, hashtags, etc) */ + facets?: AppBskyRichtextFacet.Main[] + embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown} + sender?: MessageViewSender + sentAt: string + [k: string]: unknown +} + +export function isMessageView(v: unknown): v is MessageView { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView' + ) +} + +export function validateMessageView(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface DeletedMessage { + id: string + rev?: string + sender?: MessageViewSender + sentAt: string + [k: string]: unknown +} + +export function isDeletedMessage(v: unknown): v is DeletedMessage { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage' + ) +} + +export function validateDeletedMessage(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface MessageViewSender { + did: string + [k: string]: unknown +} + +export function isMessageViewSender(v: unknown): v is MessageViewSender { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'temp.dm.defs#messageViewSender' + ) +} + +export function validateMessageViewSender(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface ChatView { + id: string + rev: string + members: AppBskyActorDefs.ProfileViewBasic[] + lastMessage?: + | MessageView + | DeletedMessage + | {$type: string; [k: string]: unknown} + unreadCount: number + [k: string]: unknown +} + +export function isChatView(v: unknown): v is ChatView { + return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView' +} + +export function validateChatView(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export type IncomingMessageSetting = + | 'all' + | 'none' + | 'following' + | (string & {}) + +export interface LogBeginChat { + rev: string + chatId: string + [k: string]: unknown +} + +export function isLogBeginChat(v: unknown): v is LogBeginChat { + return ( + isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat' + ) +} + +export function validateLogBeginChat(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface LogCreateMessage { + rev: string + chatId: string + message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} + [k: string]: unknown +} + +export function isLogCreateMessage(v: unknown): v is LogCreateMessage { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'temp.dm.defs#logCreateMessage' + ) +} + +export function validateLogCreateMessage(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export interface LogDeleteMessage { + rev: string + chatId: string + message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown} + [k: string]: unknown +} + +export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'temp.dm.defs#logDeleteMessage' + ) +} + +export function validateLogDeleteMessage(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export function isObj(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null +} + +export function hasProp<K extends PropertyKey>( + data: object, + prop: K, +): data is Record<K, unknown> { + return prop in data +} diff --git a/src/temp/dm/deleteMessage.ts b/src/temp/dm/deleteMessage.ts new file mode 100644 index 000000000..d9fa1f9cf --- /dev/null +++ b/src/temp/dm/deleteMessage.ts @@ -0,0 +1,31 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + messageId: string + [k: string]: unknown +} + +export type OutputSchema = TempDmDefs.DeletedMessage + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/getChat.ts b/src/temp/dm/getChat.ts new file mode 100644 index 000000000..d0a7b891c --- /dev/null +++ b/src/temp/dm/getChat.ts @@ -0,0 +1,30 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams { + chatId: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + chat: TempDmDefs.ChatView + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/getChatForMembers.ts b/src/temp/dm/getChatForMembers.ts new file mode 100644 index 000000000..0c9962c8b --- /dev/null +++ b/src/temp/dm/getChatForMembers.ts @@ -0,0 +1,30 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams { + members: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + chat: TempDmDefs.ChatView + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/getChatLog.ts b/src/temp/dm/getChatLog.ts new file mode 100644 index 000000000..9d310d908 --- /dev/null +++ b/src/temp/dm/getChatLog.ts @@ -0,0 +1,36 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams { + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + logs: ( + | TempDmDefs.LogBeginChat + | TempDmDefs.LogCreateMessage + | TempDmDefs.LogDeleteMessage + | {$type: string; [k: string]: unknown} + )[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/getChatMessages.ts b/src/temp/dm/getChatMessages.ts new file mode 100644 index 000000000..54ae21910 --- /dev/null +++ b/src/temp/dm/getChatMessages.ts @@ -0,0 +1,37 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams { + chatId: string + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + messages: ( + | TempDmDefs.MessageView + | TempDmDefs.DeletedMessage + | {$type: string; [k: string]: unknown} + )[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/getUserSettings.ts b/src/temp/dm/getUserSettings.ts new file mode 100644 index 000000000..792c697b4 --- /dev/null +++ b/src/temp/dm/getUserSettings.ts @@ -0,0 +1,28 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + allowIncoming: TempDmDefs.IncomingMessageSetting + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/leaveChat.ts b/src/temp/dm/leaveChat.ts new file mode 100644 index 000000000..e116f2775 --- /dev/null +++ b/src/temp/dm/leaveChat.ts @@ -0,0 +1,30 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + [k: string]: unknown +} + +export interface OutputSchema { + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/listChats.ts b/src/temp/dm/listChats.ts new file mode 100644 index 000000000..0f9cb0c6a --- /dev/null +++ b/src/temp/dm/listChats.ts @@ -0,0 +1,32 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams { + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + chats: TempDmDefs.ChatView[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/muteChat.ts b/src/temp/dm/muteChat.ts new file mode 100644 index 000000000..e116f2775 --- /dev/null +++ b/src/temp/dm/muteChat.ts @@ -0,0 +1,30 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + [k: string]: unknown +} + +export interface OutputSchema { + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/sendMessage.ts b/src/temp/dm/sendMessage.ts new file mode 100644 index 000000000..24a4cf733 --- /dev/null +++ b/src/temp/dm/sendMessage.ts @@ -0,0 +1,31 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + message: TempDmDefs.Message + [k: string]: unknown +} + +export type OutputSchema = TempDmDefs.MessageView + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/sendMessageBatch.ts b/src/temp/dm/sendMessageBatch.ts new file mode 100644 index 000000000..c2ce1d82c --- /dev/null +++ b/src/temp/dm/sendMessageBatch.ts @@ -0,0 +1,66 @@ +import {ValidationResult} from '@atproto/lexicon' +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + items: BatchItem[] + [k: string]: unknown +} + +export interface OutputSchema { + items: TempDmDefs.MessageView[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} + +export interface BatchItem { + chatId: string + message: TempDmDefs.Message + [k: string]: unknown +} + +export function isBatchItem(v: unknown): v is BatchItem { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'temp.dm.sendMessageBatch#batchItem' + ) +} + +export function validateBatchItem(v: unknown): ValidationResult { + return { + success: true, + value: v, + } +} + +export function isObj(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null +} + +export function hasProp<K extends PropertyKey>( + data: object, + prop: K, +): data is Record<K, unknown> { + return prop in data +} diff --git a/src/temp/dm/unmuteChat.ts b/src/temp/dm/unmuteChat.ts new file mode 100644 index 000000000..e116f2775 --- /dev/null +++ b/src/temp/dm/unmuteChat.ts @@ -0,0 +1,30 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + [k: string]: unknown +} + +export interface OutputSchema { + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/updateChatRead.ts b/src/temp/dm/updateChatRead.ts new file mode 100644 index 000000000..7eec7e4ac --- /dev/null +++ b/src/temp/dm/updateChatRead.ts @@ -0,0 +1,31 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + chatId: string + messageId?: string + [k: string]: unknown +} + +export type OutputSchema = TempDmDefs.ChatView + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/temp/dm/updateUserSettings.ts b/src/temp/dm/updateUserSettings.ts new file mode 100644 index 000000000..f88122f5a --- /dev/null +++ b/src/temp/dm/updateUserSettings.ts @@ -0,0 +1,33 @@ +import {Headers, XRPCError} from '@atproto/xrpc' + +import * as TempDmDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + allowIncoming?: TempDmDefs.IncomingMessageSetting + [k: string]: unknown +} + +export interface OutputSchema { + allowIncoming: TempDmDefs.IncomingMessageSetting + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 6b5390c29..470bace87 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -51,6 +51,7 @@ import {HandIcon, HashtagIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' +import {useGate} from 'lib/statsig/statsig' import {colors, s} from 'lib/styles' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' @@ -61,8 +62,10 @@ import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' import {ScrollView} from 'view/com/util/Views' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import * as TextField from '#/components/forms/TextField' import {navigate, resetToTab} from '#/Navigation' import {Email2FAToggle} from './Email2FAToggle' import {ExportCarDialog} from './ExportCarDialog' @@ -169,6 +172,11 @@ export function SettingsScreen({}: Props) { const exportCarControl = useDialogControl() const birthdayControl = useDialogControl() + // TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED + const gate = useGate() + const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} = + useDmServiceUrlStorage() + // const primaryBg = useCustomPalette<ViewStyle>({ // light: {backgroundColor: colors.blue0}, // dark: {backgroundColor: colors.blue6}, @@ -778,6 +786,22 @@ export function SettingsScreen({}: Props) { <Trans>System log</Trans> </Text> </TouchableOpacity> + {gate('dms') && ( + <TextField.Root> + <TextField.Input + value={dmServiceUrl} + onChangeText={(text: string) => { + if (text.endsWith('/')) { + text = text.slice(0, -1) + } + setDmServiceUrl(text) + }} + autoCapitalize="none" + keyboardType="url" + label="🐴" + /> + </TextField.Root> + )} {__DEV__ ? ( <> <TouchableOpacity diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 8b316faa5..d8deaf696 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -122,6 +122,16 @@ export function BottomBarWeb() { ) }} </NavItem> + <NavItem routeName="Messages" href="/messages"> + {() => { + return ( + <Envelope + size="lg" + style={[styles.ctrlIcon, pal.text, styles.messagesIcon]} + /> + ) + }} + </NavItem> {gate('dms') && ( <NavItem routeName="Messages" href="/messages"> {({isActive}) => { |