Merge branch 'virtual-scrolling-2.0' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-09-16 21:02:45 +03:00
commit 73888c861f
17 changed files with 736 additions and 199 deletions

View file

@ -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) =>

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -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')"

View file

@ -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),

View file

@ -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,

View file

@ -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'
}) })

View file

@ -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

View file

@ -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 }
} }

View file

@ -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,
} }
} }

View file

@ -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
View 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,
})

View 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' }])],
]),
)
})
})

View 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 })
})
})

View 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'],
]),
)
})
})
})

View file

@ -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',