Add Chat service
This commit is contained in:
@@ -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 <MessageItem item={item.message} next={item.next} />
|
||||
|
||||
if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text>
|
||||
function renderItem({item}: {item: ChatItem}) {
|
||||
if (item.type === 'message') {
|
||||
return <MessageItem item={item.message} next={item.nextMessage} />
|
||||
} else if (item.type === 'deleted-message') {
|
||||
return <Text>Deleted message</Text>
|
||||
} else if (item.type === 'pending-message') {
|
||||
return <Text>{item.message.text}</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 (
|
||||
<ChatProvider chatId={chatId}>
|
||||
<MessagesListInner />
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessagesListInner() {
|
||||
const chat = useChat()
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
|
||||
// Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true
|
||||
// once the request for new posts starts. Then, we will change it back to false after the content size changes.
|
||||
const isFetching = useRef(false)
|
||||
|
||||
// We use this to know if we should scroll after a new clop is added to the list
|
||||
const isAtBottom = useRef(false)
|
||||
|
||||
// Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
|
||||
// total number of clops
|
||||
// TODO this needs to be set to whatever the initial number of messages is
|
||||
const totalMessages = useRef(10)
|
||||
// const totalMessages = useRef(10)
|
||||
|
||||
// TODO later
|
||||
|
||||
const [_, setShowSpinner] = useState(false)
|
||||
|
||||
// Query Data
|
||||
const {data: chat} = useChat(chatId)
|
||||
const {mutate: sendMessage} = useSendMessageMutation(chatId)
|
||||
useChatLogQuery()
|
||||
|
||||
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
|
||||
return [
|
||||
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
|
||||
@@ -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 (
|
||||
<KeyboardAvoidingView
|
||||
style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={70}
|
||||
contentContainerStyle={{flex: 1}}>
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={item => 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={<MaybeLoader isLoading={false} />}
|
||||
ref={flatListRef}
|
||||
keyboardDismissMode="none"
|
||||
/>
|
||||
{chat.state.status === ChatStatus.Ready && (
|
||||
<FlatList
|
||||
data={chat.state.items}
|
||||
keyExtractor={item => 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={
|
||||
<MaybeLoader isLoading={chat.state.isFetchingHistory} />
|
||||
}
|
||||
removeClippedSubviews={true}
|
||||
ref={flatListRef}
|
||||
keyboardDismissMode="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={{paddingHorizontal: 10}}>
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
onSendMessage={text => {
|
||||
chat.service.sendMessage({
|
||||
text,
|
||||
})
|
||||
}}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user