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