Add Chat service

This commit is contained in:
Eric Bailey
2024-04-30 14:46:00 -05:00
parent 80bcf81dc5
commit cd46f0f435
5 changed files with 608 additions and 135 deletions
@@ -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/dms'
import {ChatProvider} from '#/state/dms'
import {ChatItem, ChatStatus} from '#/state/dms/chat'
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: ChatItem}) {
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({chatId}: {chatId: string}) {
return (
<ChatProvider chatId={chatId}>
<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}}>
<FlatList {chat.state.status === ChatStatus.Ready && (
data={messages} <FlatList
keyExtractor={item => item.message.id} data={chat.state.items}
renderItem={renderItem} keyExtractor={item => item.key}
contentContainerStyle={{paddingHorizontal: 10}} renderItem={renderItem}
inverted={true} contentContainerStyle={{paddingHorizontal: 10}}
// 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={
ref={flatListRef} <MaybeLoader isLoading={chat.state.isFetchingHistory} />
keyboardDismissMode="none" }
/> removeClippedSubviews={true}
ref={flatListRef}
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}
/> />
+28 -17
View File
@@ -1,3 +1,5 @@
import React from 'react'
import {BskyAgent} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
@@ -11,7 +13,6 @@ import * as TempDmChatGetChat from '#/temp/dm/getChat'
import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers' import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog' import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages' import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
import * as TempDmChatListChats from '#/temp/dm/listChats'
import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
/** /**
@@ -229,19 +230,24 @@ export function useGetChatFromMembers({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const headers = useHeaders() const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage() const {serviceUrl} = useDmServiceUrlStorage()
const [agent] = React.useState(() => {
return new BskyAgent({
service: serviceUrl,
})
})
return useMutation({ return useMutation({
mutationFn: async (members: string[]) => { mutationFn: async (members: string[]) => {
const response = await fetch( const {data} = await agent.api.temp.dm.getChatForMembers(
`${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join( {
',', members,
)}`, },
{headers}, {
headers,
},
) )
if (!response.ok) throw new Error('Failed to fetch chat') return data
return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
}, },
onSuccess: data => { onSuccess: data => {
queryClient.setQueryData(['chat', data.chat.id], { queryClient.setQueryData(['chat', data.chat.id], {
@@ -258,20 +264,25 @@ export function useGetChatFromMembers({
export function useListChats() { export function useListChats() {
const headers = useHeaders() const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage() const {serviceUrl} = useDmServiceUrlStorage()
const [agent] = React.useState(() => {
return new BskyAgent({
service: serviceUrl,
})
})
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ['chats'], queryKey: ['chats'],
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const response = await fetch( const {data} = await agent.api.temp.dm.listChats(
`${serviceUrl}/xrpc/temp.dm.listChats${ {
pageParam ? `?cursor=${pageParam}` : '' cursor: pageParam,
}`, },
{headers}, {
headers,
},
) )
if (!response.ok) throw new Error('Failed to fetch chats') return data
return (await response.json()) as TempDmChatListChats.OutputSchema
}, },
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
+38
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`)
})
})
})
+425
View File
@@ -0,0 +1,425 @@
import {BskyAgent, TempDmDefs, TempDmSendMessage} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
export type ChatParams = {
chatId: string
agent: BskyAgent
__tempFromUserDid: string
}
export enum ChatStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Ready = 'ready',
Error = 'error',
Destroyed = 'destroyed',
}
export type ChatItem =
| {
type: 'message'
key: string
message: TempDmDefs.MessageView
nextMessage: TempDmDefs.MessageView | TempDmDefs.DeletedMessage | null
}
| {
type: 'deleted-message'
key: string
message: TempDmDefs.DeletedMessage
nextMessage: TempDmDefs.MessageView | TempDmDefs.DeletedMessage | null
}
| {
type: 'pending-message'
key: string
message: TempDmSendMessage.InputSchema['message']
}
export type ChatState =
| {
status: ChatStatus.Uninitialized
}
| {
status: ChatStatus.Initializing
}
| {
status: ChatStatus.Ready
items: ChatItem[]
chat: TempDmDefs.ChatView
isFetchingHistory: boolean
}
| {
status: ChatStatus.Error
error: any
}
| {
status: ChatStatus.Destroyed
}
export class Chat {
private chatId: string
private agent: BskyAgent
private __tempFromUserDid: string
private status: ChatStatus = ChatStatus.Uninitialized
private error: any
private chat: TempDmDefs.ChatView | undefined
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
private pastMessages: Map<
string,
TempDmDefs.MessageView | TempDmDefs.DeletedMessage
> = new Map()
private newMessages: Map<
string,
TempDmDefs.MessageView | TempDmDefs.DeletedMessage
> = new Map()
private deletedMessages: TempDmDefs.DeletedMessage[] = []
private pendingMessages: Map<
string,
{id: string; message: TempDmSendMessage.InputSchema['message']}
> = new Map()
private pendingEventIngestion: Promise<void> | undefined
constructor(params: ChatParams) {
this.chatId = params.chatId
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
}
async initialize() {
if (this.status !== 'uninitialized') return
this.status = ChatStatus.Initializing
try {
const response = await this.agent.api.temp.dm.getChat(
{
chatId: this.chatId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {chat} = response.data
this.chat = chat
this.status = ChatStatus.Ready
this.commit()
await this.fetchMessageHistory()
this.pollEvents()
} catch (e) {
this.status = ChatStatus.Error
this.error = e
}
}
private async pollEvents() {
if (this.status === ChatStatus.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 === ChatStatus.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.temp.dm.getChatMessages(
{
cursor: this.historyCursor,
chatId: this.chatId,
limit: 20,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {cursor, messages} = response.data
this.historyCursor = cursor || null
for (const message of messages) {
if (
TempDmDefs.isMessageView(message) ||
TempDmDefs.isDeletedMessage(message)
) {
this.pastMessages.set(message.id, message)
// set to latest rev
if (
// @ts-ignore TODO divy said so
message.rev > (this.eventsCursor = this.eventsCursor || message.rev)
) {
this.eventsCursor = message.rev
}
}
}
this.isFetchingHistory = false
this.commit()
}
async ingestLatestEvents() {
if (this.status === ChatStatus.Destroyed) return
const response = await this.agent.api.temp.dm.getChatLog(
{
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.chatId !== this.chatId) continue
if (
TempDmDefs.isLogCreateMessage(log) &&
TempDmDefs.isMessageView(log.message)
) {
this.newMessages.set(log.message.id, log.message)
} else if (
TempDmDefs.isLogDeleteMessage(log) &&
TempDmDefs.isDeletedMessage(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)) {
this.pastMessages.set(log.message.id, log.message)
}
}
}
}
}
this.commit()
}
async sendMessage(message: TempDmSendMessage.InputSchema['message']) {
if (this.status === ChatStatus.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.temp.dm.sendMessage(
{
chatId: this.chatId,
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: 'temp.dm.defs#messageView',
sender: this.chat?.members.find(m => m.did === this.__tempFromUserDid),
})
this.pendingMessages.delete(tempId)
this.commit()
}
/*
* Items in reverse order, since FlatList inverts
*
* TODO remove `deletedMessages` from these lists
*/
get items(): ChatItem[] {
const items: ChatItem[] = []
// `newMessages` is in insertion order, unshift to reverse
this.newMessages.forEach(m => {
if (TempDmDefs.isMessageView(m)) {
items.unshift({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (TempDmDefs.isDeletedMessage(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 (TempDmDefs.isMessageView(m)) {
items.push({
type: 'message',
key: m.id,
message: m,
nextMessage: null,
})
} else if (TempDmDefs.isDeletedMessage(m)) {
items.push({
type: 'deleted-message',
key: m.id,
message: m,
nextMessage: null,
})
}
})
return items.map((item, i) => {
let nextMessage = null
if (
TempDmDefs.isMessageView(item.message) ||
TempDmDefs.isDeletedMessage(item.message)
) {
const next = items[i - 1]
if (
next &&
(TempDmDefs.isMessageView(next.message) ||
TempDmDefs.isDeletedMessage(next.message))
) {
nextMessage = next.message
}
}
return {
...item,
nextMessage,
}
})
}
destroy() {
this.status = ChatStatus.Destroyed
this.commit()
}
get state(): ChatState {
switch (this.status) {
case ChatStatus.Initializing: {
return {
status: ChatStatus.Initializing,
}
}
case ChatStatus.Ready: {
return {
status: ChatStatus.Ready,
items: this.items,
chat: this.chat!,
isFetchingHistory: this.isFetchingHistory,
}
}
case ChatStatus.Error: {
return {
status: ChatStatus.Error,
error: this.error,
}
}
case ChatStatus.Destroyed: {
return {
status: ChatStatus.Destroyed,
}
}
default: {
return {
status: ChatStatus.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)
}
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import {BskyAgent} from '@atproto/api'
import {Chat, ChatParams} from '#/state/dms/chat'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<{
service: Chat
state: Chat['state']
}>({
// @ts-ignore
service: null,
// @ts-ignore
state: null,
})
export function useChat() {
return React.useContext(ChatContext)
}
export function ChatProvider({
children,
chatId,
}: Pick<ChatParams, 'chatId'> & {children: React.ReactNode}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [service] = React.useState(
() =>
new Chat({
chatId,
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>
)
}