Page MenuHomePhorge

No OneTemporary

Size
17 KB
Referenced Files
None
Subscribers
None
diff --git a/src/composables/usePleromaChat.js b/src/composables/usePleromaChat.js
index 15954e828e..21dc9b7478 100644
--- a/src/composables/usePleromaChat.js
+++ b/src/composables/usePleromaChat.js
@@ -1,349 +1,356 @@
import { maxBy, minBy } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useChatsStore } from 'src/stores/chats.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import {
deleteChatMessage as apiDeleteChatMessage,
chatMessages,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { buildFakeMessage } from 'src/services/chat_utils/chat_utils.js'
import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
const MAX_RETRIES = 10
export function usePleromaChat(userId) {
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus === WSConnectionStatus.JOINED,
)
const chat = ref(null)
const socket = ref(null)
const error = ref(null)
const streaming = ref(false)
const fetcher = ref(null)
const fetching = ref(false)
const minId = ref(undefined)
const maxId = ref(undefined)
const idempotencyKeyIndex = ref(new Map())
const messages = ref([])
const messagesIndex = ref(new Map())
const pendingMessages = ref([])
const pendingMessagesIndex = ref(new Map())
const messageRetriers = ref(new Map())
const lastReadMessageId = ref(null)
const newMessagesCount = ref(0)
// # Posting & Optimism
const { currentUser } = storeToRefs(useUsersStore())
const sendMessage = async ({ status, media, idempotencyKey }) => {
const params = {
id: chat.value.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: chat.value.id,
content: status,
userId: currentUser.value.id,
idempotencyKey,
})
pendingMessages.value.push(fakeMessage)
pendingMessagesIndex.value.set(idempotencyKey, fakeMessage)
return doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
}
const doSendMessage = async ({ params, retriesLeft = MAX_RETRIES }) => {
if (retriesLeft <= 0) return
const handleMessageError = ({ idempotencyKey, isRetry }) => {
const fakeMessage = pendingMessagesIndex.value.get(idempotencyKey)
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
}
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
addMessages([{ ...data }])
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
) {
throw error
}
console.error('Error sending message', error)
handleMessageError({
chatId: chat.value.id,
idempotencyKey: params.idempotencyKey,
isRetry: retriesLeft !== MAX_RETRIES,
})
const error5xx = error.statusCode >= 500 && error.statusCode < 600
if (error5xx || error.message === 'Failed to fetch') {
messageRetriers.set(
params.idempotencyKey,
setTimeout(
() => {
doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
),
)
}
}
}
// # Poll & Push
const startFetching = (reason, isFirstFetch) => {
console.debug('[Pleroma Chat] Started fetching', 'Reason:', reason)
fetchOlder()
fetcher.value = promiseInterval(() => fetchChat({ latest: true }), 5000)
fetching.value = true
}
const stopFetching = (reason) => {
console.debug('[Pleroma Chat] Stopped fetching', 'Reason:', reason)
if (!fetching.value) return
fetcher.value.stop()
fetcher.value = null
fetching.value = false
}
const onStreamConnect = () => {
streaming.value = true
stopFetching('Socket connected')
}
const onStreamDisconnect = (closeEvent) => {
streaming.value = false
startFetching('Socket disconnected')
}
const onChatUpdate = ({ data: { chatUpdate } }) => {
const messages = [chatUpdate.lastMessage]
addMessages(messages)
}
// # Actions
const markAsRead = async () => {
if (!maxId.value || document.hidden) {
return
}
const lastReadId = maxId.value
const isNewMessage = lastReadMessageId.value !== lastReadId
if (!isNewMessage) return
await readChat({
id: chat.value.id,
lastReadId,
credentials: useOAuthStore().token,
})
useChatsStore().readChat(chat.value.id)
lastReadMessageId.value = maxId.value
newMessagesCount.value = 0
}
const deleteChatMessage = async ({ chatId, messageId }) => {
await apiDeleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
messages.value = messages.value.filter((m) => m.id !== messageId)
- messagesIndex.delete(messageId)
+ messagesIndex.value.delete(messageId)
if (maxId.value === messageId) {
const lastMessage = maxBy(messages.value, 'id')
maxId.value = lastMessage.id
}
if (minId.value === messageId) {
const firstMessage = minBy(messages.value, 'id')
minId.value = firstMessage.id
}
}
const fetchError = ref(null)
const fetchChat = async ({ older = false, latest = false }) => {
if (!older && streamingEnabled.value) {
return
}
try {
const { data: messages } = await chatMessages({
id: chat.value.id,
maxId: older ? minId.value : null,
sinceId: older ? null : maxId.value,
credentials: useOAuthStore().token,
})
addMessages(messages, true)
fetchError.value = null
} catch (e) {
console.error('Error fetching chat', e)
fetchError.value = e
}
}
const fetchOlder = () => {
fetchChat({ older: true })
}
// # Message list forming
const addMessages = (newMessages, prepend = false) => {
for (let message of newMessages) {
// Clear any known pending messages
if (message.idempotency_key) {
if (pendingMessagesIndex.value.has(message.idempotency_key)) {
pendingMessagesIndex.value.delete(message.idempotency_key)
pendingMessages.value = pendingMessages.value.filter(
({ idempotency_key }) =>
idempotency_key !== message.idempotency_key,
)
}
}
if (!minId.value || (!message.pending && message.id < minId.value)) {
minId.value = message.id
}
if (!maxId.value || message.id > maxId.value) {
maxId.value = message.id
}
const isConfirmation = (message) => {
if (!message.idempotency_key) return
return idempotencyKeyIndex.value.has(message.idempotency_key)
}
if (!messagesIndex.value.has(message.id) && !isConfirmation(message)) {
- if (lastReadMessageId < message.id) {
+ if (lastReadMessageId.value < message.id) {
newMessagesCount.value++
}
messagesIndex.value.set(message.id, message)
if (prepend) {
messages.value.unshift(messagesIndex.value.get(message.id))
} else {
messages.value.push(messagesIndex.value.get(message.id))
}
idempotencyKeyIndex.value.set(message.idempotency_key, true)
}
}
}
const clear = () => {
messages.value = messages.value.filter((m) => m.error)
messagesIndex.value = messages.value.reduce((acc, m) => {
acc.set(m.id, m)
return acc
}, new Map())
newMessagesCount.value = 0
lastReadMessageId.value = null
minId.value = undefined
maxId.value = undefined
}
const attachSocket = () => {
const et = new EventTarget()
const newSocket = {
name: 'chatview',
et,
}
et.addEventListener('pleroma:chat_update', onChatUpdate)
et.addEventListener('open', onStreamConnect)
et.addEventListener('close', onStreamDisconnect)
socket.value = newSocket
useStreamingStore().addSubscriber(socket.value)
}
const detachSocket = () => {
const { et } = socket.value
et.removeEventListener('pleroma:chat_update', onChatUpdate)
et.removeEventListener('open', onStreamConnect)
et.removeEventListener('close', onStreamDisconnect)
useStreamingStore().removeSubscriber(socket.value)
}
const activate = async () => {
try {
attachSocket()
const result = await getOrCreateChat({
accountId: userId.value,
credentials: useOAuthStore().token,
})
const { data } = result
newMessagesCount.value = data.unread
useUsersStore().addNewUsers({ ...result, data: data.account })
data.account = useUsersStore().findUser(data.account.id)
chat.value = data
startFetching('Chat activated', true)
} catch (e) {
console.error('Error creating or getting a chat', e)
error.value = e
}
}
const deactivate = () => {
if (fetching.value) {
stopFetching('Chat deactivated')
}
clear()
detachSocket()
}
const ready = computed(() => !!chat.value)
const recipient = computed(() => chat.value?.account)
return {
activate,
deactivate,
sendMessage,
newMessagesCount,
markAsRead,
deleteChatMessage,
messages,
pendingMessages,
fetchError,
fetchOlder,
ready,
recipient,
+ _test: {
+ chat,
+ addMessages,
+ minId,
+ maxId,
+ lastReadMessageId,
+ },
}
}
diff --git a/test/unit/specs/components/chat_view.spec.js b/test/unit/specs/components/chat_view.spec.js
deleted file mode 100644
index dd95b1fd3d..0000000000
--- a/test/unit/specs/components/chat_view.spec.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { createTestingPinia } from '@pinia/testing'
-import { shallowMount } from '@vue/test-utils'
-import { setActivePinia } from 'pinia'
-
-import ChatView from 'src/components/chat_view/chat_view.vue'
-
-const message1 = {
- id: '1',
- chat_id: 2,
- idempotency_key: '1',
- created_at: new Date('2020-06-22T18:45:53.000Z'),
-}
-
-const message2 = {
- id: '2',
- chat_id: 2,
- idempotency_key: '2',
- account_id: '9vmRb29zLQReckr5ay',
- created_at: new Date('2020-06-22T18:45:56.000Z'),
-}
-
-const message3 = {
- id: '3',
- chat_id: 2,
- idempotency_key: '3',
- account_id: '9vmRb29zLQReckr5ay',
- created_at: new Date('2020-07-22T18:45:59.000Z'),
-}
-
-const global = {
- mocks: {
- $route: {
- params: {
- recipient_id: 2,
- },
- },
- $router: {
- push: () => {
- /* noop */
- },
- },
- },
- stubs: {
- FAIcon: true,
- },
-}
-
-describe('ChatView methods', () => {
- let component
- beforeEach(() => {
- setActivePinia(createTestingPinia())
- component = shallowMount(ChatView, { global, props: { testMode: true } })
- component.vm.chat = { id: 2 }
- })
-
- describe('addMessages', () => {
- it("Doesn't add duplicates", () => {
- component.vm.addMessages({ messages: [message1] })
- component.vm.addMessages({ messages: [message1] })
- expect(component.vm.messages).to.have.length(1)
-
- component.vm.addMessages({ messages: [message2] })
- expect(component.vm.messages).to.have.length(2)
- })
-
- it('Updates minId and lastMessage and newMessageCount', async () => {
- component.vm.addMessages({ messages: [message1] })
- expect(component.vm.maxId).to.eql(message1.id)
- expect(component.vm.minId).to.eql(message1.id)
- expect(component.vm.newMessageCount).to.eql(1)
-
- component.vm.addMessages({ messages: [message2] })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message1.id)
- expect(component.vm.newMessageCount).to.eql(2)
-
- await component.vm.readChat()
- expect(component.vm.newMessageCount).to.eql(0)
- expect(component.vm.lastReadMessageId).to.eql(message2.id)
-
- // Add message with higher id
- component.vm.addMessages({ messages: [message3] })
- expect(component.vm.newMessageCount).to.eql(1)
- })
- })
-
- describe('deleteChatMessage', () => {
- it('Updates minId and lastMessage', () => {
- component.vm.addMessages({ messages: [message1] })
- component.vm.addMessages({ messages: [message2] })
- component.vm.addMessages({ messages: [message3] })
-
- expect(component.vm.maxId).to.eql(message3.id)
- expect(component.vm.minId).to.eql(message1.id)
-
- component.vm.deleteChatMessage({ messageId: message3.id })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message1.id)
-
- component.vm.deleteChatMessage({ messageId: message1.id })
- expect(component.vm.maxId).to.eql(message2.id)
- expect(component.vm.minId).to.eql(message2.id)
- })
- })
-
- describe('cullOlder', () => {
- it('keeps 50 newest messages and messagesIndex matches', () => {
- for (let i = 100; i > 0; i--) {
- // Use decimal values with toFixed to hack together constant length predictable strings
- component.vm.addMessages({
- messages: [
- {
- ...message1,
- id: 'a' + (i / 1000).toFixed(3),
- idempotency_key: i,
- },
- ],
- })
- }
- component.vm.cullOlder()
- expect(component.vm.messages).to.have.length(50)
- expect(component.vm.messages[0].id).to.eql('a0.051')
- expect(component.vm.minId).to.eql('a0.051')
- expect(component.vm.messages[49].id).to.eql('a0.100')
- expect(Object.keys(component.vm.messagesIndex)).to.have.length(50)
- })
- })
-})
diff --git a/test/unit/specs/composables/usePleromaChat.spec.js b/test/unit/specs/composables/usePleromaChat.spec.js
new file mode 100644
index 0000000000..bb00ef7dbb
--- /dev/null
+++ b/test/unit/specs/composables/usePleromaChat.spec.js
@@ -0,0 +1,105 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+
+import { usePleromaChat } from 'src/composables/usePleromaChat.js'
+
+const message1 = {
+ id: '1',
+ chat_id: 2,
+ idempotency_key: '1',
+ created_at: new Date('2020-06-22T18:45:53.000Z'),
+}
+
+const message2 = {
+ id: '2',
+ chat_id: 2,
+ idempotency_key: '2',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: new Date('2020-06-22T18:45:56.000Z'),
+}
+
+const message3 = {
+ id: '3',
+ chat_id: 2,
+ idempotency_key: '3',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: new Date('2020-07-22T18:45:59.000Z'),
+}
+
+describe('usePleromaChat', () => {
+ let composable
+ beforeEach(() => {
+ setActivePinia(createTestingPinia())
+ composable = usePleromaChat('1')
+ composable._test.chat.value = { id: 2 }
+ })
+
+ describe('addMessages', () => {
+ it("Doesn't add duplicates", () => {
+ composable._test.addMessages([message1])
+ composable._test.addMessages([message1])
+ expect(composable.messages.value).to.have.length(1)
+
+ composable._test.addMessages([message2])
+ expect(composable.messages.value).to.have.length(2)
+ })
+
+ it('Updates minId and lastMessage and newMessageCount', async () => {
+ composable._test.addMessages([message1])
+ expect(composable._test.maxId.value).to.eql(message1.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+ expect(composable.newMessagesCount.value).to.eql(1)
+
+ composable._test.addMessages([message2])
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+ expect(composable.newMessagesCount.value).to.eql(2)
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', mockFetch)
+ await composable.markAsRead()
+ expect(composable.newMessagesCount.value).to.eql(0)
+ expect(composable._test.lastReadMessageId.value).to.eql(message2.id)
+
+ // Add message with higher id
+ composable._test.addMessages([message3])
+ expect(composable.newMessagesCount.value).to.eql(1)
+ })
+ })
+
+ describe('deleteChatMessage', () => {
+ it('Updates minId and lastMessage', async () => {
+ composable._test.addMessages([message1])
+ composable._test.addMessages([message2])
+ composable._test.addMessages([message3])
+
+ expect(composable._test.maxId.value).to.eql(message3.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+
+ const mockFetch = vi.fn()
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({}), {
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ vi.stubGlobal('fetch', mockFetch)
+ await composable.deleteChatMessage({ messageId: message3.id })
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message1.id)
+
+ await composable.deleteChatMessage({ messageId: message1.id })
+ expect(composable._test.maxId.value).to.eql(message2.id)
+ expect(composable._test.minId.value).to.eql(message2.id)
+ })
+ })
+})

File Metadata

Mime Type
text/x-diff
Expires
Sun, Oct 11, 5:14 AM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785614
Default Alt Text
(17 KB)

Event Timeline