Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
44 KB
Referenced Files
None
Subscribers
None
diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js
index 8edfdc2bea..c1d07228ff 100644
--- a/src/composables/useConversation.js
+++ b/src/composables/useConversation.js
@@ -1,214 +1,214 @@
import { storeToRefs } from 'pinia'
-import { computed, nextTick, ref, watch, toValue } from 'vue'
+import { computed, nextTick, ref, toValue, watch } from 'vue'
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 { useMainStatus } from 'src/composables/useMainStatus.js'
import {
fetchConversation as apiFetchConversation,
fetchStatus as apiFetchStatus,
} from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
export function useConversation(statusId, expanded) {
const loadError = ref(null)
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
// # Config
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus === WSConnectionStatus.JOINED,
)
watch(statusId, (neu, old) => {
if (neu !== old) {
fetchConversation()
}
})
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const fullyLoaded = ref(false)
const conversationId = computed(
() => mainStatus.value?.statusnet_conversation_id ?? null,
)
watch(conversationId, (neu, old) => {
if (neu !== old) fullyLoaded.value = false
})
const conversation = computed(() => {
if (!currentStatus.value) {
return []
}
if (!toValue(expanded) || !fullyLoaded.value) {
return [currentStatus.value]
}
/* It took me a week or so to figure this out.
*
* Virtual scrolling can compensate for posts being prepended to content,
* and prepended posts changing height. The problem is that it has to happen
* in prepended (i.e. either above visible post and/or above screen boundary)
*
* Here's the problem: showing conversation from store can be broken. UI might
* already know that some posts belong to a conversation, because you were
* mentioned in it, but doesn't know the rest of it. It ends up displaying this
* "partial" conversation, and then the rest loads in.
*
* Problem is, this partial conversation can be very fragmented, with missing
* pieces appearing in-between posts. These pieces don't have proper heights
* assigned to them yet but their neigbours do and neither me nor virtual
* scrolling knows how to compensate for it, it ends up either not compensating
* or compensating wrong.
*
* Using "fullyLoaded" ref helps with this, to ensure that we have stable
* conversation expansion process of focused post -> entire convo
*
* With fullyLoaded:
* **id:3** -> id:1 id:2 id:3 id:4 id:5 id:6
*
* Without fullyLoaded:
* id:1 **id:3** id:5 -> id:1 id:2 **id:3** id:4 id:5 id:6
* (focused post is **id:3**)
*
* After initial load, fullyLoaded remains true, allowing newer updates to
* appear in conversation.
*/
const fullConversation = useStatusesStore().conversations.get(
conversationId.value,
)
return [...fullConversation.keys()]
.map((k) => useStatusesStore().allStatuses.get(k))
.filter((status) => status.type != 'repeat') // Old backend behavior?
.toSorted(sortById)
})
const replies = computed(() =>
conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }, index) => {
if (irid) {
if (!result.has(irid)) {
result.set(irid, new Set())
}
result.get(irid).add({
name: `#${index}`,
id,
})
}
return result
},
new Map(),
),
)
const getReplies = (id) => replies.value.get(id) ?? new Set()
const fetchConversation = async () => {
if (currentStatus.value) {
const {
data: { ancestors, descendants },
timestamp,
} = await apiFetchConversation({
id: toValue(statusId),
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
await nextTick()
fullyLoaded.value = true
} else {
try {
loadError.value = null
const { data: status } = await apiFetchStatus({
id: toValue(statusId),
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: [status] })
fetchConversation()
} catch (error) {
console.error(error)
loadError.value = error
}
}
}
watch(
expanded,
(value) => {
if (value) {
fetchConversation()
}
},
{ flush: 'post' },
)
// # Focus
const focused = ref(null)
const { mainStatus: focusedStatus } = useMainStatus(focused)
const setFocused = (id) => {
focused.value = id
}
watch(statusId, (val) => setFocused(val), { immediate: true })
const focusedId = computed(() =>
- (toValue(expanded) && fullyLoaded.value) ? focusedStatus.value?.id : null,
+ toValue(expanded) && fullyLoaded.value ? focusedStatus.value?.id : null,
)
watch(
focusedId,
(newVal, oldVal) => {
if (!newVal) return
if (newVal === oldVal) return // prevents infinite loop
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(newVal)
}
useStatusesStore().fetchFavsAndRepeats(newVal)
useStatusesStore().fetchEmojiReactions(newVal)
},
{ immediate: true },
)
return {
focusedId,
setFocused,
conversationId,
currentStatus,
mainStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
}
}
diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js
index 6cd2575a14..243c08c8b2 100644
--- a/src/composables/useScrollPosition.js
+++ b/src/composables/useScrollPosition.js
@@ -1,30 +1,28 @@
import { onMounted, onUnmounted, ref } from 'vue'
-import { useWindowSize } from 'src/composables/useWindowSize.js'
-
export function useScrollPosition() {
const x = ref(0)
const y = ref(0)
const inProgress = ref(false)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
const scrollBy = async (...args) => {
inProgress.value = true
await window.scrollBy(...args)
inProgress.value = false
}
return { x, y, scrollBy, inProgress }
}
diff --git a/src/composables/useTreeConversationTopology.js b/src/composables/useTreeConversationTopology.js
index 1136112b51..b1baed9980 100644
--- a/src/composables/useTreeConversationTopology.js
+++ b/src/composables/useTreeConversationTopology.js
@@ -1,120 +1,119 @@
import { storeToRefs } from 'pinia'
import { computed, ref, toValue } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
export function useTreeConversationTopology(conversation, replies, current) {
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const getReplies = (id) => toValue(replies).get(id) ?? new Set()
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const maxDepthToShowByDefault = computed(() => {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = mergedConfig.value.maxDepthInThread - 2
return Math.max(1, maxDepth)
})
const ancestors = computed(() => {
// First we fill map with empty sets and add given id's parent
// as set's only element (if any)
const parentMap = toValue(conversation).reduce(
(result, { id, in_reply_to_status_id: irid }) => {
if (!result.has(id)) {
result.set(id, new Set())
}
if (irid && toValue(conversation).length !== 1) {
// Setting parent for current item
result.get(id).add(irid)
}
return result
},
new Map(),
)
// Next we iterate over each entry and fill the whole ancestry chain
parentMap.entries().forEach(([originId, originSet]) => {
let current = originSet.values().next().value
while (current) {
originSet.add(current)
const parent = parentMap.get(current) ?? new Set()
current = parent.values().next().value
}
})
return parentMap
})
const topLevelIds = computed(() =>
[...ancestors.value.entries()]
.filter(([id, ancestors]) => ancestors.size === 0)
.map(([id]) => id),
)
const topLevel = computed(() =>
topLevelIds.value.map((id) => getStatusObject(id)),
)
const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
const getAncestors = (id) =>
[...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
const currentAncestors = computed(() =>
getAncestors(toValue(current)).reverse(),
)
const currentDepth = computed(() => currentAncestors.value.length)
// Thread Display, for collapsing/expanding tree branches
// Map of id => 'showing' | 'hidden'
const threadDisplayOverride = ref(new Map())
const threadDisplayDefault = computed(() => {
return toValue(conversation).reduce((map, status) => {
const { id } = status
const depth = ancestors.value.get(id).size
const state = (() => {
- console.log(toValue(currentAncestors))
if (depth - currentDepth.value <= maxDepthToShowByDefault.value) {
return 'showing'
} else {
return 'hidden'
}
})()
map.set(id, state)
return map
}, new Map())
})
const threadDisplay = computed(() => {
return new Map(
[...threadDisplayDefault.value.entries()].map(([k, v]) => [
k,
threadDisplayOverride.value.get(k) ?? threadDisplayDefault.value.get(k),
]),
)
})
const setThreadDisplayRecursively = (id, value) => {
threadDisplayOverride.value.set(id, value)
;[...getReplies(id)]
.map((k) => k.id)
.map((id) => setThreadDisplayRecursively(id, value))
}
const showThreadRecursively = (id) => {
setThreadDisplayRecursively(id, 'showing')
}
const resetThreadDisplay = () => {
threadDisplayOverride.value = new Map()
}
return {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
// For testing
ancestors,
}
}
diff --git a/test/unit/specs/composables/useConversation.spec.js b/test/unit/specs/composables/useConversation.spec.js
index b8873d0ece..cb274ef2a3 100644
--- a/test/unit/specs/composables/useConversation.spec.js
+++ b/test/unit/specs/composables/useConversation.spec.js
@@ -1,251 +1,316 @@
import { createTestingPinia } from '@pinia/testing'
-import { flushPromises, mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
+import { mockMastoAPIStatus, mockStatus } from 'test/fixtures/masto_api.js'
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,
+ MASTODON_STATUS_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)])
+ [...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 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' },
- }))
+ .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' },
- }))
+ .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(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(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 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' },
- }))
+ .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())
+ 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())
+ 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 aConvoAPI = [...new Array(20)].map((i, index) =>
+ constructStatusAPI(index + 'a', '1000'),
+ )
+ 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' },
- }))
+ .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' },
- }))
+ .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' },
- }))
+ .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' },
- }))
+ .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(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(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(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(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 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' },
- }))
+ .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' }])],
- ]))
+ 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/stores/statuses.spec.js b/test/unit/specs/stores/statuses.spec.js
index bc0a92a49e..50becc0f0d 100644
--- a/test/unit/specs/stores/statuses.spec.js
+++ b/test/unit/specs/stores/statuses.spec.js
@@ -1,601 +1,597 @@
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'
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 DEFAULT_OPTIONS = (method = 'GET') => ({
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
describe('Statuses store', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('init', () => {
const store = useStatusesStore()
const sub = vi.fn()
useStreamingStore().addSubscriber = sub
store.attachSocket()
expect(store.socket).to.not.be.null
expect(sub).to.have.been.called
})
it('resetStatuses', () => {
const store = useStatusesStore()
const statuses = [...new Array(20)].map((empty, index) =>
mockStatus({
id: 's' + index,
statusUser: mockUser({ id: 'u' + index }),
}),
)
store.attachSocket()
store.addNewStatuses({
statuses,
timestamp: 1,
})
store.resetStatuses()
expect(store.allStatuses).to.have.length(0)
})
describe('addNewStatuses', () => {
beforeEach(() => {
const usersStore = useUsersStore()
usersStore.addNewUsers = vi.fn().mockReturnValue([mockUser()])
})
it('adds the status to allStatuses', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
expect(store.conversations).to.eql(new Map([['c1', new Set(['1'])]]))
expect(usersStore.addNewUsers).to.have.callCount(1)
})
it('splits retweets from their status and links them', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const retweet = mockStatus({
id: '2',
type: 'retweet',
user: mockUser({ id: '2' }),
})
retweet.type = 'retweet'
retweet.retweeted_status = status
store.addNewStatuses({
statuses: [retweet],
timestamp: 1,
})
expect(store.allStatuses).to.eql(
new Map([
['1', status],
['2', retweet],
]),
)
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('splits quotes from their status and links them', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const quote = mockStatus({ id: '2' })
quote.quote = status
store.addNewStatuses({
statuses: [quote],
timestamp: 1,
})
expect(store.allStatuses).to.eql(
new Map([
['1', status],
['2', quote],
]),
)
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('replaces existing statuses with the same id', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('handles edits', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({
id: '1',
text: 'something else',
type: 'edit',
})
store.addNewStatuses({
statuses: [status],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', modStatus]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('ignores updates with older timestamp', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
store.addNewStatuses({
statuses: [modStatus],
timestamp: 1991,
})
expect(store.allStatuses).to.eql(new Map([['1', status]]))
expect(usersStore.addNewUsers).to.have.callCount(2)
})
it('calls useUsersStore().addNewUsers() even on older timestamp', () => {
const store = useStatusesStore()
const usersStore = useUsersStore()
const status = mockStatus({ id: '1' })
const modStatus = mockStatus({ id: '1', text: 'something else' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
store.addNewStatuses({
statuses: [modStatus],
timestamp: 1991,
})
expect(usersStore.addNewUsers).to.have.callCount(2)
})
})
describe('fetchers', () => {
it.each([
[
'StatusSource',
{
content_type: 'text/plain',
text: 'Text',
spoiler_text: 'Text',
},
],
[
'EmojiReactions',
[
{
accounts: [
mockMastoAPIUser({ id: 'u1' }),
mockMastoAPIUser({ id: 'u2' }),
],
count: 1,
me: false,
name: 'cofe',
url: '',
},
],
],
[
'Favs',
[mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })],
],
[
'Repeats',
[mockMastoAPIUser({ id: 'u1' }), mockMastoAPIUser({ id: 'u2' })],
],
])('fetch%s', async (group, mockedResponse) => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockedResponse), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const addNewUsers = vi.spyOn(useUsersStore(), 'addNewUsers')
let urlKey
let prefix = 'MASTODON'
if (group === 'Favs') {
urlKey = 'STATUS_FAVORITEDBY'
} else if (group === 'Repeats') {
urlKey = 'STATUS_REBLOGGEDBY'
} else {
urlKey = snakeCase(group).toUpperCase()
}
if (group === 'EmojiReactions') {
prefix = 'PLEROMA'
}
const url = PUBLIC_API[`${prefix}_${urlKey}_URL`]('id')
const store = useStatusesStore()
const status = mockStatus({ id: 'id' })
store.addNewStatuses({
statuses: [status],
timestamp: 2000,
})
const result = await store[`fetch${group}`]('id')
const updated = store.allStatuses.get('id')
// Fetch called
expect(mockFetch).to.have.been.calledWith(url, DEFAULT_OPTIONS())
// Users updated
if (group !== 'StatusSource') {
// first call is the one for the status
expect(addNewUsers).to.have.been.calledTwice
const secondCallData = addNewUsers.mock.calls[1][0].data
expect(secondCallData).to.have.length(2)
expect(secondCallData[0]).to.have.property('id', 'u1')
expect(secondCallData[1]).to.have.property('id', 'u2')
}
if (group === 'Favs') {
expect(store.favs).to.have.length(1)
expect(store.favs.get('id')).to.have.length(2)
expect(store.favs.get('id')).to.eql(new Set(['u1', 'u2']))
expect(updated.fave_num).to.eql(2)
} else if (group === 'Repeats') {
expect(store.repeats).to.have.length(1)
expect(store.repeats.get('id')).to.have.length(2)
expect(store.repeats.get('id')).to.eql(new Set(['u1', 'u2']))
expect(updated.repeat_num).to.eql(2)
} else if (group === 'EmojiReactions') {
expect(updated.emoji_reactions).to.have.length(mockedResponse.length)
expect(updated.emoji_reactions[0].name).to.eql(mockedResponse[0].name)
} else {
expect(result).to.eql(mockedResponse)
}
})
it('fetchFavsAndRepeats', async () => {
const store = useStatusesStore()
store.fetchFavs = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
store.fetchRepeats = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
await store.fetchFavsAndRepeats('id')
expect(store.fetchFavs).to.have.been.calledWith('id')
expect(store.fetchRepeats).to.have.been.calledWith('id')
})
it('fetchStatus', async () => {
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus()), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useStatusesStore()
await store.fetchStatus('id')
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API.MASTODON_STATUS_URL('id'),
DEFAULT_OPTIONS(),
)
expect(store.allStatuses).to.have.length(1)
})
})
describe('interactions', () => {
it.each([
['favorite', 'MASTODON_FAVORITE_URL'],
['unfavorite', 'MASTODON_UNFAVORITE_URL'],
['retweet', 'MASTODON_RETWEET_URL'],
['unretweet', 'MASTODON_UNRETWEET_URL'],
['reactWithEmoji', 'PLEROMA_EMOJI_REACT_URL', 'PUT'],
['unreactWithEmoji', 'PLEROMA_EMOJI_UNREACT_URL', 'DELETE'],
[
'bookmark',
'MASTODON_BOOKMARK_STATUS_URL',
undefined,
{ folder_id: 'argument' },
],
['unbookmark', 'MASTODON_UNBOOKMARK_STATUS_URL'],
['pinStatus', 'MASTODON_PIN_OWN_STATUS_URL'],
['unpinStatus', 'MASTODON_UNPIN_OWN_STATUS_URL'],
['muteConversation', 'MASTODON_MUTE_CONVERSATION_URL'],
['unmuteConversation', 'MASTODON_UNMUTE_CONVERSATION_URL'],
['deleteStatus', 'MASTODON_DELETE_URL', 'DELETE'],
])('%s - api call', async (interaction, urlKey, method = 'POST', body) => {
const url = USER_API[urlKey]('1', 'argument')
const status = mockStatus()
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
await store[interaction]('1', 'argument')
const expectedArg = DEFAULT_OPTIONS(method)
if (body) {
expectedArg.body = JSON.stringify(body)
}
expect(mockFetch).to.have.been.calledWith(url, expectedArg)
if (interaction === 'deleteStatus') {
expect(store.allStatuses.get('1').deleted).to.be.true
} else {
expect(store.allStatuses.get('1').text).to.eql('Updated')
}
})
const optimismInteractions = [
['favorite', 'favorited', 'fave_num'],
['retweet', 'repeated', 'repeat_num'],
['bookmark', 'bookmarked'],
['muteConversation', 'thread_muted'],
]
.map(([method, property, count]) => [
[method, property, count],
['un' + method, property, count],
])
.flat()
it.each(optimismInteractions)(
'%s - optimism call',
async (method, property, count) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const oldCount = 9
const newCount = negate ? 8 : 10
status[property] = negate
if (count) {
status[count] = oldCount
}
if (method === 'unbookmark') {
status.bookmark_folder_id = 'argument'
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
vi.stubGlobal('fetch', mockFetch)
store[method]('1', 'argument')
expect(store.allStatuses.get('1')).to.have.property(property, !negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, newCount)
}
if (property === 'bookmarked') {
expect(store.allStatuses.get('1')).to.have.property(
'bookmark_folder_id',
'argument',
)
}
},
)
it.each(optimismInteractions)(
'%s - optimism fail',
async (method, property, count) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const oldCount = 9
status[property] = negate
if (count) {
status[count] = oldCount
}
if (method === 'unbookmark') {
status.bookmark_folder_id = 'argument'
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockRejectedValueOnce(new Error('Failure!'))
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
vi.stubGlobal('fetch', mockFetch)
await store[method]('1', 'argument')
expect(store.allStatuses.get('1')).to.have.property(property, negate)
if (count) {
expect(store.allStatuses.get('1')).to.have.property(count, oldCount)
}
// Failing 'bookmark' method SHOULD clear folder id
if (method === 'unbookmark') {
expect(store.allStatuses.get('1')).to.have.property(
'bookmark_folder_id',
'argument',
)
}
},
)
it.each([
['reactWithEmoji', 0],
['unreactWithEmoji', 1],
['reactWithEmoji', 1],
['unreactWithEmoji', 2],
])('%s count: %s - optimism call', async (method, oldCount) => {
// Prepare our status
const status = mockStatus()
const negate = method.startsWith('un')
const newCount = negate ? oldCount - 1 : oldCount + 1
useUsersStore().currentUser = mockUser()
if (oldCount > 0) {
const reactors = [...new Array(oldCount)].map((empty, index) =>
mockUser({ id: 'o' + index }),
)
if (negate) {
// Replace one of reactors with ourselves
reactors[0] = useUsersStore().currentUser
}
status.emoji_reactions = [
{
name: 'hyperlol',
count: oldCount,
accounts: reactors,
},
]
} else {
status.emoji_reactions = []
}
// Insert it
const store = useStatusesStore()
store.addNewStatuses({
statuses: [status],
timestamp: 1,
})
const mockFetch = vi.fn()
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIStatus({ text: 'Updated' })), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const expected = store.allStatuses.get('1')
expect(expected.emoji_reactions).to.have.length(oldCount === 0 ? 0 : 1)
if (oldCount !== 0) {
expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
expect(expected.emoji_reactions[0].count).to.eql(oldCount)
expect(expected.emoji_reactions[0].accounts).to.have.length(oldCount)
}
store[method]('1', 'hyperlol')
expect(expected.emoji_reactions).to.have.length(newCount === 0 ? 0 : 1)
if (newCount !== 0) {
expect(expected.emoji_reactions[0].name).to.eql('hyperlol')
expect(expected.emoji_reactions[0].count).to.eql(newCount)
expect(expected.emoji_reactions[0].accounts).to.have.length(newCount)
}
})
})
it('wipeUserStatuses', () => {
const store = useStatusesStore()
const statuses = [...new Array(20)].map((empty, index) =>
mockStatus({
id: 's' + index,
statusUser: mockUser({ id: 'u' + index }),
}),
)
store.addNewStatuses({
statuses,
timestamp: 1,
})
const result = store.wipeUserStatuses('u19')
expect(store.allStatuses).to.have.length(19)
expect(result).to.eql(new Set(['s19']))
})
})

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 31, 6:14 AM (17 h, 6 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801108
Default Alt Text
(44 KB)

Event Timeline