diff --git a/src/api/public.js b/src/api/public.js index 839196b9e..7e109e297 100644 --- a/src/api/public.js +++ b/src/api/public.js @@ -25,7 +25,8 @@ export const MASTODON_FOLLOWERS_URL = ( `/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}` export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}` -const MASTODON_STATUS_CONTEXT_URL = (id) => `/api/v1/statuses/${id}/context` +export const MASTODON_STATUS_CONTEXT_URL = (id) => + `/api/v1/statuses/${id}/context` export const MASTODON_STATUS_SOURCE_URL = (id) => `/api/v1/statuses/${id}/source` export const MASTODON_STATUS_HISTORY_URL = (id) => diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index fdd4a36a8..b0d116341 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -114,16 +114,9 @@ export default { const conversationLite = computed(() => conversation.value.map(({ id }) => ({ id })), ) - - watch( - expanded, - (value) => { - if (value) { - fetchConversation() - } - }, - { flush: 'post' }, - ) + provide('focusedId', focusedId) + provide('conversation', conversation) + provide('replies', replies) // Component created if (isPage.value) { diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js index 13dd5503d..8edfdc2be 100644 --- a/src/composables/useConversation.js +++ b/src/composables/useConversation.js @@ -1,5 +1,5 @@ import { storeToRefs } from 'pinia' -import { computed, nextTick, provide, ref, watch } from 'vue' +import { computed, nextTick, ref, watch, toValue } from 'vue' import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useOAuthStore } from 'src/stores/oauth.js' @@ -52,7 +52,7 @@ export function useConversation(statusId, expanded) { } const fullyLoaded = ref(false) const conversationId = computed( - () => mainStatus.value?.statusnet_conversation_id, + () => mainStatus.value?.statusnet_conversation_id ?? null, ) watch(conversationId, (neu, old) => { if (neu !== old) fullyLoaded.value = false @@ -62,7 +62,7 @@ export function useConversation(statusId, expanded) { return [] } - if (!expanded.value || !fullyLoaded.value) { + if (!toValue(expanded) || !fullyLoaded.value) { return [currentStatus.value] } @@ -124,8 +124,6 @@ export function useConversation(statusId, expanded) { ), ) const getReplies = (id) => replies.value.get(id) ?? new Set() - provide('conversation', conversation) - provide('replies', replies) const fetchConversation = async () => { if (currentStatus.value) { @@ -133,7 +131,7 @@ export function useConversation(statusId, expanded) { data: { ancestors, descendants }, timestamp, } = await apiFetchConversation({ - id: statusId.value, + id: toValue(statusId), credentials: useOAuthStore().token, }) @@ -150,7 +148,7 @@ export function useConversation(statusId, expanded) { loadError.value = null const { data: status } = await apiFetchStatus({ - id: statusId.value, + id: toValue(statusId), credentials: useOAuthStore().token, }) @@ -164,6 +162,16 @@ export function useConversation(statusId, expanded) { } } + watch( + expanded, + (value) => { + if (value) { + fetchConversation() + } + }, + { flush: 'post' }, + ) + // # Focus const focused = ref(null) const { mainStatus: focusedStatus } = useMainStatus(focused) @@ -173,29 +181,28 @@ export function useConversation(statusId, expanded) { watch(statusId, (val) => setFocused(val), { immediate: true }) const focusedId = computed(() => - expanded.value && fullyLoaded.value ? focusedStatus.value?.id : null, + (toValue(expanded) && fullyLoaded.value) ? focusedStatus.value?.id : null, ) - provide('focusedId', focusedId) watch( - focusedStatus, + focusedId, (newVal, oldVal) => { if (!newVal) return - if (newVal?.id === oldVal?.id) return // prevents infinite loop + if (newVal === oldVal) return // prevents infinite loop if (!streamingEnabled.value) { - useStatusesStore().fetchStatus(newVal.id) + useStatusesStore().fetchStatus(newVal) } - useStatusesStore().fetchFavsAndRepeats(newVal.id) - useStatusesStore().fetchEmojiReactions(newVal.id) + useStatusesStore().fetchFavsAndRepeats(newVal) + useStatusesStore().fetchEmojiReactions(newVal) }, { immediate: true }, ) return { focusedId, - conversationId, setFocused, + conversationId, currentStatus, mainStatus, conversation, diff --git a/src/composables/useMainStatus.js b/src/composables/useMainStatus.js index 902bc2b77..dbf94d5c8 100644 --- a/src/composables/useMainStatus.js +++ b/src/composables/useMainStatus.js @@ -6,7 +6,7 @@ export function useMainStatus(statusId) { const statusesStore = useStatusesStore() const getStatusObject = (id) => statusesStore.allStatuses.get(id) - const status = computed(() => getStatusObject(toValue(statusId))) + const status = computed(() => getStatusObject(toValue(statusId)) ?? null) const mainStatus = computed(() => { if (!status.value) return null diff --git a/test/fixtures/masto_api.js b/test/fixtures/masto_api.js new file mode 100644 index 000000000..59feaa38e --- /dev/null +++ b/test/fixtures/masto_api.js @@ -0,0 +1,93 @@ +export const userId = '1' +export const userScreenName = 'user' +export const userName = 'Guy' +export const userUrl = 'http://localhost/user' + +export const fetchOptions = (url, method = 'GET') => [ + url, + { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, +] + +export const mockMastoAPIUser = ({ + screen_name = userScreenName, + name = userName, + url = userUrl, + id = userId, +} = {}) => ({ + id, + acct: screen_name, + display_name: name, + fields: [], + avatar: '', + url, + pleroma: { + emoji_reactions: [], + }, +}) + +export const mockMastoAPIStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockMastoAPIUser(), + in_reply_to_status_id = null, + statusnet_conversation_id = 'c1', +} = {}) => ({ + id, + account: statusUser, + name: 'status', + content: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + pleroma: { + conversation_id: statusnet_conversation_id, + }, + in_reply_to_id: in_reply_to_status_id, +}) + +export const mockUser = ({ + screen_name = userScreenName, + id = userId, + name = userName, + url = userUrl, +} = {}) => ({ + _original: mockMastoAPIUser({ + screen_name, + id, + name, + url, + }), + id, + name, + screen_name, + url, + relationship: undefined, +}) + +export const mockStatus = ({ + id = '1', + text, + type = 'status', + statusUser = mockUser(), + in_reply_to_status_id = null, + statusnet_conversation_id = 'c1', +} = {}) => ({ + id, + user: statusUser, + name: 'status', + text: text ?? `Text number ${id}`, + uri: '', + type, + attentions: [], + statusnet_conversation_id, + emoji_reactions: [], + in_reply_to_status_id, +}) diff --git a/test/unit/specs/composables/useConversation.spec.js b/test/unit/specs/composables/useConversation.spec.js new file mode 100644 index 000000000..b8873d0ec --- /dev/null +++ b/test/unit/specs/composables/useConversation.spec.js @@ -0,0 +1,251 @@ +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { ref } from 'vue' +import { + mockMastoAPIStatus, + + mockMastoAPIUser, + mockStatus, + mockUser, + userId, + userName, + userScreenName, + userUrl, +} from 'test/fixtures/masto_api.js' + +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useOAuthStore } from 'src/stores/oauth.js' +import { useStatusesStore } from 'src/stores/statuses.js' +import { useStreamingStore } from 'src/stores/streaming.js' + +import { useConversation } from 'src/composables/useConversation.js' + +import { + MASTODON_STATUS_CONTEXT_URL, + MASTODON_STATUS_URL, + MASTODON_STATUS_FAVORITEDBY_URL, + MASTODON_STATUS_REBLOGGEDBY_URL, + PLEROMA_EMOJI_REACTIONS_URL, +} from 'src/api/public.js' + +describe('useConversation', () => { + const constructStatus = (index, convoId = '1000') => { + const stringId = index.toString() + const object = { id: stringId, statusnet_conversation_id: convoId } + if (index > 0) { + object.in_reply_to_status_id = (index - 1).toString() + } + + return mockStatus(object) + } + const constructStatusAPI = (index, convoId = '1000') => { + const stringId = index.toString() + const object = { id: stringId, statusnet_conversation_id: convoId } + if (index > 0) { + object.in_reply_to_status_id = (index - 1).toString() + } + + return mockMastoAPIStatus(object) + } + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.useFakeTimers() + useStatusesStore().resetStatuses() + }) + afterEach(() => { + vi.useRealTimers() + vi.resetAllMocks() + }) + + it('should work if status is unknown yet', () => { + useStatusesStore().allStatuses = new Map() + + const result = useConversation(ref('1'), ref(false)) + + expect(result.focusedId.value).to.eql(null) + expect(result.currentStatus.value).to.eql(null) + expect(result.mainStatus.value).to.eql(null) + expect(result.replies.value).to.eql(new Map()) + expect(result.conversationId.value).to.eql(null) + expect(result.conversation.value).to.eql([]) + expect(result.loadError.value).to.eql(null) + }) + + it('should return single item that is already known when not expanded', () => { + useStatusesStore().allStatuses = new Map( + [...new Array(20)].map((i, index) => [index.toString(), constructStatus(index)]) + ) + + const result = useConversation(ref('1'), ref(false)) + const expectedStatus = constructStatus(1) + + expect(result.focusedId.value).to.eql(null) + expect(result.currentStatus.value).to.eql(expectedStatus) + expect(result.mainStatus.value).to.eql(expectedStatus) + expect(result.conversation.value).to.eql([expectedStatus]) + expect(result.conversationId.value).to.eql('1000') + }) + + it('should return entire conversation when fethed', async () => { + const convo = [...new Array(20)].map((i, index) => constructStatus(index)) + const convoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index)) + + const mockFetch = vi.fn() + vi.when(mockFetch, { onUnmatched: 'throw' }) + .calledWith(MASTODON_STATUS_URL('4'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify(convoAPI[4]), { + headers: { 'Content-Type': 'application/json' }, + })) + .calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify({ + ancestors: convoAPI.slice(0,4), + descendants: convoAPI.slice(5), + }), { + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', mockFetch) + + const result = useConversation(ref('4'), ref(true)) + await result.fetchConversation() + + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4'), expect.anything()) + expect(result.conversation.value).to.have.length(1) + expect(result.conversation.value[0]).to.have.property('id', '4') + expect(result.conversationId.value).to.have.eql('1000') + + await vi.advanceTimersToNextTimerAsync() + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything()) + expect(result.conversation.value).to.have.length(20) + expect(result.conversation.value[0]).to.have.property('id', '0') + }) + + it('should fetch entire conversation when expanded', async () => { + const convo = [...new Array(20)].map((i, index) => constructStatus(index)) + const convoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index)) + useStatusesStore().addNewStatuses({ statuses: [convo[4]], timestamp: Date.now() }) + + const mockFetch = vi.fn() + vi.when(mockFetch) + .calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify({ + ancestors: convoAPI.slice(0,4), + descendants: convoAPI.slice(5), + }), { + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', mockFetch) + + const expanded = ref(false) + const result = useConversation(ref('4'), expanded) + expect(result.conversation.value).to.have.length(1) + expect(result.conversation.value[0]).to.have.property('id', '4') + expect(result.focusedId.value).to.eql(null) + expanded.value = true + await vi.advanceTimersToNextTimerAsync() + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4'), expect.anything()) + await vi.advanceTimersToNextTimerAsync() + expect(result.focusedId.value).to.eql('4') + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_FAVORITEDBY_URL('4'), expect.anything()) + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_REBLOGGEDBY_URL('4'), expect.anything()) + expect(mockFetch).to.have.been.calledWith(PLEROMA_EMOJI_REACTIONS_URL('4'), expect.anything()) + await vi.advanceTimersToNextTimerAsync() + expect(result.conversation.value).to.have.length(20) + expect(result.conversation.value[0]).to.have.property('id', '0') + }) + + it('should reset and fetch another conversation when statusId changes', async () => { + const aConvo = [...new Array(20)].map((i, index) => constructStatus(index + 'a', '1000')) + const aConvoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index + 'a', '1000')) + const bConvo = [...new Array(20)].map((i, index) => constructStatus(index + 'b', '2000')) + const bConvoAPI = [...new Array(20)].map((i, index) => constructStatusAPI(index + 'b', '2000')) + + const mockFetch = vi.fn() + vi.when(mockFetch, { onUnmatched: 'throw' }) + .calledWith(MASTODON_STATUS_URL('4a'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify(aConvoAPI[4]), { + headers: { 'Content-Type': 'application/json' }, + })) + .calledWith(MASTODON_STATUS_CONTEXT_URL('4a'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify({ + ancestors: aConvoAPI.slice(0,4), + descendants: aConvoAPI.slice(5), + }), { + headers: { 'Content-Type': 'application/json' }, + })) + .calledWith(MASTODON_STATUS_URL('4b'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify(bConvoAPI[4]), { + headers: { 'Content-Type': 'application/json' }, + })) + .calledWith(MASTODON_STATUS_CONTEXT_URL('4b'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify({ + ancestors: bConvoAPI.slice(0,4), + descendants: bConvoAPI.slice(5), + }), { + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', mockFetch) + + const statusId = ref('4a') + + const result = useConversation(statusId, true) + await result.fetchConversation() + + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4a'), expect.anything()) + expect(result.conversation.value).to.have.length(1) + expect(result.conversation.value[0]).to.have.property('id', '4a') + expect(result.conversationId.value).to.have.eql('1000') + + await vi.advanceTimersToNextTimerAsync() + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4a'), expect.anything()) + expect(result.conversation.value).to.have.length(20) + expect(result.conversation.value[0]).to.have.property('id', '0a') + + statusId.value = '4b' + + await vi.advanceTimersToNextTimerAsync() + expect(result.conversation.value).to.have.length(0) + + await vi.advanceTimersToNextTimerAsync() + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_URL('4b'), expect.anything()) + expect(result.conversation.value).to.have.length(1) + expect(result.conversation.value[0]).to.have.property('id', '4b') + expect(result.conversationId.value).to.have.eql('2000') + + await vi.advanceTimersToNextTimerAsync() + expect(mockFetch).to.have.been.calledWith(MASTODON_STATUS_CONTEXT_URL('4b'), expect.anything()) + expect(result.conversation.value).to.have.length(20) + expect(result.conversation.value[0]).to.have.property('id', '0b') + }) + + it('should form replies object', async () => { + const convo = [...new Array(4)].map((i, index) => constructStatus(index)) + const convoAPI = [...new Array(4)].map((i, index) => constructStatusAPI(index)) + useStatusesStore().addNewStatuses({ statuses: convo, timestamp: Date.now() }) + + const mockFetch = vi.fn() + vi.when(mockFetch) + .calledWith(MASTODON_STATUS_CONTEXT_URL('2'), expect.anything()) + .thenResolveOnce(new Response(JSON.stringify({ + ancestors: convoAPI.slice(0,1), + descendants: convoAPI.slice(2), + }), { + headers: { 'Content-Type': 'application/json' }, + })) + vi.stubGlobal('fetch', mockFetch) + + const result = useConversation('2', true) + await result.fetchConversation() + + await vi.advanceTimersToNextTimerAsync() + await vi.advanceTimersToNextTimerAsync() + await vi.advanceTimersToNextTimerAsync() + expect(result.conversation.value).to.have.length(4) + expect(result.replies.value).to.eql(new Map([ + ['0', new Set([{ name: '#1', id: '1' }])], + ['1', new Set([{ name: '#2', id: '2' }])], + ['2', new Set([{ name: '#3', id: '3' }])], + ])) + }) +}) diff --git a/test/unit/specs/composables/useMainStatus.spec.js b/test/unit/specs/composables/useMainStatus.spec.js index 2ea2a772f..f6bf4139d 100644 --- a/test/unit/specs/composables/useMainStatus.spec.js +++ b/test/unit/specs/composables/useMainStatus.spec.js @@ -10,7 +10,7 @@ describe('useMainStatus', () => { setActivePinia(createTestingPinia()) useStatusesStore().allStatuses = new Map([ [1, { id: 1 }], - [2, { id: 2, retweeted_status: { id: 1 }}], + [2, { id: 2, retweeted_status: { id: 1 } }], ]) }) @@ -24,7 +24,7 @@ describe('useMainStatus', () => { it('repeat', () => { const { status, mainStatus } = useMainStatus(2) - expect(status.value).to.eql({ id: 2, retweeted_status: { id: 1 }}) + expect(status.value).to.eql({ id: 2, retweeted_status: { id: 1 } }) expect(mainStatus.value).to.eql({ id: 1 }) }) }) diff --git a/test/unit/specs/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js index 51247447f..bc0a92a49 100644 --- a/test/unit/specs/stores/statuses.spec.js +++ b/test/unit/specs/stores/statuses.spec.js @@ -1,6 +1,16 @@ import { createTestingPinia } from '@pinia/testing' import { snakeCase } from 'lodash-es' import { setActivePinia } from 'pinia' +import { + mockMastoAPIStatus, + mockMastoAPIUser, + mockStatus, + mockUser, + userId, + userName, + userScreenName, + userUrl, +} from 'test/fixtures/masto_api.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' @@ -9,80 +19,6 @@ import { useUsersStore } from 'src/stores/users.js' import * as PUBLIC_API from 'src/api/public.js' import * as USER_API from 'src/api/user.js' -const userId = '1' -const userScreenName = 'user' -const userName = 'Guy' -const userUrl = 'http://localhost/user' - -const mockMastoAPIUser = ({ - screen_name = userScreenName, - name = userName, - url = userUrl, - id = userId, -} = {}) => ({ - id, - acct: screen_name, - display_name: name, - fields: [], - avatar: '', - url, - pleroma: { - emoji_reactions: [], - }, -}) - -const mockUser = ({ - screen_name = userScreenName, - id = userId, - name = userName, - url = userUrl, -} = {}) => ({ - _original: mockMastoAPIUser({ - screen_name, - id, - name, - url, - }), - id, - name, - screen_name, - url, - relationship: undefined, -}) - -const mockStatus = ({ - id = '1', - text, - type = 'status', - statusUser = mockUser(), -} = {}) => ({ - id, - user: statusUser, - name: 'status', - text: text ?? `Text number ${id}`, - uri: '', - type, - attentions: [], - statusnet_conversation_id: 'c1', - emoji_reactions: [], -}) - -const mockMastoAPIStatus = ({ - id = '1', - text, - type = 'status', - statusUser = mockMastoAPIUser(), -} = {}) => ({ - id, - account: statusUser, - name: 'status', - content: text ?? `Text number ${id}`, - uri: '', - type, - attentions: [], - statusnet_conversation_id: 'c1', -}) - const DEFAULT_OPTIONS = (method = 'GET') => ({ method, credentials: 'same-origin',