Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805267
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
17 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment