[Clipclops] 2 Clipped 2 Clopped (#3796)

* Add new pkg

* copy queries over to new file

* useConvoQuery

* useListConvos

* Use useListConvos

* extract useConvoQuery

* useGetConvoForMembers

* Delete unused

* exract useListConvos

* Replace imports

* Messages/List/index.tsx

* extract getconvoformembers

* MessageItem

* delete chatLog and rename query.ts

* Update import

* Clipclop service (#3794)

* Add Chat service

* Better handle deletions

* Rollback unneeded changes

* Better insertion order

* Use clipclops

* don't show FAB if error

* clean up imports

* Update Convo service

* Remove temp files

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
zio/stable
Eric Bailey 2024-05-01 12:14:41 -05:00 committed by GitHub
parent d61b366b26
commit 538ca8dff1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 752 additions and 1130 deletions

View File

@ -50,6 +50,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0",
"@atproto/api": "^0.12.5", "@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",

View File

@ -9,6 +9,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@ -17,7 +18,6 @@ import {atoms as a, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query'
import {Button} from '../Button' import {Button} from '../Button'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
import {ListMaybePlaceholder} from '../Lists' import {ListMaybePlaceholder} from '../Lists'
@ -33,9 +33,9 @@ export function NewChat({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {mutate: createChat} = useGetChatFromMembers({ const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => { onSuccess: data => {
onNewChat(data.chat.id) onNewChat(data.convo.id)
}, },
onError: error => { onError: error => {
Toast.show(error.message) Toast.show(error.message)

View File

@ -1,5 +1,6 @@
import React, {useCallback, useMemo} from 'react' import React, {useCallback, useMemo} from 'react'
import {StyleProp, TextStyle, View} from 'react-native' import {StyleProp, TextStyle, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -7,14 +8,16 @@ import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
export function MessageItem({ export function MessageItem({
item, item,
next, next,
}: { }: {
item: TempDmChatDefs.MessageView item: ChatBskyConvoDefs.MessageView
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}) { }) {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@ -22,7 +25,7 @@ export function MessageItem({
const isFromSelf = item.sender?.did === currentAccount?.did const isFromSelf = item.sender?.did === currentAccount?.did
const isNextFromSelf = const isNextFromSelf =
TempDmChatDefs.isMessageView(next) && ChatBskyConvoDefs.isMessageView(next) &&
next.sender?.did === currentAccount?.did next.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => { const isLastInGroup = useMemo(() => {
@ -32,7 +35,7 @@ export function MessageItem({
} }
// or, if there's a 10 minute gap between this message and the next // or, if there's a 10 minute gap between this message and the next
if (TempDmChatDefs.isMessageView(next)) { if (ChatBskyConvoDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt) const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt) const nextDate = new Date(next.sentAt)
@ -88,7 +91,7 @@ function Metadata({
isLastInGroup, isLastInGroup,
style, style,
}: { }: {
message: TempDmChatDefs.MessageView message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean isLastInGroup: boolean
style: StyleProp<TextStyle> style: StyleProp<TextStyle>
}) { }) {

View File

@ -1,24 +1,15 @@
import React, {useCallback, useMemo, useRef, useState} from 'react' import React, {useCallback, useMemo, useRef} from 'react'
import {FlatList, View, ViewToken} from 'react-native' import {FlatList, View, ViewToken} from 'react-native'
import {Alert} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller' import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {isWeb} from '#/platform/detection' import {useChat} from '#/state/messages'
import {ChatProvider} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {isWeb} from 'platform/detection'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
import {
useChat,
useChatLogQuery,
useSendMessageMutation,
} from '#/screens/Messages/Temp/query/query'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
type MessageWithNext = {
message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
}
function MaybeLoader({isLoading}: {isLoading: boolean}) { function MaybeLoader({isLoading}: {isLoading: boolean}) {
return ( return (
@ -34,47 +25,43 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
) )
} }
function renderItem({item}: {item: MessageWithNext}) { function renderItem({item}: {item: ConvoItem}) {
if (TempDmChatDefs.isMessageView(item.message)) if (item.type === 'message') {
return <MessageItem item={item.message} next={item.next} /> return <MessageItem item={item.message} next={item.nextMessage} />
} else if (item.type === 'deleted-message') {
if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text> return <Text>Deleted message</Text>
} else if (item.type === 'pending-message') {
return <Text>{item.message.text}</Text>
}
return null 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() { function onScrollToEndFailed() {
// Placeholder function. You have to give FlatList something or else it will error. // Placeholder function. You have to give FlatList something or else it will error.
} }
export function MessagesList({chatId}: {chatId: string}) { export function MessagesList({convoId}: {convoId: string}) {
return (
<ChatProvider convoId={convoId}>
<MessagesListInner />
</ChatProvider>
)
}
export function MessagesListInner() {
const chat = useChat()
const flatListRef = useRef<FlatList>(null) 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 // We use this to know if we should scroll after a new clop is added to the list
const isAtBottom = useRef(false) const isAtBottom = useRef(false)
// Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
// total number of clops // total number of clops
// TODO this needs to be set to whatever the initial number of messages is // TODO this needs to be set to whatever the initial number of messages is
const totalMessages = useRef(10) // const totalMessages = useRef(10)
// TODO later // TODO later
const [_, setShowSpinner] = useState(false)
// Query Data
const {data: chat} = useChat(chatId)
const {mutate: sendMessage} = useSendMessageMutation(chatId)
useChatLogQuery()
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => { const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
return [ return [
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => { (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
@ -93,23 +80,11 @@ export function MessagesList({chatId}: {chatId: string}) {
if (isAtBottom.current) { if (isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true}) flatListRef.current?.scrollToOffset({offset: 0, animated: true})
} }
isFetching.current = false
setShowSpinner(false)
}, []) }, [])
const onEndReached = useCallback(() => { const onEndReached = useCallback(() => {
if (isFetching.current) return chat.service.fetchMessageHistory()
isFetching.current = true }, [chat])
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(() => { const onInputFocus = useCallback(() => {
if (!isAtBottom.current) { if (!isAtBottom.current) {
@ -117,84 +92,51 @@ export function MessagesList({chatId}: {chatId: string}) {
} }
}, []) }, [])
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 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)
)
},
)
.reduce((acc, message) => {
// convert [n1, n2, n3, ...] to [{message: n1, next: n2}, {message: n2, next: n3}, {message: n3, next: n4}, ...]
return [...acc, {message, next: acc.at(-1)?.message ?? null}]
}, [] as MessageWithNext[])
totalMessages.current = filtered.length
return filtered
}, [chat])
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={{flex: 1, marginBottom: isWeb ? 20 : 85}} style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
behavior="padding" behavior="padding"
keyboardVerticalOffset={70} keyboardVerticalOffset={70}
contentContainerStyle={{flex: 1}}> contentContainerStyle={{flex: 1}}>
{chat.state.status === ConvoStatus.Ready && (
<FlatList <FlatList
data={messages} data={chat.state.items}
keyExtractor={item => item.message.id} keyExtractor={item => item.key}
renderItem={renderItem} renderItem={renderItem}
contentContainerStyle={{paddingHorizontal: 10}} contentContainerStyle={{paddingHorizontal: 10}}
inverted={true}
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only // 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 // dealing with text. But whenever we have images or other media and things are taller, we will want to lower
// this...probably. // this...probably.
initialNumToRender={20} initialNumToRender={20}
// Same with the max to render per batch. Let's be safe for now though. // Same with the max to render per batch. Let's be safe for now though.
maxToRenderPerBatch={25} maxToRenderPerBatch={25}
removeClippedSubviews={true} inverted={true}
onEndReached={onEndReached} onEndReached={onEndReached}
onScrollToIndexFailed={onScrollToEndFailed} onScrollToIndexFailed={onScrollToEndFailed}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
onViewableItemsChanged={onViewableItemsChanged} onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig} viewabilityConfig={viewabilityConfig}
maintainVisibleContentPosition={{ maintainVisibleContentPosition={{
minIndexForVisible: 1, minIndexForVisible: 0,
}} }}
ListFooterComponent={<MaybeLoader isLoading={false} />} ListFooterComponent={
<MaybeLoader isLoading={chat.state.isFetchingHistory} />
}
removeClippedSubviews={true}
ref={flatListRef} ref={flatListRef}
keyboardDismissMode="none" keyboardDismissMode="none"
/> />
)}
<View style={{paddingHorizontal: 10}}> <View style={{paddingHorizontal: 10}}>
<MessageInput <MessageInput
onSendMessage={onSendMessage} onSendMessage={text => {
chat.service.sendMessage({
text,
})
}}
onFocus={onInputFocus} onFocus={onInputFocus}
onBlur={onInputBlur} onBlur={onInputBlur}
/> />

View File

@ -9,13 +9,13 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {useConvoQuery} from '#/state/queries/messages/conversation'
import {BACK_HITSLOP} from 'lib/constants' import {BACK_HITSLOP} from 'lib/constants'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views' import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {useChatQuery} from '#/screens/Messages/Temp/query/query'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid' import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
@ -29,11 +29,11 @@ type Props = NativeStackScreenProps<
> >
export function MessagesConversationScreen({route}: Props) { export function MessagesConversationScreen({route}: Props) {
const gate = useGate() const gate = useGate()
const chatId = route.params.conversation const convoId = route.params.conversation
const {currentAccount} = useSession() const {currentAccount} = useSession()
const myDid = currentAccount?.did const myDid = currentAccount?.did
const {data: chat, isError: isError} = useChatQuery(chatId) const {data: chat, isError: isError} = useConvoQuery(convoId)
const otherProfile = React.useMemo(() => { const otherProfile = React.useMemo(() => {
return chat?.members?.find(m => m.did !== myDid) return chat?.members?.find(m => m.did !== myDid)
}, [chat?.members, myDid]) }, [chat?.members, myDid])
@ -51,7 +51,7 @@ export function MessagesConversationScreen({route}: Props) {
return ( return (
<CenteredView style={{flex: 1}} sideBorders> <CenteredView style={{flex: 1}} sideBorders>
<Header profile={otherProfile} /> <Header profile={otherProfile} />
<MessagesList chatId={chatId} /> <MessagesList convoId={convoId} />
</CenteredView> </CenteredView>
) )
} }

View File

@ -2,6 +2,7 @@
import React, {useCallback, useMemo, useState} from 'react' import React, {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
@ -11,24 +12,22 @@ import {MessagesTabNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useListConvos} from '#/state/queries/messages/list-converations'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ViewHeader} from '#/view/com/util/ViewHeader' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/NewChat'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as TempDmChatDefs from '#/temp/dm/defs'
import {NewChat} from '../../../components/dms/NewChat'
import {ClipClopGate} from '../gate' import {ClipClopGate} from '../gate'
import {useListChats} from '../Temp/query/query'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
export function MessagesListScreen({navigation}: Props) { export function MessagesListScreen({navigation}: Props) {
@ -59,13 +58,13 @@ export function MessagesListScreen({navigation}: Props) {
fetchNextPage, fetchNextPage,
error, error,
refetch, refetch,
} = useListChats() } = useListConvos()
const isError = !!error const isError = !!error
const conversations = useMemo(() => { const conversations = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return data.pages.flatMap(page => page.chats) return data.pages.flatMap(page => page.convos)
} }
return [] return []
}, [data]) }, [data])
@ -99,9 +98,12 @@ export function MessagesListScreen({navigation}: Props) {
navigation.navigate('MessagesSettings') navigation.navigate('MessagesSettings')
}, [navigation]) }, [navigation])
const renderItem = useCallback(({item}: {item: TempDmChatDefs.ChatView}) => { const renderItem = useCallback(
return <ChatListItem key={item.id} chat={item} /> ({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
}, []) return <ChatListItem key={item.id} convo={item} />
},
[],
)
const gate = useGate() const gate = useGate()
if (!gate('dms')) return <ClipClopGate /> if (!gate('dms')) return <ClipClopGate />
@ -119,7 +121,7 @@ export function MessagesListScreen({navigation}: Props) {
errorMessage={cleanError(error)} errorMessage={cleanError(error)}
onRetry={isError ? refetch : undefined} onRetry={isError ? refetch : undefined}
/> />
<NewChat onNewChat={onNewChat} control={newChatControl} /> {!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
</> </>
) )
} }
@ -166,26 +168,26 @@ export function MessagesListScreen({navigation}: Props) {
) )
} }
function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) { function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
let lastMessage = _(msg`No messages yet`) let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null let lastMessageSentAt: string | null = null
if (TempDmChatDefs.isMessageView(chat.lastMessage)) { if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (chat.lastMessage.sender?.did === currentAccount?.did) { if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${chat.lastMessage.text}`) lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else { } else {
lastMessage = chat.lastMessage.text lastMessage = convo.lastMessage.text
} }
lastMessageSentAt = chat.lastMessage.sentAt lastMessageSentAt = convo.lastMessage.sentAt
} }
if (TempDmChatDefs.isDeletedMessage(chat.lastMessage)) { if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = _(msg`Message deleted`) lastMessage = _(msg`Message deleted`)
} }
const otherUser = chat.members.find( const otherUser = convo.members.find(
member => member.did !== currentAccount?.did, member => member.did !== currentAccount?.did,
) )
@ -194,7 +196,7 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
} }
return ( return (
<Link to={`/messages/${chat.id}`} style={a.flex_1}> <Link to={`/messages/${convo.id}`} style={a.flex_1}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<View <View
style={[ style={[
@ -211,7 +213,8 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
</View> </View>
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}> <Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
<Text style={[t.atoms.text, chat.unreadCount > 0 && a.font_bold]}> <Text
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
{otherUser.displayName || otherUser.handle} {otherUser.displayName || otherUser.handle}
</Text>{' '} </Text>{' '}
{lastMessageSentAt ? ( {lastMessageSentAt ? (
@ -233,14 +236,14 @@ function ChatListItem({chat}: {chat: TempDmChatDefs.ChatView}) {
style={[ style={[
a.text_sm, a.text_sm,
a.leading_snug, a.leading_snug,
chat.unreadCount > 0 convo.unreadCount > 0
? a.font_bold ? a.font_bold
: t.atoms.text_contrast_medium, : t.atoms.text_contrast_medium,
]}> ]}>
{lastMessage} {lastMessage}
</Text> </Text>
</View> </View>
{chat.unreadCount > 0 && ( {convo.unreadCount > 0 && (
<View <View
style={[ style={[
a.flex_0, a.flex_0,

View File

@ -1,305 +0,0 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useSession} from '#/state/session'
import * as TempDmChatDefs from '#/temp/dm/defs'
import * as TempDmChatGetChat from '#/temp/dm/getChat'
import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
import * as TempDmChatListChats from '#/temp/dm/listChats'
import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
/**
* TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
* (and do not try this at home)
*/
const useHeaders = () => {
const {currentAccount} = useSession()
return {
get Authorization() {
return currentAccount!.did
},
}
}
type Chat = {
chatId: string
messages: TempDmChatGetChatMessages.OutputSchema['messages']
lastCursor?: string
lastRev?: string
}
export function useChat(chatId: string) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
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,
},
)
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,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const chatJson =
(await chatResponse.json()) as TempDmChatGetChat.OutputSchema
queryClient.setQueryData(['chatQuery', chatId], chatJson.chat)
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 headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
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: {
...headers,
'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: [
{
$type: 'temp.dm.defs#messageView',
id: variables.tempId,
text: variables.message,
sender: {did: headers.Authorization}, // TODO a real DID get
sentAt: new Date().toISOString(),
},
...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 headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
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,
},
)
if (!response.ok) throw new Error('Failed to fetch chat log')
const json =
(await response.json()) as TempDmChatGetChatLog.OutputSchema
if (json.logs.length > 0) {
queryClient.invalidateQueries({queryKey: ['chats']})
}
for (const log of json.logs) {
if (TempDmChatDefs.isLogCreateMessage(log)) {
queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
// TODO hack filter out duplicates
if (prev?.messages.find(m => m.id === log.message.id)) return
return {
...prev,
messages: [log.message, ...prev.messages],
}
})
}
}
return json
} catch (e) {
console.log(e)
}
},
refetchInterval: 5000,
})
}
export function useGetChatFromMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
',',
)}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chat')
return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
},
onSuccess: data => {
queryClient.setQueryData(['chat', data.chat.id], {
chatId: data.chat.id,
messages: [],
lastRev: data.chat.rev,
})
onSuccess?.(data)
},
onError,
})
}
export function useListChats() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: ['chats'],
queryFn: async ({pageParam}) => {
const response = await fetch(
`${serviceUrl}/xrpc/temp.dm.listChats${
pageParam ? `?cursor=${pageParam}` : ''
}`,
{headers},
)
if (!response.ok) throw new Error('Failed to fetch chats')
return (await response.json()) as TempDmChatListChats.OutputSchema
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export function useChatQuery(chatId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: ['chatQuery', chatId],
queryFn: async () => {
const chatResponse = await fetch(
`${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
{
headers,
},
)
if (!chatResponse.ok) throw new Error('Failed to fetch chat')
const json = (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
return json.chat
},
})
}

View File

@ -0,0 +1,38 @@
import {describe, it} from '@jest/globals'
describe(`#/state/dms/client`, () => {
describe(`ChatsService`, () => {
describe(`unread count`, () => {
it.todo(`marks a chat as read, decrements total unread count`)
})
describe(`log processing`, () => {
/*
* We receive a new chat log AND messages for it in the same batch. We
* need to first initialize the chat, then process the received logs.
*/
describe(`handles new chats and subsequent messages received in same log batch`, () => {
it.todo(`receives new chat and messages`)
it.todo(
`receives new chat, new messages come in while still initializing new chat`,
)
})
})
describe(`reset state`, () => {
it.todo(`after period of inactivity, rehydrates entirely fresh state`)
})
})
describe(`ChatService`, () => {
describe(`history fetching`, () => {
it.todo(`fetches initial chat history`)
it.todo(`fetches additional chat history`)
it.todo(`handles history fetch failure`)
})
describe(`optimistic updates`, () => {
it.todo(`adds sending messages`)
})
})
})

View File

@ -0,0 +1,442 @@
import {
BskyAgent,
ChatBskyConvoDefs,
ChatBskyConvoSendMessage,
} from '@atproto-labs/api'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
export type ConvoParams = {
convoId: string
agent: BskyAgent
__tempFromUserDid: string
}
export enum ConvoStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Ready = 'ready',
Error = 'error',
Destroyed = 'destroyed',
}
export type ConvoItem =
| {
type: 'message'
key: string
message: ChatBskyConvoDefs.MessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'deleted-message'
key: string
message: ChatBskyConvoDefs.DeletedMessageView
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'pending-message'
key: string
message: ChatBskyConvoSendMessage.InputSchema['message']
}
export type ConvoState =
| {
status: ConvoStatus.Uninitialized
}
| {
status: ConvoStatus.Initializing
}
| {
status: ConvoStatus.Ready
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
isFetchingHistory: boolean
}
| {
status: ConvoStatus.Error
error: any
}
| {
status: ConvoStatus.Destroyed
}
export class Convo {
private convoId: string
private agent: BskyAgent
private __tempFromUserDid: string
private status: ConvoStatus = ConvoStatus.Uninitialized
private error: any
private convo: ChatBskyConvoDefs.ConvoView | undefined
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
private pastMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private newMessages: Map<
string,
ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
> = new Map()
private pendingMessages: Map<
string,
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map()
private pendingEventIngestion: Promise<void> | undefined
constructor(params: ConvoParams) {
this.convoId = params.convoId
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
}
async initialize() {
if (this.status !== 'uninitialized') return
this.status = ConvoStatus.Initializing
try {
const response = await this.agent.api.chat.bsky.convo.getConvo(
{
convoId: this.convoId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {convo} = response.data
this.convo = convo
this.status = ConvoStatus.Ready
this.commit()
await this.fetchMessageHistory()
this.pollEvents()
} catch (e) {
this.status = ConvoStatus.Error
this.error = e
}
}
private async pollEvents() {
if (this.status === ConvoStatus.Destroyed) return
if (this.pendingEventIngestion) return
setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion
this.pendingEventIngestion = undefined
this.pollEvents()
}, 5e3)
}
async fetchMessageHistory() {
if (this.status === ConvoStatus.Destroyed) return
// reached end
if (this.historyCursor === null) return
if (this.isFetchingHistory) return
this.isFetchingHistory = true
this.commit()
/*
* Delay if paginating while scrolled.
*
* TODO why does the FlatList jump without this delay?
*
* Tbh it feels a little more natural with a slight delay.
*/
if (this.pastMessages.size > 0) {
await new Promise(y => setTimeout(y, 500))
}
const response = await this.agent.api.chat.bsky.convo.getMessages(
{
cursor: this.historyCursor,
convoId: this.convoId,
limit: 20,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {cursor, messages} = response.data
this.historyCursor = cursor || null
for (const message of messages) {
if (
ChatBskyConvoDefs.isMessageView(message) ||
ChatBskyConvoDefs.isDeletedMessageView(message)
) {
this.pastMessages.set(message.id, message)
// set to latest rev
if (
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
) {
this.eventsCursor = message.rev
}
}
}
this.isFetchingHistory = false
this.commit()
}
async ingestLatestEvents() {
if (this.status === ConvoStatus.Destroyed) return
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
for (const log of logs) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof log.rev === 'string') {
/*
* We only care about new events
*/
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
/*
* Update rev regardless of if it's a log type we care about or not
*/
this.eventsCursor = log.rev
/*
* This is VERY important. We don't want to insert any messages from
* your other chats.
*
* TODO there may be a better way to handle this
*/
if (log.convoId !== this.convoId) continue
if (
ChatBskyConvoDefs.isLogCreateMessage(log) &&
ChatBskyConvoDefs.isMessageView(log.message)
) {
if (this.newMessages.has(log.message.id)) {
// Trust the log as the source of truth on ordering
// TODO test this
this.newMessages.delete(log.message.id)
}
this.newMessages.set(log.message.id, log.message)
} else if (
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
ChatBskyConvoDefs.isDeletedMessageView(log.message)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
*/
if (this.pastMessages.has(log.message.id)) {
/*
* For now, we remove deleted messages from the thread, if we receive one.
*
* To support them, it'd look something like this:
* this.pastMessages.set(log.message.id, log.message)
*/
this.pastMessages.delete(log.message.id)
}
}
}
}
}
this.commit()
}
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
if (this.status === ConvoStatus.Destroyed) return
// Ignore empty messages for now since they have no other purpose atm
if (!message.text) return
const tempId = nanoid()
this.pendingMessages.set(tempId, {
id: tempId,
message,
})
this.commit()
await new Promise(y => setTimeout(y, 500))
const response = await this.agent.api.chat.bsky.convo.sendMessage(
{
convoId: this.convoId,
message,
},
{
encoding: 'application/json',
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const res = response.data
/*
* Insert into `newMessages` as soon as we have a real ID. That way, when
* we get an event log back, we can replace in situ.
*/
this.newMessages.set(res.id, {
...res,
$type: 'chat.bsky.convo.defs#messageView',
sender: this.convo?.members.find(m => m.did === this.__tempFromUserDid),
})
this.pendingMessages.delete(tempId)
this.commit()
}
/*
* Items in reverse order, since FlatList inverts
*/
get items(): ConvoItem[] {
const items: ConvoItem[] = []
// `newMessages` is in insertion order, unshift to reverse
this.newMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.unshift({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.unshift({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
// `newMessages` is in insertion order, unshift to reverse
this.pendingMessages.forEach(m => {
items.unshift({
type: 'pending-message',
key: m.id,
message: m.message,
})
})
this.pastMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) {
items.push({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
return items.map((item, i) => {
let nextMessage = null
if (
ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.message)
) {
const next = items[i - 1]
if (
next &&
(ChatBskyConvoDefs.isMessageView(next.message) ||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
) {
nextMessage = next.message
}
}
return {
...item,
nextMessage,
}
})
}
destroy() {
this.status = ConvoStatus.Destroyed
this.commit()
}
get state(): ConvoState {
switch (this.status) {
case ConvoStatus.Initializing: {
return {
status: ConvoStatus.Initializing,
}
}
case ConvoStatus.Ready: {
return {
status: ConvoStatus.Ready,
items: this.items,
convo: this.convo!,
isFetchingHistory: this.isFetchingHistory,
}
}
case ConvoStatus.Error: {
return {
status: ConvoStatus.Error,
error: this.error,
}
}
case ConvoStatus.Destroyed: {
return {
status: ConvoStatus.Destroyed,
}
}
default: {
return {
status: ConvoStatus.Uninitialized,
}
}
}
}
private _emitter = new EventEmitter()
private commit() {
this._emitter.emit('update')
}
on(event: 'update', cb: () => void) {
this._emitter.on(event, cb)
}
off(event: 'update', cb: () => void) {
this._emitter.off(event, cb)
}
}

View File

@ -0,0 +1,57 @@
import React from 'react'
import {BskyAgent} from '@atproto-labs/api'
import {Convo, ConvoParams} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<{
service: Convo
state: Convo['state']
}>({
// @ts-ignore
service: null,
// @ts-ignore
state: null,
})
export function useChat() {
return React.useContext(ChatContext)
}
export function ChatProvider({
children,
convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [service] = React.useState(
() =>
new Convo({
convoId,
agent: new BskyAgent({
service: serviceUrl,
}),
__tempFromUserDid: getAgent().session?.did!,
}),
)
const [state, setState] = React.useState(service.state)
React.useEffect(() => {
service.initialize()
}, [service])
React.useEffect(() => {
const update = () => setState(service.state)
service.on('update', update)
return () => {
service.destroy()
}
}, [service])
return (
<ChatContext.Provider value={{state, service}}>
{children}
</ChatContext.Provider>
)
}

View File

@ -0,0 +1,25 @@
import {BskyAgent} from '@atproto-labs/api'
import {useQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
const RQKEY_ROOT = 'convo'
export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId]
export function useConvoQuery(convoId: string) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useQuery({
queryKey: RQKEY(convoId),
queryFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvo(
{convoId},
{headers},
)
return data.convo
},
})
}

View File

@ -0,0 +1,35 @@
import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {useHeaders} from './temp-headers'
export function useGetConvoForMembers({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoGetConvoForMembers.OutputSchema) => void
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async (members: string[]) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.getConvoForMembers(
{members: members},
{headers},
)
return data
},
onSuccess: data => {
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
onSuccess?.(data)
},
onError,
})
}

View File

@ -0,0 +1,28 @@
import {BskyAgent} from '@atproto-labs/api'
import {useInfiniteQuery} from '@tanstack/react-query'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvos() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useInfiniteQuery({
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.listConvos(
{cursor: pageParam},
{headers},
)
return data
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
})
}

View File

@ -0,0 +1,11 @@
import {useSession} from '#/state/session'
// toy auth
export const useHeaders = () => {
const {currentAccount} = useSession()
return {
get Authorization() {
return currentAccount!.did
},
}
}

View File

@ -1,195 +0,0 @@
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
}

View File

@ -1,31 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,36 +0,0 @@
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
}

View File

@ -1,37 +0,0 @@
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
}

View File

@ -1,28 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,32 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,31 +0,0 @@
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
}

View File

@ -1,66 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
}

View File

@ -1,31 +0,0 @@
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
}

View File

@ -1,33 +0,0 @@
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
}

View File

@ -34,6 +34,18 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto-labs/api@^0.12.8-clipclops.0":
version "0.12.8-clipclops.0"
resolved "https://registry.yarnpkg.com/@atproto-labs/api/-/api-0.12.8-clipclops.0.tgz#1c5d41d3396e439a0b645f7e1ccf500cc4b42580"
integrity sha512-YYDtWWk6BR+aRBVja/1v+gceNK81lkmF5bi6O4pTmJhFt/321XATx/ql8uTWta4VnVThoFeNPG6nLr7hs8b9cA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.12.3": "@atproto/api@^0.12.3":
version "0.12.3" version "0.12.3"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17"