Merge branch 'virtual-scrolling-2.0' into shigusegubu-themes3
This commit is contained in:
commit
73888c861f
17 changed files with 736 additions and 199 deletions
|
|
@ -25,7 +25,8 @@ export const MASTODON_FOLLOWERS_URL = (
|
||||||
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
|
`/api/v1/accounts/${id}/followers${paramsString({ minId, maxId, sinceId, limit, withRelationships })}`
|
||||||
|
|
||||||
export const MASTODON_STATUS_URL = (id) => `/api/v1/statuses/${id}`
|
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) =>
|
export const MASTODON_STATUS_SOURCE_URL = (id) =>
|
||||||
`/api/v1/statuses/${id}/source`
|
`/api/v1/statuses/${id}/source`
|
||||||
export const MASTODON_STATUS_HISTORY_URL = (id) =>
|
export const MASTODON_STATUS_HISTORY_URL = (id) =>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
|
nextTick,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
provide,
|
provide,
|
||||||
ref,
|
ref,
|
||||||
toRefs,
|
toRefs,
|
||||||
useTemplateRef,
|
useTemplateRef,
|
||||||
watch,
|
watch,
|
||||||
nextTick,
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
|
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
|
||||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||||
|
|
@ -68,7 +67,6 @@ export default {
|
||||||
},
|
},
|
||||||
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
|
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
|
||||||
const scroller = useScrollPosition()
|
const scroller = useScrollPosition()
|
||||||
const { statusId } = toRefs(props)
|
const { statusId } = toRefs(props)
|
||||||
|
|
||||||
|
|
@ -97,7 +95,9 @@ export default {
|
||||||
provide('isExpanded', isExpanded)
|
provide('isExpanded', isExpanded)
|
||||||
provide('isPage', isPage)
|
provide('isPage', isPage)
|
||||||
provide('expandable', true)
|
provide('expandable', true)
|
||||||
watch(expanded, (val) => val ? emit('expanded') : emit('collapsed'), { flush: 'post' })
|
watch(expanded, (val) => (val ? emit('expanded') : emit('collapsed')), {
|
||||||
|
flush: 'post',
|
||||||
|
})
|
||||||
|
|
||||||
// # Main things
|
// # Main things
|
||||||
const {
|
const {
|
||||||
|
|
@ -105,7 +105,6 @@ export default {
|
||||||
conversationId,
|
conversationId,
|
||||||
setFocused,
|
setFocused,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
mainStatus,
|
|
||||||
conversation,
|
conversation,
|
||||||
replies,
|
replies,
|
||||||
getReplies,
|
getReplies,
|
||||||
|
|
@ -115,17 +114,9 @@ export default {
|
||||||
const conversationLite = computed(() =>
|
const conversationLite = computed(() =>
|
||||||
conversation.value.map(({ id }) => ({ id })),
|
conversation.value.map(({ id }) => ({ id })),
|
||||||
)
|
)
|
||||||
const mainStatusId = computed(() => mainStatus.value.id)
|
provide('focusedId', focusedId)
|
||||||
|
provide('conversation', conversation)
|
||||||
watch(
|
provide('replies', replies)
|
||||||
expanded,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
fetchConversation()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ flush: 'post' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Component created
|
// Component created
|
||||||
if (isPage.value) {
|
if (isPage.value) {
|
||||||
|
|
@ -149,7 +140,7 @@ export default {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// External virtual scrolling
|
// # External virtual scrolling
|
||||||
const unsuspendableIds = ref(new Set())
|
const unsuspendableIds = ref(new Set())
|
||||||
const suspendable = computed(
|
const suspendable = computed(
|
||||||
() => !isExpanded.value && unsuspendableIds.value.size === 0,
|
() => !isExpanded.value && unsuspendableIds.value.size === 0,
|
||||||
|
|
@ -176,32 +167,30 @@ export default {
|
||||||
emit('suspendableStateChange', { suspend: value, id: statusId.value }),
|
emit('suspendableStateChange', { suspend: value, id: statusId.value }),
|
||||||
)
|
)
|
||||||
onUnmounted(() => resizeObserver.value.disconnect())
|
onUnmounted(() => resizeObserver.value.disconnect())
|
||||||
// Internal virtual scrolling
|
|
||||||
|
// # Internal virtual scrolling
|
||||||
const virtualScrollingEnabled = ref(isExpanded.value)
|
const virtualScrollingEnabled = ref(isExpanded.value)
|
||||||
|
|
||||||
// Placeholder heights.
|
// Placeholder heights.
|
||||||
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
|
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
|
||||||
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
|
|
||||||
const normalStatusHeight = computed(() => fontSize.value * 10)
|
const normalStatusHeight = computed(() => fontSize.value * 10)
|
||||||
const getPlaceholderHeight = (id) =>
|
const getPlaceholderHeight = (id) => normalStatusHeight
|
||||||
conversation.value.find((item) => item.id === id)?.muted
|
|
||||||
? mutedStatusHeight
|
|
||||||
: normalStatusHeight
|
|
||||||
const offset = computed(() => {
|
const offset = computed(() => {
|
||||||
// The fontsize after navbar is the little gap between navbar and content
|
// The fontsize after navbar is the little gap between navbar and content
|
||||||
if (isPage.value) {
|
if (isPage.value) {
|
||||||
return navbarSize.value + fontSize.value + panelHeaderSize.value
|
return navbarSize.value + fontSize.value + panelHeaderSize.value
|
||||||
} else if (expanded.value) {
|
} else if (expanded.value) {
|
||||||
return navbarSize.value + fontSize.value + panelHeaderSize.value * 2 + rootElementMargin.value
|
return (
|
||||||
|
navbarSize.value +
|
||||||
|
fontSize.value +
|
||||||
|
panelHeaderSize.value * 2 +
|
||||||
|
rootElementMargin.value
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
return navbarSize.value + fontSize.value
|
return navbarSize.value + fontSize.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const anchorIds = computed(
|
|
||||||
() => new Set([mainStatus.value?.id, currentStatus.value?.id]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// # Linear style stuff
|
// # Linear style stuff
|
||||||
const isLinearView = computed(() => displayStyle.value !== 'tree')
|
const isLinearView = computed(() => displayStyle.value !== 'tree')
|
||||||
const linearElement = useTemplateRef('linear')
|
const linearElement = useTemplateRef('linear')
|
||||||
|
|
@ -222,8 +211,6 @@ export default {
|
||||||
offset,
|
offset,
|
||||||
scrollPositionInstance: scroller,
|
scrollPositionInstance: scroller,
|
||||||
scrollCompensation: linearScrollCompensation,
|
scrollCompensation: linearScrollCompensation,
|
||||||
anchorIds,
|
|
||||||
collapseMode: 'item',
|
|
||||||
getPlaceholderHeight,
|
getPlaceholderHeight,
|
||||||
})
|
})
|
||||||
const changeSuspendStateLinearLocal = (e) => {
|
const changeSuspendStateLinearLocal = (e) => {
|
||||||
|
|
@ -301,7 +288,6 @@ export default {
|
||||||
resetTreeScrollVirtualization()
|
resetTreeScrollVirtualization()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const treeViewIsSimple = computed(
|
const treeViewIsSimple = computed(
|
||||||
() => !mergedConfig.value.conversationTreeAdvanced,
|
() => !mergedConfig.value.conversationTreeAdvanced,
|
||||||
)
|
)
|
||||||
|
|
@ -318,8 +304,8 @@ export default {
|
||||||
return linearScrollTo(ids)
|
return linearScrollTo(ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const diveIntoStatus = (id) => scrollTo(new Set([id]))
|
const diveIntoStatus = (id) => setFocused(id)
|
||||||
const diveToTopLevel = () => scrollTo(new Set([currentAncestors.value[0].id]))
|
const diveToTopLevel = () => setFocused(currentAncestors.value[0].id)
|
||||||
|
|
||||||
watch(focusedId, async (neu, old) => {
|
watch(focusedId, async (neu, old) => {
|
||||||
// Ignoring initial update (null -> id) since that is handled by scroll compensation
|
// Ignoring initial update (null -> id) since that is handled by scroll compensation
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<ThreadTree
|
<ThreadTree
|
||||||
:status-id="currentStatus.id"
|
:status-id="focusedId ?? currentStatus.id"
|
||||||
:depth="0"
|
:depth="0"
|
||||||
|
|
||||||
@goto="setFocused"
|
@goto="setFocused"
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,9 @@ const Status = {
|
||||||
user() {
|
user() {
|
||||||
return useUsersStore().findUser(this.mainStatus.user.id)
|
return useUsersStore().findUser(this.mainStatus.user.id)
|
||||||
},
|
},
|
||||||
|
isTreeView() {
|
||||||
|
return this.mergedConfig.conversationDisplay === 'tree'
|
||||||
|
},
|
||||||
simpleTree() {
|
simpleTree() {
|
||||||
return !this.mergedConfig.conversationTreeAdvanced
|
return !this.mergedConfig.conversationTreeAdvanced
|
||||||
},
|
},
|
||||||
|
|
@ -312,6 +315,7 @@ const Status = {
|
||||||
hasMentionsLine() {
|
hasMentionsLine() {
|
||||||
return this.mentionsLine.length > 0
|
return this.mentionsLine.length > 0
|
||||||
},
|
},
|
||||||
|
// TODO move muting logic into some store
|
||||||
muteReasons() {
|
muteReasons() {
|
||||||
return [
|
return [
|
||||||
this.userIsMuted ? 'user' : null,
|
this.userIsMuted ? 'user' : null,
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="isExpanded && !simpleTree"
|
v-if="isExpanded && isTreeView && !simpleTree"
|
||||||
class="button-unstyled"
|
class="button-unstyled"
|
||||||
:title="$t('status.show_only_conversation_under_this')"
|
:title="$t('status.show_only_conversation_under_this')"
|
||||||
@click.prevent="$emit('dive')"
|
@click.prevent="$emit('dive')"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
toRefs,
|
toRefs,
|
||||||
useTemplateRef,
|
useTemplateRef,
|
||||||
watch,
|
watch,
|
||||||
nextTick,
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ const Timeline = {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Virtual scrolling
|
// Virtual scrolling
|
||||||
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
|
const { fontSize, navbarSize } = useInterfaceSizes()
|
||||||
|
|
||||||
// Placeholder heights.
|
// Placeholder heights.
|
||||||
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
|
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
|
||||||
|
|
@ -83,17 +82,13 @@ const Timeline = {
|
||||||
const body = useTemplateRef('timeline')
|
const body = useTemplateRef('timeline')
|
||||||
const offset = computed(() => {
|
const offset = computed(() => {
|
||||||
if (embedded.value) {
|
if (embedded.value) {
|
||||||
// The fontsize after navbar is the little gap between navbar and content
|
// The fontsize after navbar is the little gap between navbar and content
|
||||||
return navbarSize.value + fontSize.value
|
return navbarSize.value + fontSize.value
|
||||||
} else {
|
} else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const {
|
const { heightChart, changeSuspendState, updateVirtualHeight } =
|
||||||
heightChart,
|
|
||||||
changeSuspendState,
|
|
||||||
updateVirtualHeight,
|
|
||||||
} =
|
|
||||||
useVirtualScrolling({
|
useVirtualScrolling({
|
||||||
name: 'Timeline',
|
name: 'Timeline',
|
||||||
enabled: ref(true),
|
enabled: ref(true),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, provide, ref, watch, nextTick } from 'vue'
|
import { computed, nextTick, ref, toValue, watch } from 'vue'
|
||||||
|
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||||
|
|
@ -17,7 +17,6 @@ import { WSConnectionStatus } from 'src/api/websocket.js'
|
||||||
export function useConversation(statusId, expanded) {
|
export function useConversation(statusId, expanded) {
|
||||||
const loadError = ref(null)
|
const loadError = ref(null)
|
||||||
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
|
const { status: currentStatus, mainStatus } = useMainStatus(statusId)
|
||||||
const mainStatusId = computed(() => mainStatus.value?.id)
|
|
||||||
|
|
||||||
// # Config
|
// # Config
|
||||||
const { mergedConfig } = storeToRefs(useMergedConfigStore())
|
const { mergedConfig } = storeToRefs(useMergedConfigStore())
|
||||||
|
|
@ -51,10 +50,9 @@ export function useConversation(statusId, expanded) {
|
||||||
return idA < idB ? -1 : 1
|
return idA < idB ? -1 : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fullConversation = ref(new Set([mainStatus.value?.id].filter(Boolean)))
|
|
||||||
const fullyLoaded = ref(false)
|
const fullyLoaded = ref(false)
|
||||||
const conversationId = computed(
|
const conversationId = computed(
|
||||||
() => mainStatus.value?.statusnet_conversation_id,
|
() => mainStatus.value?.statusnet_conversation_id ?? null,
|
||||||
)
|
)
|
||||||
watch(conversationId, (neu, old) => {
|
watch(conversationId, (neu, old) => {
|
||||||
if (neu !== old) fullyLoaded.value = false
|
if (neu !== old) fullyLoaded.value = false
|
||||||
|
|
@ -64,11 +62,45 @@ export function useConversation(statusId, expanded) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!expanded.value) {
|
if (!toValue(expanded) || !fullyLoaded.value) {
|
||||||
return [currentStatus.value]
|
return [currentStatus.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...fullConversation.value.keys()]
|
/* 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))
|
.map((k) => useStatusesStore().allStatuses.get(k))
|
||||||
.filter((status) => status.type != 'repeat') // Old backend behavior?
|
.filter((status) => status.type != 'repeat') // Old backend behavior?
|
||||||
.toSorted(sortById)
|
.toSorted(sortById)
|
||||||
|
|
@ -92,8 +124,6 @@ export function useConversation(statusId, expanded) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const getReplies = (id) => replies.value.get(id) ?? new Set()
|
const getReplies = (id) => replies.value.get(id) ?? new Set()
|
||||||
provide('conversation', conversation)
|
|
||||||
provide('replies', replies)
|
|
||||||
|
|
||||||
const fetchConversation = async () => {
|
const fetchConversation = async () => {
|
||||||
if (currentStatus.value) {
|
if (currentStatus.value) {
|
||||||
|
|
@ -101,7 +131,7 @@ export function useConversation(statusId, expanded) {
|
||||||
data: { ancestors, descendants },
|
data: { ancestors, descendants },
|
||||||
timestamp,
|
timestamp,
|
||||||
} = await apiFetchConversation({
|
} = await apiFetchConversation({
|
||||||
id: statusId.value,
|
id: toValue(statusId),
|
||||||
credentials: useOAuthStore().token,
|
credentials: useOAuthStore().token,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -111,12 +141,6 @@ export function useConversation(statusId, expanded) {
|
||||||
timestamp,
|
timestamp,
|
||||||
})
|
})
|
||||||
|
|
||||||
fullConversation.value = new Set([
|
|
||||||
...ancestors,
|
|
||||||
mainStatus.value,
|
|
||||||
...descendants
|
|
||||||
].map(({ id }) => id))
|
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
fullyLoaded.value = true
|
fullyLoaded.value = true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -124,14 +148,12 @@ export function useConversation(statusId, expanded) {
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
|
|
||||||
const { data: status } = await apiFetchStatus({
|
const { data: status } = await apiFetchStatus({
|
||||||
id: statusId.value,
|
id: toValue(statusId),
|
||||||
credentials: useOAuthStore().token,
|
credentials: useOAuthStore().token,
|
||||||
})
|
})
|
||||||
|
|
||||||
useStatusesStore().addNewStatuses({ statuses: [status] })
|
useStatusesStore().addNewStatuses({ statuses: [status] })
|
||||||
fullConversation.value = new Set([
|
|
||||||
currentStatus.value,
|
|
||||||
].map(({ id }) => id))
|
|
||||||
fetchConversation()
|
fetchConversation()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
@ -140,6 +162,16 @@ export function useConversation(statusId, expanded) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
expanded,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
fetchConversation()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
// # Focus
|
// # Focus
|
||||||
const focused = ref(null)
|
const focused = ref(null)
|
||||||
const { mainStatus: focusedStatus } = useMainStatus(focused)
|
const { mainStatus: focusedStatus } = useMainStatus(focused)
|
||||||
|
|
@ -148,28 +180,29 @@ export function useConversation(statusId, expanded) {
|
||||||
}
|
}
|
||||||
watch(statusId, (val) => setFocused(val), { immediate: true })
|
watch(statusId, (val) => setFocused(val), { immediate: true })
|
||||||
|
|
||||||
const focusedId = computed(() => (expanded.value && fullyLoaded.value) ? focusedStatus.value?.id : null)
|
const focusedId = computed(() =>
|
||||||
provide('focusedId', focusedId)
|
toValue(expanded) && fullyLoaded.value ? focusedStatus.value?.id : null,
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
focusedStatus,
|
focusedId,
|
||||||
(newVal, oldVal) => {
|
(newVal, oldVal) => {
|
||||||
if (!newVal) return
|
if (!newVal) return
|
||||||
if (newVal?.id === oldVal?.id) return // prevents infinite loop
|
if (newVal === oldVal) return // prevents infinite loop
|
||||||
if (!streamingEnabled.value) {
|
if (!streamingEnabled.value) {
|
||||||
useStatusesStore().fetchStatus(newVal.id)
|
useStatusesStore().fetchStatus(newVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
useStatusesStore().fetchFavsAndRepeats(newVal.id)
|
useStatusesStore().fetchFavsAndRepeats(newVal)
|
||||||
useStatusesStore().fetchEmojiReactions(newVal.id)
|
useStatusesStore().fetchEmojiReactions(newVal)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focusedId,
|
focusedId,
|
||||||
conversationId,
|
|
||||||
setFocused,
|
setFocused,
|
||||||
|
conversationId,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
mainStatus,
|
mainStatus,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,17 @@ export function useInterfaceSizes() {
|
||||||
watch(fontSizeSetting, updateFontSize, { immediate: true })
|
watch(fontSizeSetting, updateFontSize, { immediate: true })
|
||||||
|
|
||||||
const navbarSize = computed(() => {
|
const navbarSize = computed(() => {
|
||||||
const string =
|
const string = window
|
||||||
window.getComputedStyle(document.body).getPropertyValue('--navbarSize')
|
.getComputedStyle(document.body)
|
||||||
|
.getPropertyValue('--navbarSize')
|
||||||
|
|
||||||
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
|
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelHeaderSize = computed(() => {
|
const panelHeaderSize = computed(() => {
|
||||||
const string =
|
const string = window
|
||||||
window
|
.getComputedStyle(document.body)
|
||||||
.getComputedStyle(document.body)
|
.getPropertyValue('--panelHeaderSize')
|
||||||
.getPropertyValue('--panelHeaderSize')
|
|
||||||
|
|
||||||
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
|
return fontSize.value * Number.parseInt(string.slice(0, -3), 10) // remove the 'rem'
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { computed, toValue } from 'vue'
|
import { computed, toValue } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
|
|
||||||
import { useStatusesStore } from 'src/stores/statuses.js'
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
|
|
||||||
export function useMainStatus(statusId) {
|
export function useMainStatus(statusId) {
|
||||||
const statusesStore = storeToRefs(useStatusesStore())
|
const statusesStore = useStatusesStore()
|
||||||
const getStatusObject = (id) => statusesStore.allStatuses.value.get(id)
|
const getStatusObject = (id) => statusesStore.allStatuses.get(id)
|
||||||
|
|
||||||
const status = computed(() => getStatusObject(statusId.value))
|
const status = computed(() => getStatusObject(toValue(statusId)) ?? null)
|
||||||
|
|
||||||
const mainStatus = computed(() => {
|
const mainStatus = computed(() => {
|
||||||
if (!status.value) return null
|
if (!status.value) return null
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { useWindowSize } from 'src/composables/useWindowSize.js'
|
|
||||||
|
|
||||||
export function useScrollPosition() {
|
export function useScrollPosition() {
|
||||||
const x = ref(0)
|
const x = ref(0)
|
||||||
const y = ref(0)
|
const y = ref(0)
|
||||||
|
|
@ -26,25 +24,5 @@ export function useScrollPosition() {
|
||||||
inProgress.value = false
|
inProgress.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollIntoView = async (element, options) => {
|
return { x, y, scrollBy, inProgress }
|
||||||
if (element == null) throw new TypeError(`Element is ${element}!`)
|
|
||||||
inProgress.value = true
|
|
||||||
if (!element.scrollIntoViewIfNeeded) {
|
|
||||||
const { height: windowHeight } = useWindowSize()
|
|
||||||
const { top, height } = element.getBoundingClientRect()
|
|
||||||
const bottom = top + height
|
|
||||||
|
|
||||||
const biggerThanScreen = height > windowHeight
|
|
||||||
const aboveTop = top < 0
|
|
||||||
const belowBottom = bottom > windowHeight.value
|
|
||||||
if (aboveTop || belowBottom || biggerThanScreen) {
|
|
||||||
await element.scrollIntoView(options)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await element.scrollIntoViewIfNeeded(options)
|
|
||||||
}
|
|
||||||
inProgress.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x, y, scrollBy, scrollIntoView, inProgress }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, toValue } from 'vue'
|
||||||
|
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
import { useStatusesStore } from 'src/stores/statuses.js'
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
|
|
||||||
export function useTreeConversationTopology(conversation, replies, current) {
|
export function useTreeConversationTopology(conversation, replies, current) {
|
||||||
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
|
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
|
||||||
const getReplies = (id) => replies.value.get(id) ?? new Set()
|
const getReplies = (id) => toValue(replies).get(id) ?? new Set()
|
||||||
|
|
||||||
const { mergedConfig } = storeToRefs(useMergedConfigStore())
|
const { mergedConfig } = storeToRefs(useMergedConfigStore())
|
||||||
|
|
||||||
|
|
@ -21,12 +21,12 @@ export function useTreeConversationTopology(conversation, replies, current) {
|
||||||
const ancestors = computed(() => {
|
const ancestors = computed(() => {
|
||||||
// First we fill map with empty sets and add given id's parent
|
// First we fill map with empty sets and add given id's parent
|
||||||
// as set's only element (if any)
|
// as set's only element (if any)
|
||||||
const parentMap = conversation.value.reduce(
|
const parentMap = toValue(conversation).reduce(
|
||||||
(result, { id, in_reply_to_status_id: irid }) => {
|
(result, { id, in_reply_to_status_id: irid }) => {
|
||||||
if (!result.has(id)) {
|
if (!result.has(id)) {
|
||||||
result.set(id, new Set())
|
result.set(id, new Set())
|
||||||
}
|
}
|
||||||
if (irid && conversation.value.length !== 1) {
|
if (irid && toValue(conversation).length !== 1) {
|
||||||
// Setting parent for current item
|
// Setting parent for current item
|
||||||
result.get(id).add(irid)
|
result.get(id).add(irid)
|
||||||
}
|
}
|
||||||
|
|
@ -48,22 +48,27 @@ export function useTreeConversationTopology(conversation, replies, current) {
|
||||||
})
|
})
|
||||||
return parentMap
|
return parentMap
|
||||||
})
|
})
|
||||||
const topLevel = computed(() =>
|
const topLevelIds = computed(() =>
|
||||||
[...ancestors.value.entries()]
|
[...ancestors.value.entries()]
|
||||||
.filter(([id, ancestors]) => ancestors.size === 0)
|
.filter(([id, ancestors]) => ancestors.size === 0)
|
||||||
.map(([id]) => getStatusObject(id)),
|
.map(([id]) => id),
|
||||||
|
)
|
||||||
|
const topLevel = computed(() =>
|
||||||
|
topLevelIds.value.map((id) => getStatusObject(id)),
|
||||||
)
|
)
|
||||||
const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
|
const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
|
||||||
const getAncestors = (id) =>
|
const getAncestors = (id) =>
|
||||||
[...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
|
[...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
|
||||||
const currentAncestors = computed(() => getAncestors(current.value).reverse())
|
const currentAncestors = computed(() =>
|
||||||
|
getAncestors(toValue(current)).reverse(),
|
||||||
|
)
|
||||||
const currentDepth = computed(() => currentAncestors.value.length)
|
const currentDepth = computed(() => currentAncestors.value.length)
|
||||||
|
|
||||||
// Thread Display, for collapsing/expanding tree branches
|
// Thread Display, for collapsing/expanding tree branches
|
||||||
// Map of id => 'showing' | 'hidden'
|
// Map of id => 'showing' | 'hidden'
|
||||||
const threadDisplayOverride = ref(new Map())
|
const threadDisplayOverride = ref(new Map())
|
||||||
const threadDisplayDefault = computed(() => {
|
const threadDisplayDefault = computed(() => {
|
||||||
return conversation.value.reduce((map, status) => {
|
return toValue(conversation).reduce((map, status) => {
|
||||||
const { id } = status
|
const { id } = status
|
||||||
const depth = ancestors.value.get(id).size
|
const depth = ancestors.value.get(id).size
|
||||||
|
|
||||||
|
|
@ -107,5 +112,8 @@ export function useTreeConversationTopology(conversation, replies, current) {
|
||||||
threadDisplay,
|
threadDisplay,
|
||||||
showThreadRecursively,
|
showThreadRecursively,
|
||||||
resetThreadDisplay,
|
resetThreadDisplay,
|
||||||
|
|
||||||
|
// For testing
|
||||||
|
ancestors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ export function useVirtualScrolling({
|
||||||
// - 'item' - same as height but uses anchor element's top offset
|
// - 'item' - same as height but uses anchor element's top offset
|
||||||
// instead of whole height
|
// instead of whole height
|
||||||
collapseMode,
|
collapseMode,
|
||||||
// Anchor. Set of IDs of element relative to which do scroll compensation
|
|
||||||
anchorIds,
|
|
||||||
// Placeholder height specification. Must be a function.
|
// Placeholder height specification. Must be a function.
|
||||||
// function will be called either:
|
// function will be called either:
|
||||||
// - without arguments (for generic placeholder, i.e. buffer zone size)
|
// - without arguments (for generic placeholder, i.e. buffer zone size)
|
||||||
|
|
@ -215,10 +213,6 @@ export function useVirtualScrolling({
|
||||||
const newBottomElement = last(newVal)
|
const newBottomElement = last(newVal)
|
||||||
|
|
||||||
return newBottomElement.top + newBottomElement.height
|
return newBottomElement.top + newBottomElement.height
|
||||||
} else if (toValue(collapseMode) === 'item') {
|
|
||||||
const element = newVal.find(({ id }) => toValue(anchorIds).has(id))
|
|
||||||
|
|
||||||
return element.top
|
|
||||||
} else {
|
} else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -241,19 +235,21 @@ export function useVirtualScrolling({
|
||||||
|
|
||||||
const expansion = (() => {
|
const expansion = (() => {
|
||||||
if (newVal.length < oldVal.length) return 0
|
if (newVal.length < oldVal.length) return 0
|
||||||
const oldVisible = oldVal.filter((item) => checkVisible(item) && item.real)
|
const oldVisible = oldVal.filter(
|
||||||
|
(item) => checkVisible(item) && item.real,
|
||||||
|
)
|
||||||
const oldItem = first(oldVisible)
|
const oldItem = first(oldVisible)
|
||||||
if (!oldItem) return 0 // probably out of bounds in timeline
|
if (!oldItem) return 0 // probably out of bounds in timeline
|
||||||
const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
|
const oldItemUpdated = newVal.find(({ id }) => id === oldItem.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
oldItemUpdated.top -
|
oldItemUpdated.top -
|
||||||
oldItem.top -
|
oldItem.top -
|
||||||
(oldItem.height - oldItemUpdated.height)
|
(oldItem.height - oldItemUpdated.height)
|
||||||
)
|
)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const collapsing = (() => {
|
const collapsing = (() => {
|
||||||
if (newVal.length >= oldVal.length) return 0
|
if (newVal.length >= oldVal.length) return 0
|
||||||
const newVisible = newVal
|
const newVisible = newVal
|
||||||
const newItem = first(newVisible)
|
const newItem = first(newVisible)
|
||||||
|
|
@ -262,8 +258,8 @@ export function useVirtualScrolling({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
newItem.top -
|
newItem.top -
|
||||||
newItemBefore.top -
|
newItemBefore.top -
|
||||||
(newItemBefore.height - newItem.height)
|
(newItemBefore.height - newItem.height)
|
||||||
)
|
)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
@ -295,7 +291,10 @@ export function useVirtualScrolling({
|
||||||
|
|
||||||
const element = heightChart.value.find(({ id }) => anchors.has(id))
|
const element = heightChart.value.find(({ id }) => anchors.has(id))
|
||||||
const elementMiddle = element.top + element.height / 2
|
const elementMiddle = element.top + element.height / 2
|
||||||
const desiredTopBoundary = Math.min(element.top, elementMiddle - (windowHeight.value - offset.value) / 2)
|
const desiredTopBoundary = Math.min(
|
||||||
|
element.top,
|
||||||
|
elementMiddle - (windowHeight.value - offset.value) / 2,
|
||||||
|
)
|
||||||
|
|
||||||
scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
|
scrollBy(0, desiredTopBoundary - topScrollBoundary.value)
|
||||||
|
|
||||||
|
|
|
||||||
93
test/fixtures/masto_api.js
vendored
Normal file
93
test/fixtures/masto_api.js
vendored
Normal file
|
|
@ -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,
|
||||||
|
})
|
||||||
316
test/unit/specs/composables/useConversation.spec.js
Normal file
316
test/unit/specs/composables/useConversation.spec.js
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { mockMastoAPIStatus, mockStatus } from 'test/fixtures/masto_api.js'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
|
|
||||||
|
import { useConversation } from 'src/composables/useConversation.js'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MASTODON_STATUS_CONTEXT_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),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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 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' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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' }])],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
30
test/unit/specs/composables/useMainStatus.spec.js
Normal file
30
test/unit/specs/composables/useMainStatus.spec.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
|
|
||||||
|
import { useMainStatus } from 'src/composables/useMainStatus.js'
|
||||||
|
|
||||||
|
describe('useMainStatus', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia())
|
||||||
|
useStatusesStore().allStatuses = new Map([
|
||||||
|
[1, { id: 1 }],
|
||||||
|
[2, { id: 2, retweeted_status: { id: 1 } }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-repeat', () => {
|
||||||
|
const { status, mainStatus } = useMainStatus(1)
|
||||||
|
|
||||||
|
expect(status.value).to.eql({ id: 1 })
|
||||||
|
expect(mainStatus.value).to.eql({ id: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repeat', () => {
|
||||||
|
const { status, mainStatus } = useMainStatus(2)
|
||||||
|
|
||||||
|
expect(status.value).to.eql({ id: 2, retweeted_status: { id: 1 } })
|
||||||
|
expect(mainStatus.value).to.eql({ id: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
163
test/unit/specs/composables/useTreeConversationTopology.spec.js
Normal file
163
test/unit/specs/composables/useTreeConversationTopology.spec.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
|
|
||||||
|
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
|
||||||
|
|
||||||
|
describe('useTreeConversationTopology', () => {
|
||||||
|
const conversation = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
in_reply_to_status_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
in_reply_to_status_id: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
in_reply_to_status_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
in_reply_to_status_id: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
in_reply_to_status_id: 3,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const replies = new Map([
|
||||||
|
[1, new Set([{ id: 2 }, { id: 4 }])],
|
||||||
|
[2, new Set([{ id: 3 }])],
|
||||||
|
[3, new Set([{ id: 6 }])],
|
||||||
|
[4, new Set([{ id: 5 }])],
|
||||||
|
[6, new Set([])],
|
||||||
|
])
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia())
|
||||||
|
useStatusesStore().allStatuses = new Map(
|
||||||
|
conversation.map(({ id }) => [id, { id }]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should form a correct topology', () => {
|
||||||
|
const { topLevel, ancestors, currentAncestors } =
|
||||||
|
useTreeConversationTopology(conversation, null, 3)
|
||||||
|
|
||||||
|
expect(ancestors.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, new Set()],
|
||||||
|
[2, new Set([1])],
|
||||||
|
[3, new Set([2, 1])],
|
||||||
|
[4, new Set([1])],
|
||||||
|
[5, new Set([4, 1])],
|
||||||
|
[6, new Set([3, 2, 1])],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(currentAncestors.value).to.eql([{ id: 1 }, { id: 2 }])
|
||||||
|
expect(topLevel.value.map(({ id }) => id)).to.eql([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ThreadDisplay', () => {
|
||||||
|
it('should set default ThreadDisplay with maxDepth 3', () => {
|
||||||
|
useMergedConfigStore().mergedConfig = { maxDepthInThread: 3 }
|
||||||
|
|
||||||
|
const { threadDisplay } = useTreeConversationTopology(conversation)
|
||||||
|
|
||||||
|
expect(threadDisplay.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, 'showing'],
|
||||||
|
[2, 'showing'],
|
||||||
|
[3, 'hidden'],
|
||||||
|
[4, 'showing'],
|
||||||
|
[5, 'hidden'],
|
||||||
|
[6, 'hidden'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set default ThreadDisplay with maxDepth 6', () => {
|
||||||
|
useMergedConfigStore().mergedConfig = { maxDepthInThread: 6 }
|
||||||
|
|
||||||
|
const { threadDisplay } = useTreeConversationTopology(conversation)
|
||||||
|
|
||||||
|
expect(threadDisplay.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, 'showing'],
|
||||||
|
[2, 'showing'],
|
||||||
|
[3, 'showing'],
|
||||||
|
[4, 'showing'],
|
||||||
|
[5, 'showing'],
|
||||||
|
[6, 'showing'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set default ThreadDisplay with maxDepth 3 && current depth being 3', () => {
|
||||||
|
useMergedConfigStore().mergedConfig = { maxDepthInThread: 3 }
|
||||||
|
|
||||||
|
const { threadDisplay } = useTreeConversationTopology(
|
||||||
|
conversation,
|
||||||
|
null,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(threadDisplay.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, 'showing'],
|
||||||
|
[2, 'showing'],
|
||||||
|
[3, 'showing'],
|
||||||
|
[4, 'showing'],
|
||||||
|
[5, 'showing'],
|
||||||
|
[6, 'hidden'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should recursively expand thread when calling showThreadRecursively', () => {
|
||||||
|
useMergedConfigStore().mergedConfig = { maxDepthInThread: 3 }
|
||||||
|
|
||||||
|
const { threadDisplay, showThreadRecursively } =
|
||||||
|
useTreeConversationTopology(conversation, replies, 1)
|
||||||
|
|
||||||
|
showThreadRecursively(3)
|
||||||
|
expect(threadDisplay.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, 'showing'],
|
||||||
|
[2, 'showing'],
|
||||||
|
[3, 'showing'],
|
||||||
|
[4, 'showing'],
|
||||||
|
[5, 'hidden'],
|
||||||
|
[6, 'showing'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset thread when calling resetThreadDisplay', () => {
|
||||||
|
useMergedConfigStore().mergedConfig = { maxDepthInThread: 3 }
|
||||||
|
|
||||||
|
const { threadDisplay, showThreadRecursively, resetThreadDisplay } =
|
||||||
|
useTreeConversationTopology(conversation, replies, 1)
|
||||||
|
|
||||||
|
showThreadRecursively(3)
|
||||||
|
resetThreadDisplay()
|
||||||
|
expect(threadDisplay.value).to.eql(
|
||||||
|
new Map([
|
||||||
|
[1, 'showing'],
|
||||||
|
[2, 'showing'],
|
||||||
|
[3, 'hidden'],
|
||||||
|
[4, 'showing'],
|
||||||
|
[5, 'hidden'],
|
||||||
|
[6, 'hidden'],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { snakeCase } from 'lodash-es'
|
import { snakeCase } from 'lodash-es'
|
||||||
import { setActivePinia } from 'pinia'
|
import { setActivePinia } from 'pinia'
|
||||||
|
import {
|
||||||
|
mockMastoAPIStatus,
|
||||||
|
mockMastoAPIUser,
|
||||||
|
mockStatus,
|
||||||
|
mockUser,
|
||||||
|
} from 'test/fixtures/masto_api.js'
|
||||||
|
|
||||||
import { useStatusesStore } from 'src/stores/statuses.js'
|
import { useStatusesStore } from 'src/stores/statuses.js'
|
||||||
import { useStreamingStore } from 'src/stores/streaming.js'
|
import { useStreamingStore } from 'src/stores/streaming.js'
|
||||||
|
|
@ -9,80 +15,6 @@ import { useUsersStore } from 'src/stores/users.js'
|
||||||
import * as PUBLIC_API from 'src/api/public.js'
|
import * as PUBLIC_API from 'src/api/public.js'
|
||||||
import * as USER_API from 'src/api/user.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') => ({
|
const DEFAULT_OPTIONS = (method = 'GET') => ({
|
||||||
method,
|
method,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue