diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 25aaf28c4e..6ec5ec210d 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -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 {Alert} from 'react-native'
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 {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'
-
-type MessageWithNext = {
- message: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
- next: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage | null
-}
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
@@ -34,47 +25,43 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
-function renderItem({item}: {item: MessageWithNext}) {
- if (TempDmChatDefs.isMessageView(item.message))
- return
-
- if (TempDmChatDefs.isDeletedMessage(item)) return Deleted message
+function renderItem({item}: {item: ChatItem}) {
+ if (item.type === 'message') {
+ return
+ } else if (item.type === 'deleted-message') {
+ return Deleted message
+ } else if (item.type === 'pending-message') {
+ return {item.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}) {
+ return (
+
+
+
+ )
+}
+
+export function MessagesListInner() {
+ const chat = useChat()
const flatListRef = useRef(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)
+ // 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; changed: Array}) => {
@@ -93,23 +80,11 @@ export function MessagesList({chatId}: {chatId: string}) {
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)
- }, [])
+ chat.service.fetchMessageHistory()
+ }, [chat])
const onInputFocus = useCallback(() => {
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 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 (
- item.message.id}
- renderItem={renderItem}
- 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
- // 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}
- removeClippedSubviews={true}
- onEndReached={onEndReached}
- onScrollToIndexFailed={onScrollToEndFailed}
- onContentSizeChange={onContentSizeChange}
- onViewableItemsChanged={onViewableItemsChanged}
- viewabilityConfig={viewabilityConfig}
- maintainVisibleContentPosition={{
- minIndexForVisible: 1,
- }}
- ListFooterComponent={}
- ref={flatListRef}
- keyboardDismissMode="none"
- />
+ {chat.state.status === ChatStatus.Ready && (
+ item.key}
+ 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,
+ }}
+ ListFooterComponent={
+
+ }
+ removeClippedSubviews={true}
+ ref={flatListRef}
+ keyboardDismissMode="none"
+ />
+ )}
+
{
+ chat.service.sendMessage({
+ text,
+ })
+ }}
onFocus={onInputFocus}
onBlur={onInputBlur}
/>
diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts
index a4d78e0bb7..4d52558777 100644
--- a/src/screens/Messages/Temp/query/query.ts
+++ b/src/screens/Messages/Temp/query/query.ts
@@ -1,3 +1,5 @@
+import React from 'react'
+import {BskyAgent} from '@atproto/api'
import {
useInfiniteQuery,
useMutation,
@@ -11,7 +13,6 @@ 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'
/**
@@ -229,19 +230,24 @@ export function useGetChatFromMembers({
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
+ const [agent] = React.useState(() => {
+ return new BskyAgent({
+ service: serviceUrl,
+ })
+ })
return useMutation({
mutationFn: async (members: string[]) => {
- const response = await fetch(
- `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
- ',',
- )}`,
- {headers},
+ const {data} = await agent.api.temp.dm.getChatForMembers(
+ {
+ members,
+ },
+ {
+ headers,
+ },
)
- if (!response.ok) throw new Error('Failed to fetch chat')
-
- return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
+ return data
},
onSuccess: data => {
queryClient.setQueryData(['chat', data.chat.id], {
@@ -258,20 +264,25 @@ export function useGetChatFromMembers({
export function useListChats() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
+ const [agent] = React.useState(() => {
+ return new BskyAgent({
+ service: serviceUrl,
+ })
+ })
return useInfiniteQuery({
queryKey: ['chats'],
queryFn: async ({pageParam}) => {
- const response = await fetch(
- `${serviceUrl}/xrpc/temp.dm.listChats${
- pageParam ? `?cursor=${pageParam}` : ''
- }`,
- {headers},
+ const {data} = await agent.api.temp.dm.listChats(
+ {
+ cursor: pageParam,
+ },
+ {
+ headers,
+ },
)
- if (!response.ok) throw new Error('Failed to fetch chats')
-
- return (await response.json()) as TempDmChatListChats.OutputSchema
+ return data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
diff --git a/src/state/dms/__tests__/client.test.ts b/src/state/dms/__tests__/client.test.ts
new file mode 100644
index 0000000000..cab1d9021f
--- /dev/null
+++ b/src/state/dms/__tests__/client.test.ts
@@ -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`)
+ })
+ })
+})
diff --git a/src/state/dms/chat.ts b/src/state/dms/chat.ts
new file mode 100644
index 0000000000..9b9b7b6635
--- /dev/null
+++ b/src/state/dms/chat.ts
@@ -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 | 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)
+ }
+}
diff --git a/src/state/dms/index.tsx b/src/state/dms/index.tsx
new file mode 100644
index 0000000000..45be8e001f
--- /dev/null
+++ b/src/state/dms/index.tsx
@@ -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 & {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 (
+
+ {children}
+
+ )
+}