From e34f4814c878a79839b431956502b3afe4a9dcc9 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 09:49:36 +0300 Subject: [PATCH 01/15] move composables to their own folder --- src/components/conversation/conversation.js | 15 ++------------- .../useScrollPosition.js | 0 .../conversation => composables}/useWindowSize.js | 0 3 files changed, 2 insertions(+), 13 deletions(-) rename src/{components/conversation => composables}/useScrollPosition.js (100%) rename src/{components/conversation => composables}/useWindowSize.js (100%) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 919017387..4148e9000 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -13,8 +13,8 @@ import { import { useRouter } from 'vue-router' import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue' -import { useScrollPosition } from 'src/components/conversation/useScrollPosition.js' -import { useWindowSize } from 'src/components/conversation/useWindowSize.js' +import { useScrollPosition } from 'src/composables/useScrollPosition.js' +import { useWindowSize } from 'src/composables/useWindowSize.js' import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue' import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue' @@ -353,17 +353,6 @@ export default { const finalBottomScrollBoundary = realBottomScrollBoundary.value + buffer - // console.log( - // 'TOP SCROLL', - // itemBottomBoundary > finalTopScrollBoundary, - // itemBottomBoundary, finalTopScrollBoundary, - // ) - // console.log( - // 'BOTTOM SCROLL', - // itemTopBoundary < finalBottomScrollBoundary, - // itemTopBoundary, finalBottomScrollBoundary, - // ) - // To be visible, item's bottom boundary shoud be below top scroll boundary) const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary // To be visible, item's top boundary shoud be above bottom scroll boundary) diff --git a/src/components/conversation/useScrollPosition.js b/src/composables/useScrollPosition.js similarity index 100% rename from src/components/conversation/useScrollPosition.js rename to src/composables/useScrollPosition.js diff --git a/src/components/conversation/useWindowSize.js b/src/composables/useWindowSize.js similarity index 100% rename from src/components/conversation/useWindowSize.js rename to src/composables/useWindowSize.js From f788af6c287d92ad2e41c82801ecb982c41cea70 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 09:50:58 +0300 Subject: [PATCH 02/15] organize --- biome.json | 10 +++++++++- src/components/conversation/conversation.js | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/biome.json b/biome.json index a03cd02b5..0eb1d5e21 100644 --- a/biome.json +++ b/biome.json @@ -140,7 +140,15 @@ ":BLANK_LINE:", [":PATH:", "src/stores/**"], ":BLANK_LINE:", - [":PATH:", "src/**", "src/stores/**", "src/components/**"], + [":PATH:", "src/composables/**"], + ":BLANK_LINE:", + [ + ":PATH:", + "src/**", + "src/stores/**", + "src/components/**", + "src/composables/**" + ], ":BLANK_LINE:", "@fortawesome/fontawesome-svg-core", "@fortawesome/*" diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 4148e9000..29f3597d5 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -13,8 +13,6 @@ import { import { useRouter } from 'vue-router' import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue' -import { useScrollPosition } from 'src/composables/useScrollPosition.js' -import { useWindowSize } from 'src/composables/useWindowSize.js' import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue' import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue' @@ -27,6 +25,9 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' +import { useScrollPosition } from 'src/composables/useScrollPosition.js' +import { useWindowSize } from 'src/composables/useWindowSize.js' + import { fetchConversation as apiFetchConversation, fetchStatus as apiFetchStatus, From fe5c1f4d4354397d97ad8cfe4bdd83541f78d3b4 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 13:17:44 +0300 Subject: [PATCH 03/15] split tree topology into its own composable --- src/components/conversation/conversation.js | 100 +++------------- src/components/thread_tree/thread_tree.js | 6 +- .../useTreeConversationTopology.js | 111 ++++++++++++++++++ 3 files changed, 126 insertions(+), 91 deletions(-) create mode 100644 src/composables/useTreeConversationTopology.js diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 29f3597d5..3549828dc 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -26,6 +26,7 @@ import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' import { useScrollPosition } from 'src/composables/useScrollPosition.js' +import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js' import { useWindowSize } from 'src/composables/useWindowSize.js' import { @@ -155,7 +156,7 @@ export default { } const resetDisplayState = () => { setFocused(statusId.value) - threadDisplay.value = new Map() + resetThreadDisplay() } watch(statusId, (newVal, oldVal) => { const newConversationId = getConversationId(newVal) @@ -415,98 +416,25 @@ export default { const isTreeView = computed(() => displayStyle.value === 'tree') // ## Tree state - // ### Topology - const ancestors = computed(() => { - // First we fill map with empty sets and add given id's parent - // as set's only element (if any) - const parentMap = conversation.value.reduce( - (result, { id, in_reply_to_status_id: irid }) => { - if (!result.has(id)) { - result.set(id, new Set()) - } - if (irid) { - // Setting parent for current item - result.get(id).add(irid) - } - return result - }, - new Map(), - ) - - // Next we iterate over each entry and fill the whole ancestry chain - parentMap.entries().forEach(([originId, originSet]) => { - let current = originSet.values().next().value - while (current) { - originSet.add(current) - - const parent = parentMap.get(current) ?? new Set() - - current = parent.values().next().value - } - }) - return parentMap - }) - const topLevel = computed(() => - [...ancestors.value.entries()] - .filter(([id, ancestors]) => ancestors.size === 0) - .map(([id]) => getStatusObject(id)), - ) - const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set() - const getAncestors = (id) => - [...getAncestorIds(id)].map(getStatusObject).filter(Boolean) - const currentAncestors = computed(() => - getAncestors(focusedId.value).reverse(), - ) - const currentDepth = computed(() => currentAncestors.value.length) - - // ### Thread Display - const threadDisplay = ref(new Map()) // id => 'showing' | 'hidden' - const threadDisplayDefault = computed(() => { - return conversation.value.reduce((map, status) => { - const { id } = status - const depth = ancestors.value.get(id).size - - const state = (() => { - if (depth - currentDepth.value <= maxDepthToShowByDefault.value) { - return 'showing' - } else { - return 'hidden' - } - })() - - map.set(id, state) - return map - }, new Map()) - }) - provide('threadDisplay', threadDisplay) - provide('threadDisplayDefault', threadDisplayDefault) - - const setThreadDisplayRecursively = (id, value) => { - threadDisplay.value.set(id, value) - ;[...getReplies(id)] - .map((k) => k.id) - .map((id) => setThreadDisplayRecursively(id, value)) - } - const showThreadRecursively = (id) => { - setThreadDisplayRecursively(id, 'showing') - } - - // ## Derived values and config const treeViewIsSimple = computed( () => !mergedConfig.value.conversationTreeAdvanced, ) - const maxDepthToShowByDefault = computed(() => { - // maxDepthInThread = max number of depths that is *visible* - // since our depth starts with 0 and "showing" means "showing children" - // there is a -2 here - const maxDepth = mergedConfig.value.maxDepthInThread - 2 - return Math.min(1, maxDepth) - }) + + // ### Topology + const { + topLevel, + currentAncestors, + threadDisplay, + showThreadRecursively, + resetThreadDisplay, + } = useTreeConversationTopology(conversation, replies, focusedId) + provide('threadDisplay', threadDisplay) + const shouldShowAllConversationButton = computed( () => currentAncestors.value.length > 0 && topLevel.value.length > 1, ) const shouldShowAncestors = computed( - () => isExpanded.value && ancestors.value.get(focusedId.value) != null, + () => isExpanded.value && currentAncestors.value.size > 0, ) const shouldFadeAncestors = computed( () => mergedConfig.value.conversationTreeFadeAncestors, diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 714b2b3f5..9dbb4ff77 100644 --- a/src/components/thread_tree/thread_tree.js +++ b/src/components/thread_tree/thread_tree.js @@ -27,7 +27,6 @@ const ThreadTree = { 'focused', 'replies', 'threadDisplay', - 'threadDisplayDefault', 'isExpanded', 'isPage', ], @@ -39,10 +38,7 @@ const ThreadTree = { return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced }, threadShowing() { - const result = - this.threadDisplay.get(this.statusId) ?? - this.threadDisplayDefault.get(this.statusId) - return result === 'showing' + return this.threadDisplay.get(this.statusId) === 'showing' }, canDive() { return this.isExpanded diff --git a/src/composables/useTreeConversationTopology.js b/src/composables/useTreeConversationTopology.js new file mode 100644 index 000000000..a698635de --- /dev/null +++ b/src/composables/useTreeConversationTopology.js @@ -0,0 +1,111 @@ +import { storeToRefs } from 'pinia' +import { computed, ref } from 'vue' + +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useStatusesStore } from 'src/stores/statuses.js' + +export function useTreeConversationTopology(conversation, replies, current) { + const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) + const getReplies = (id) => replies.value.get(id) ?? new Set() + + const { mergedConfig } = storeToRefs(useMergedConfigStore()) + + const maxDepthToShowByDefault = computed(() => { + // maxDepthInThread = max number of depths that is *visible* + // since our depth starts with 0 and "showing" means "showing children" + // there is a -2 here + const maxDepth = mergedConfig.value.maxDepthInThread - 2 + return Math.min(1, maxDepth) + }) + + const ancestors = computed(() => { + // First we fill map with empty sets and add given id's parent + // as set's only element (if any) + const parentMap = conversation.value.reduce( + (result, { id, in_reply_to_status_id: irid }) => { + if (!result.has(id)) { + result.set(id, new Set()) + } + if (irid) { + // Setting parent for current item + result.get(id).add(irid) + } + return result + }, + new Map(), + ) + + // Next we iterate over each entry and fill the whole ancestry chain + parentMap.entries().forEach(([originId, originSet]) => { + let current = originSet.values().next().value + while (current) { + originSet.add(current) + + const parent = parentMap.get(current) ?? new Set() + + current = parent.values().next().value + } + }) + return parentMap + }) + const topLevel = computed(() => + [...ancestors.value.entries()] + .filter(([id, ancestors]) => ancestors.size === 0) + .map(([id]) => getStatusObject(id)), + ) + const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set() + const getAncestors = (id) => + [...getAncestorIds(id)].map(getStatusObject).filter(Boolean) + const currentAncestors = computed(() => getAncestors(current.value).reverse()) + const currentDepth = computed(() => currentAncestors.value.length) + + // Thread Display, for collapsing/expanding tree branches + // Map of id => 'showing' | 'hidden' + const threadDisplayOverride = ref(new Map()) + const threadDisplayDefault = computed(() => { + return conversation.value.reduce((map, status) => { + const { id } = status + const depth = ancestors.value.get(id).size + + const state = (() => { + if (depth - currentDepth.value <= maxDepthToShowByDefault.value) { + return 'showing' + } else { + return 'hidden' + } + })() + + map.set(id, state) + return map + }, new Map()) + }) + const threadDisplay = computed(() => { + return new Map( + [...threadDisplayOverride.value.entries()].map(([k, v]) => [ + k, + threadDisplayOverride.value.get(k) ?? threadDisplayDefault.value.get(k), + ]), + ) + }) + + const setThreadDisplayRecursively = (id, value) => { + threadDisplayOverride.value.set(id, value) + ;[...getReplies(id)] + .map((k) => k.id) + .map((id) => setThreadDisplayRecursively(id, value)) + } + const showThreadRecursively = (id) => { + setThreadDisplayRecursively(id, 'showing') + } + const resetThreadDisplay = () => { + threadDisplayOverride.value = new Map() + } + + return { + topLevel, + currentAncestors, + threadDisplay, + showThreadRecursively, + resetThreadDisplay, + } +} From 42eeb1a9c0b35effeb7bc70623ad840cd905ab1b Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 13:21:37 +0300 Subject: [PATCH 04/15] use less ambigious currentStatus, fix replies footer --- src/components/conversation/conversation.js | 12 ++++++------ src/components/conversation/conversation.vue | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 3549828dc..4db708cc7 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -120,10 +120,10 @@ export default { get(status, 'statusnet_conversation_id'), ) } - const status = computed(() => getStatusObject(focusedId.value)) + const currentStatus = computed(() => getStatusObject(focusedId.value)) const fetchConversation = async () => { - if (status.value) { + if (currentStatus.value) { const { data: { ancestors, descendants }, timestamp, @@ -192,12 +192,12 @@ export default { } const conversationId = computed(() => getConversationId(statusId.value)) const conversation = computed(() => { - if (!status.value) { + if (!currentStatus.value) { return [] } if (!isExpanded.value) { - return [status.value] + return [currentStatus.value] } const conversation = useStatusesStore().conversations.get( @@ -228,7 +228,7 @@ export default { ) const getReplies = (id) => replies.value.get(id) ?? new Set() const statusReplies = computed(() => { - return getReplies(status.value.id) + return getReplies(currentStatus.value.id) }) provide('conversation', conversation) @@ -487,7 +487,7 @@ export default { setFocused, // # Main things - status, + currentStatus, statusReplies, getReplies, conversation, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 9b8d342c2..130caca13 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -68,7 +68,7 @@ class="conversation-dive-to-top-level-box" >
@@ -140,7 +140,7 @@
Date: Wed, 9 Sep 2026 13:22:45 +0300 Subject: [PATCH 05/15] extraneous --- src/components/conversation/conversation.js | 5 ----- src/components/conversation/conversation.vue | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 4db708cc7..611941f46 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -227,10 +227,6 @@ export default { ), ) const getReplies = (id) => replies.value.get(id) ?? new Set() - const statusReplies = computed(() => { - return getReplies(currentStatus.value.id) - }) - provide('conversation', conversation) provide('replies', replies) @@ -488,7 +484,6 @@ export default { // # Main things currentStatus, - statusReplies, getReplies, conversation, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 130caca13..832dba93d 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -100,7 +100,7 @@ :class="getStatusClasses(status)" :status-id="status.id" - :replies="statusReplies" + :replies="getReplies(status.id)" :focused="focused === status.id" can-dive From 218c2ca56137818df31a21e6aee8046429747493 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 13:50:36 +0300 Subject: [PATCH 06/15] separate virtual scrolling 2.0 into its own composable --- src/components/conversation/conversation.js | 173 +++----------------- src/composables/useVirtualScrolling.js | 169 +++++++++++++++++++ 2 files changed, 188 insertions(+), 154 deletions(-) create mode 100644 src/composables/useVirtualScrolling.js diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 611941f46..55d70d20b 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -3,7 +3,6 @@ import { storeToRefs } from 'pinia' import { computed, nextTick, - onMounted, provide, ref, toRefs, @@ -25,9 +24,8 @@ import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' -import { useScrollPosition } from 'src/composables/useScrollPosition.js' import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js' -import { useWindowSize } from 'src/composables/useWindowSize.js' +import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js' import { fetchConversation as apiFetchConversation, @@ -252,149 +250,6 @@ export default { fetchConversation() } - // # Virtual scrolling stuff - const fontSizeSetting = computed(() => mergedConfig.value.textSize) - const fontSize = computed(() => { - // reading fontSizeSetting to make computed react to it - fontSizeSetting.value - const string = window - .getComputedStyle(document.body) - .getPropertyValue('font-size') - return Number.parseInt(string.slice(0, -2), 10) // remove the 'px' - }) - const mutedStatusHeight = computed(() => { - return fontSize.value * 1.5 - }) - const normalStatusHeight = computed(() => { - return fontSize.value * 10 - }) - const heights = ref(new Map()) - const totalHeight = computed(() => - conversation.value.reduce((acc, item) => { - if (heights.value.has(item.id)) { - return acc + heights.value.get(item.id) - } else if (item.muted) { - return acc + mutedStatusHeight.value - } else { - return acc + normalStatusHeight.value - } - }, 0), - ) - - const body = useTemplateRef('body') - const updateVirtualHeight = ({ id, height }) => { - heights.value.set(id, height) - } - - const { y: topScrollBoundary } = useScrollPosition() - const { height: windowHeight } = useWindowSize() - - const realTopScrollBoundary = ref(0) - const realBottomScrollBoundary = ref(0) - const updateBoundaries = () => { - if (!body.value) return // Not mounted yet - - const { top } = body.value.getBoundingClientRect() - - const distanceItemTopToWindowTop = 0 - top - const distanceItemTopToWindowBottom = windowHeight.value - top - - realTopScrollBoundary.value = distanceItemTopToWindowTop - realBottomScrollBoundary.value = distanceItemTopToWindowBottom - } - - watch(topScrollBoundary, updateBoundaries) - watch(totalHeight, updateBoundaries) - onMounted(updateBoundaries) - - const buffer = normalStatusHeight.value * 2 - - const unsuspendibleIds = ref(new Set()) - const onStatusSuspendStateChange = ({ id, suspend }) => { - if (!suspend) { - unsuspendibleIds.value.add(id) - } else { - unsuspendibleIds.value.delete(id) - } - } - - const heightChartLinear = computed(() => { - // Map every height and suspendable state - const chart = conversation.value.map(({ id }) => { - const status = getStatusObject(id) - const height = - (() => { - if (heights.value.has(id)) { - return heights.value.get(id) - } else if (status?.muted) { - return mutedStatusHeight.value - } else { - return normalStatusHeight.value - } - })() + 1 //including border - const suspendable = !unsuspendibleIds.value.has(id) - return { id, height, suspendable, status } - }) - - // Walk over the list to set top offsets - chart.reduce((sum, item) => { - item.top = sum - return sum + item.height - }, 0) - - // Determine visibility state - chart.forEach((heightChartItem) => { - const itemBottomBoundary = heightChartItem.top + heightChartItem.height - const itemTopBoundary = heightChartItem.top - - const finalTopScrollBoundary = realTopScrollBoundary.value - buffer - const finalBottomScrollBoundary = - realBottomScrollBoundary.value + buffer - - // To be visible, item's bottom boundary shoud be below top scroll boundary) - const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary - // To be visible, item's top boundary shoud be above bottom scroll boundary) - const aboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary - // This accounts for the case where item's boundaries exceed scroll boundary - - heightChartItem.visible = belowTopBoundary && aboveBottomBoundary - }) - - // Group invisible statuses into spacers - return chart.reduce((acc, heightChartItem) => { - const { suspendable, visible, height, top, bottom, id, status } = - heightChartItem - const present = visible || !suspendable - if (present) { - return [...acc, { type: 'status', height, top, bottom, id, status }] - } else { - const previousItem = acc[acc.length - 1] - const spacer = - previousItem?.type === 'spacer' - ? previousItem - : { - type: 'spacer', - top: Number.POSITIVE_INFINITY, - bottom: Number.POSITIVE_INFINITY, - height: 0, - ids: new Set(), - } - - spacer.ids.add(id) - spacer.id = [...spacer.ids].join() - spacer.height += height - if (top < spacer.top) spacer.top = top - if (bottom < spacer.bottom) spacer.bottom = bottom - - if (previousItem?.type === 'spacer') { - return acc - } else { - return [...acc, spacer] - } - } - }, []) - }) - // # Misc UI things const firstStatus = computed(() => conversation.value[0]) const lastStatus = computed( @@ -407,16 +262,15 @@ export default { // # Linear style stuff const isLinearView = computed(() => displayStyle.value !== 'tree') + const body = useTemplateRef('body') + const { + heightChart: heightChartLinear, + changeSuspendState: changeSuspendStateLinear, + updateVirtualHeight: updateVirtualHeightLinear, + } = useVirtualScrolling(conversation, body) // # Tree style stuff const isTreeView = computed(() => displayStyle.value === 'tree') - - // ## Tree state - const treeViewIsSimple = computed( - () => !mergedConfig.value.conversationTreeAdvanced, - ) - - // ### Topology const { topLevel, currentAncestors, @@ -426,6 +280,9 @@ export default { } = useTreeConversationTopology(conversation, replies, focusedId) provide('threadDisplay', threadDisplay) + const treeViewIsSimple = computed( + () => !mergedConfig.value.conversationTreeAdvanced, + ) const shouldShowAllConversationButton = computed( () => currentAncestors.value.length > 0 && topLevel.value.length > 1, ) @@ -439,6 +296,14 @@ export default { () => mergedConfig.value.conversationOtherRepliesButton === 'below', ) + // # Virtual scrolling stuff + const onStatusSuspendStateChange = ({ id, suspend }) => { + changeSuspendStateLinear({ id, suspend }) + } + const updateVirtualHeight = ({ id, height }) => { + updateVirtualHeightLinear({ id, height }) + } + // # Scrolling const tryScrollTo = (id) => { if (!id) { @@ -483,9 +348,9 @@ export default { setFocused, // # Main things + conversation, currentStatus, getReplies, - conversation, // # Conversation Expansion isPage, diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js new file mode 100644 index 000000000..9b0450878 --- /dev/null +++ b/src/composables/useVirtualScrolling.js @@ -0,0 +1,169 @@ +import { storeToRefs } from 'pinia' +import { computed, onMounted, ref, watch } from 'vue' + +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useStatusesStore } from 'src/stores/statuses.js' + +import { useScrollPosition } from 'src/composables/useScrollPosition.js' +import { useWindowSize } from 'src/composables/useWindowSize.js' + +export function useVirtualScrolling(conversation, body) { + const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) + + const { mergedConfig } = storeToRefs(useMergedConfigStore()) + + const unsuspendibleIds = ref(new Set()) + const changeSuspendState = ({ id, suspend }) => { + if (!suspend) { + unsuspendibleIds.value.add(id) + } else { + unsuspendibleIds.value.delete(id) + } + } + + // Getting the actual font size in pixels since UI might have + // a different scale + const fontSizeSetting = computed(() => mergedConfig.value.textSize) + const fontSize = ref(0) + const updateFontSize = () => { + const string = window + .getComputedStyle(document.body) + .getPropertyValue('font-size') + fontSize.value = Number.parseInt(string.slice(0, -2), 10) // remove the 'px' + } + // Update font size if user changed UI scale + watch(fontSizeSetting, updateFontSize) + + // Placeholder heights. + const mutedStatusHeight = computed(() => { + return fontSize.value * 1.5 + }) + const normalStatusHeight = computed(() => { + return fontSize.value * 10 + }) + + // Add buffer zone to boundary, equal to approx 3 statuses heights + const buffer = computed(() => normalStatusHeight.value * 3) + + // Heights map. + const heights = ref(new Map()) + const totalHeight = computed(() => + conversation.value.reduce((acc, item) => { + if (heights.value.has(item.id)) { + return acc + heights.value.get(item.id) + } else if (item.muted) { + return acc + mutedStatusHeight.value + } else { + return acc + normalStatusHeight.value + } + }, 0), + ) + const updateVirtualHeight = ({ id, height }) => { + heights.value.set(id, height) + } + + // Scrolling + const { y: topScrollBoundary } = useScrollPosition() + const { height: windowHeight } = useWindowSize() + + const realTopScrollBoundary = ref(0) + const realBottomScrollBoundary = ref(0) + const updateBoundaries = () => { + if (!body.value) return // Not mounted yet + + const { top } = body.value.getBoundingClientRect() + + const distanceItemTopToWindowTop = 0 - top + const distanceItemTopToWindowBottom = windowHeight.value - top + + realTopScrollBoundary.value = distanceItemTopToWindowTop + realBottomScrollBoundary.value = distanceItemTopToWindowBottom + } + watch(windowHeight, updateBoundaries) + watch(topScrollBoundary, updateBoundaries) + watch(totalHeight, updateBoundaries) + onMounted(updateBoundaries) + + const heightChart = computed(() => { + // Map every height and suspendable state + const chart = conversation.value.map(({ id }) => { + const status = getStatusObject(id) + const height = + (() => { + if (heights.value.has(id)) { + return heights.value.get(id) + } else if (status?.muted) { + return mutedStatusHeight.value + } else { + return normalStatusHeight.value + } + })() + 1 //including border + const suspendable = !unsuspendibleIds.value.has(id) + return { id, height, suspendable, status } + }) + + // Walk over the list to set top offsets + chart.reduce((sum, item) => { + item.top = sum + return sum + item.height + }, 0) + + // Determine visibility state + chart.forEach((heightChartItem) => { + const itemBottomBoundary = heightChartItem.top + heightChartItem.height + const itemTopBoundary = heightChartItem.top + + const finalTopScrollBoundary = realTopScrollBoundary.value - buffer.value + const finalBottomScrollBoundary = + realBottomScrollBoundary.value + buffer.value + + // To be visible, item's bottom boundary shoud be below top scroll boundary) + const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary + // To be visible, item's top boundary shoud be above bottom scroll boundary) + const aboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary + // This accounts for the case where item's boundaries exceed scroll boundary + + heightChartItem.visible = belowTopBoundary && aboveBottomBoundary + }) + + // Group invisible statuses into spacers + return chart.reduce((acc, heightChartItem) => { + const { suspendable, visible, height, top, bottom, id, status } = + heightChartItem + const present = visible || !suspendable + if (present) { + return [...acc, { type: 'status', height, top, bottom, id, status }] + } else { + const previousItem = acc[acc.length - 1] + const spacer = + previousItem?.type === 'spacer' + ? previousItem + : { + type: 'spacer', + top: Number.POSITIVE_INFINITY, + bottom: Number.POSITIVE_INFINITY, + height: 0, + ids: new Set(), + } + + spacer.ids.add(id) + spacer.id = [...spacer.ids].join() + spacer.height += height + if (top < spacer.top) spacer.top = top + if (bottom < spacer.bottom) spacer.bottom = bottom + + if (previousItem?.type === 'spacer') { + return acc + } else { + return [...acc, spacer] + } + } + }, []) + }) + + return { + heightChart, + changeSuspendState, + updateVirtualHeight, + } +} From a24d2a1bba1d5f988fe45303aef37c59d1d99a69 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 14:46:40 +0300 Subject: [PATCH 07/15] conversation composable + virtual scrolling for trees (somewhat) --- src/components/conversation/conversation.js | 206 ++++++------------- src/components/conversation/conversation.vue | 48 +++-- src/components/thread_tree/thread_tree.js | 20 ++ src/components/thread_tree/thread_tree.vue | 7 +- src/composables/useConversation.js | 124 +++++++++++ src/composables/useVirtualScrolling.js | 22 +- 6 files changed, 259 insertions(+), 168 deletions(-) create mode 100644 src/composables/useConversation.js diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 55d70d20b..c89cfd33a 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -20,17 +20,13 @@ import ThreadTree from 'src/components/thread_tree/thread_tree.vue' import { useInterfaceStore } from 'src/stores/interface.js' import { useMergedConfigStore } from 'src/stores/merged_config.js' -import { useOAuthStore } from 'src/stores/oauth.js' import { useStatusesStore } from 'src/stores/statuses.js' import { useStreamingStore } from 'src/stores/streaming.js' +import { useConversation } from 'src/composables/useConversation.js' import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js' import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js' -import { - fetchConversation as apiFetchConversation, - fetchStatus as apiFetchStatus, -} from 'src/api/public.js' import { WSConnectionStatus } from 'src/api/websocket.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -73,11 +69,22 @@ export default { RichContent, }, setup(props) { + // # Helpers + const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) + const getConversationId = (statusId) => { + const status = getStatusObject(statusId) + return get( + status, + 'retweeted_status.statusnet_conversation_id', + get(status, 'statusnet_conversation_id'), + ) + } + const { statusId } = toRefs(props) const router = useRouter() - // # Main Configuration + // # Main Configuration / global state const { mergedConfig } = storeToRefs(useMergedConfigStore()) const { mastoUserSocketStatus } = storeToRefs(useStreamingStore()) const displayStyle = computed(() => mergedConfig.value.conversationDisplay) @@ -86,12 +93,26 @@ export default { mergedConfig.value.useStreamingApi && mastoUserSocketStatus === WSConnectionStatus.JOINED, ) - - // # Misc - const loadStatusError = ref(null) const { layoutType } = storeToRefs(useInterfaceStore()) const mobileLayout = computed(() => layoutType.value === 'mobile') + // # Conversation Expansion + const expanded = ref(false) + const { isPage } = toRefs(props) + const isExpanded = computed(() => !!(expanded.value || isPage.value)) + const toggleExpanded = () => { + expanded.value = !expanded.value + } + watch(expanded, (value) => { + if (value) { + fetchConversation() + } else { + resetDisplayState() + } + }) + provide('isExpanded', isExpanded) + provide('isPage', isPage) + // # Focus const focusedId = ref(statusId.value) const focused = computed(() => (isExpanded.value ? focusedId.value : null)) @@ -109,49 +130,15 @@ export default { provide('focused', focused) // # Main things - const getStatusObject = (id) => useStatusesStore().allStatuses.get(id) - const getConversationId = (statusId) => { - const status = getStatusObject(statusId) - return get( - status, - 'retweeted_status.statusnet_conversation_id', - get(status, 'statusnet_conversation_id'), - ) - } - const currentStatus = computed(() => getStatusObject(focusedId.value)) + const { + currentStatus, + conversation, + replies, + getReplies, + fetchConversation, + loadError, + } = useConversation(focusedId, isExpanded) - const fetchConversation = async () => { - if (currentStatus.value) { - const { - data: { ancestors, descendants }, - timestamp, - } = await apiFetchConversation({ - id: statusId.value, - credentials: useOAuthStore().token, - }) - - useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp }) - useStatusesStore().addNewStatuses({ - statuses: descendants, - timestamp, - }) - } else { - try { - loadStatusError.value = null - - const { data: status } = await apiFetchStatus({ - id: statusId.value, - credentials: useOAuthStore().token, - }) - - useStatusesStore().addNewStatuses({ statuses: [status] }) - fetchConversation() - } catch (error) { - console.error(error) - loadStatusError.value = error - } - } - } const resetDisplayState = () => { setFocused(statusId.value) resetThreadDisplay() @@ -171,80 +158,6 @@ export default { } }) - const sortById = (a, b) => { - const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id - const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id - const seqA = Number(idA) - const seqB = Number(idB) - const isSeqA = !Number.isNaN(seqA) - const isSeqB = !Number.isNaN(seqB) - if (isSeqA && isSeqB) { - return seqA < seqB ? -1 : 1 - } else if (isSeqA && !isSeqB) { - return -1 - } else if (!isSeqA && isSeqB) { - return 1 - } else { - return idA < idB ? -1 : 1 - } - } - const conversationId = computed(() => getConversationId(statusId.value)) - const conversation = computed(() => { - if (!currentStatus.value) { - return [] - } - - if (!isExpanded.value) { - return [currentStatus.value] - } - - const conversation = useStatusesStore().conversations.get( - conversationId.value, - ) - - return [...conversation.keys()] - .map((k) => useStatusesStore().allStatuses.get(k)) - .filter((status) => status.type != 'repeat') // Old backend behavior? - .toSorted(sortById) - }) - const replies = computed(() => - conversation.value.reduce( - (result, { id, in_reply_to_status_id: irid }, index) => { - if (irid) { - if (!result.has(irid)) { - result.set(irid, new Set()) - } - result.get(irid).add({ - name: `#${index}`, - id, - }) - } - return result - }, - new Map(), - ), - ) - const getReplies = (id) => replies.value.get(id) ?? new Set() - provide('conversation', conversation) - provide('replies', replies) - - // # Conversation Expansion - const expanded = ref(false) - const { isPage } = toRefs(props) - const isExpanded = computed(() => !!(expanded.value || isPage.value)) - const toggleExpanded = () => { - expanded.value = !expanded.value - } - watch(expanded, (value) => { - if (value) { - fetchConversation() - } else { - resetDisplayState() - } - }) - provide('isExpanded', isExpanded) - provide('isPage', isPage) - // Component created if (isPage.value) { fetchConversation() @@ -280,6 +193,21 @@ export default { } = useTreeConversationTopology(conversation, replies, focusedId) provide('threadDisplay', threadDisplay) + const ancestorsElement = useTemplateRef('ancestors') + const { + heightChart: heightChartAncestors, + changeSuspendState: changeSuspendStateAncestors, + updateVirtualHeight: updateVirtualHeightAncestors, + } = useVirtualScrolling(currentAncestors, ancestorsElement) + + const currentLevel = computed(() => [currentStatus.value]) + const currentLevelElement = useTemplateRef('currentLevel') + const { + heightChart: heightChartCurrentLevel, + changeSuspendState: changeSuspendStateCurrentLevel, + updateVirtualHeight: updateVirtualHeightCurrentLevel, + } = useVirtualScrolling(currentLevel, currentLevelElement) + const treeViewIsSimple = computed( () => !mergedConfig.value.conversationTreeAdvanced, ) @@ -296,14 +224,6 @@ export default { () => mergedConfig.value.conversationOtherRepliesButton === 'below', ) - // # Virtual scrolling stuff - const onStatusSuspendStateChange = ({ id, suspend }) => { - changeSuspendStateLinear({ id, suspend }) - } - const updateVirtualHeight = ({ id, height }) => { - updateVirtualHeightLinear({ id, height }) - } - // # Scrolling const tryScrollTo = (id) => { if (!id) { @@ -340,7 +260,7 @@ export default { return { // # Misc - loadStatusError, + loadError, mobileLayout, // # Focus @@ -357,20 +277,28 @@ export default { isExpanded, toggleExpanded, - // # Virtual scrolling stuff - onStatusSuspendStateChange, - updateVirtualHeight, - // # Misc UI things getStatusClasses, // # Linear style stuff isLinearView, + + // ## Linear virtual scrolling heightChartLinear, + changeSuspendStateLinear, + updateVirtualHeightLinear, // # Tree style stuff isTreeView, + // ## Tree virtual scrolling + heightChartAncestors, + changeSuspendStateAncestors, + updateVirtualHeightAncestors, + heightChartCurrentLevel, + changeSuspendStateCurrentLevel, + updateVirtualHeightCurrentLevel, + // ## Tree state // ### Topology topLevel, @@ -379,7 +307,7 @@ export default { // ### Thread Display showThreadRecursively, - // ## Derived values and config + // ### Derived values and config treeViewIsSimple, shouldShowAllConversationButton, shouldShowAncestors, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 832dba93d..fd45ec057 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -42,7 +42,7 @@ ref="body" :class="{ 'panel-body': isExpanded }" > -

+

- {{ $t('status.load_error', { error: loadStatusError }) }} + {{ $t('status.load_error', { error: loadError }) }}

- + + + +
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 9dbb4ff77..2dacf4212 100644 --- a/src/components/thread_tree/thread_tree.js +++ b/src/components/thread_tree/thread_tree.js @@ -15,7 +15,19 @@ const ThreadTree = { statusId: String, depth: Number, }, + data() { + return { + resizeObserver: new ResizeObserver(this.updateVirtualHeight), + } + }, + mounted() { + this.resizeObserver.observe(this.$refs.root) + }, + unmounted() { + this.resizeObserver.disconnect() + }, emits: [ + 'heightChange', 'suspendableStateChange', 'goto', 'dive', @@ -88,6 +100,14 @@ const ThreadTree = { getReplies(id) { return this.replies.get(id) ?? new Set() }, + updateVirtualHeight(e) { + const [entry] = e + this.$emit('heightChange', { + id: this.statusId, + height: entry.contentRect.height, + element: this.$refs.root, + }) + }, }, } diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index 8d8f0af33..b8d35e3df 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -1,5 +1,8 @@ diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 2dacf4212..6dedf78ed 100644 --- a/src/components/thread_tree/thread_tree.js +++ b/src/components/thread_tree/thread_tree.js @@ -1,4 +1,5 @@ import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useStatusesStore } from 'src/stores/statuses.js' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -43,14 +44,21 @@ const ThreadTree = { 'isPage', ], computed: { + status() { + const status = useStatusesStore().allStatuses.get(this.statusId) + if (status.retweeted_status) { + return useStatusesStore().allStatuses.get(status.retweeted_status.id) + } + return status + }, currentReplies() { - return [...this.getReplies(this.statusId)].map(({ id }) => id) + return [...this.getReplies(this.status.id)].map(({ id }) => id) }, simple() { return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced }, threadShowing() { - return this.threadDisplay.get(this.statusId) === 'showing' + return this.threadDisplay.get(this.status.id) === 'showing' }, canDive() { return this.isExpanded diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index b8d35e3df..3f004b4a3 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -10,6 +10,7 @@ :replies="getReplies(statusId)" :focused="focused === statusId" + :conversation-rank="depth === 0 ? 'current' : 'child'" :thread-display-state="threadDisplay.get(statusId)" @dive="$emit('dive', statusId)" @@ -53,7 +54,7 @@
@@ -72,7 +73,7 @@ From 97e691b290253cfc7ffca823f8e85b0e44c77d9b Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 16:20:55 +0300 Subject: [PATCH 10/15] cleanup --- src/components/conversation/conversation.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index c0b280e7a..7c160bc42 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -221,9 +221,6 @@ export default { const shouldFadeAncestors = computed( () => mergedConfig.value.conversationTreeFadeAncestors, ) - const shouldShowOtherRepliesButton = computed( - () => mergedConfig.value.conversationOtherRepliesButton === 'below', - ) // # Scrolling const tryScrollTo = (id) => { @@ -313,7 +310,6 @@ export default { shouldShowAllConversationButton, shouldShowAncestors, shouldFadeAncestors, - shouldShowOtherRepliesButton, // # Scrolling diveToTopLevel, From 7753a94a2fb773b2b17abbe155cb7cb9ebe6101e Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Wed, 9 Sep 2026 18:22:18 +0300 Subject: [PATCH 11/15] scroll to element for linear view --- src/components/conversation/conversation.js | 58 ++++++++----------- src/components/conversation/conversation.vue | 2 + src/components/status/status.js | 4 -- src/components/status/status.vue | 2 +- src/components/thread_tree/thread_tree.vue | 1 + src/composables/useVirtualScrolling.js | 59 ++++++++++++++++---- 6 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 7c160bc42..e8e52c7d1 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -80,6 +80,19 @@ export default { ) } + const tryScrollTo = async (id) => { + if (!id) { + return + } + if (isPage.value) { + router.push({ name: 'conversation', params: { statusId: id } }) + } + setFocused(id) + const target = document.querySelector(`.Status[data-status-id=${id}]`) + await nextTick() + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + const { statusId } = toRefs(props) const router = useRouter() @@ -105,6 +118,7 @@ export default { } provide('isExpanded', isExpanded) provide('isPage', isPage) + provide('expandable', true) // # Focus const focusedId = ref(statusId.value) @@ -133,12 +147,13 @@ export default { } = useConversation(focusedId, isExpanded) watch(expanded, (value) => { + tryScrollTo(currentStatus.value.id) if (value) { fetchConversation() } else { resetDisplayState() } - }) + }, { flush: 'post' }) const resetDisplayState = () => { setFocused(statusId.value) @@ -181,7 +196,7 @@ export default { heightChart: heightChartLinear, changeSuspendState: changeSuspendStateLinear, updateVirtualHeight: updateVirtualHeightLinear, - } = useVirtualScrolling(conversation, linearElement) + } = useVirtualScrolling(conversation, linearElement, currentStatus) // # Tree style stuff const isTreeView = computed(() => displayStyle.value === 'tree') @@ -205,9 +220,10 @@ export default { const currentLevelElement = useTemplateRef('currentLevel') const { heightChart: heightChartCurrentLevel, + totalHeight: totalHeightCurrentLevel, changeSuspendState: changeSuspendStateCurrentLevel, updateVirtualHeight: updateVirtualHeightCurrentLevel, - } = useVirtualScrolling(currentLevel, currentLevelElement) + } = useVirtualScrolling(currentLevel, currentLevelElement, currentStatus) const treeViewIsSimple = computed( () => !mergedConfig.value.conversationTreeAdvanced, @@ -223,32 +239,6 @@ export default { ) // # Scrolling - const tryScrollTo = (id) => { - if (!id) { - return - } - if (isPage.value) { - router.push({ name: 'conversation', params: { statusId: id } }) - } - // Because the conversation can be unmounted when out of sight - // and mounted again when it comes into sight, - // the `mounted` or `created` function in `status` should not - // contain scrolling calls, as we do not want the page to jump - // when we scroll with an expanded conversation. - // - // Now the method is to rely solely on the `focused` watcher - // in `status` components. - // In linear views, all statuses are rendered at all times, but - // in tree views, it is possible that a change in active status - // removes and adds status components (e.g. an originally child - // status becomes an ancestor status, and thus they will be - // different). - // Here, let the components be rendered first, in order to trigger - // the `focused` watcher. - nextTick(() => { - setFocused(id) - }) - } const diveIntoStatus = (id) => { tryScrollTo(id) } @@ -261,6 +251,11 @@ export default { loadError, mobileLayout, + // # Conversation Expansion + isPage, + isExpanded, + toggleExpanded, + // # Focus focused, setFocused, @@ -270,11 +265,6 @@ export default { currentStatus, getReplies, - // # Conversation Expansion - isPage, - isExpanded, - toggleExpanded, - // # Misc UI things getStatusClasses, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 6c3421033..3a61decf5 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -106,6 +106,7 @@ :focused="focused === element.status.id" conversation-rank="ancestor" + :data-status-id="element.id" @goto="setFocused" @dive="diveIntoStatus(element.status.id)" @@ -165,6 +166,7 @@ :focused="focused === element.id || focused === element.status.retweeted_status?.id" + :data-status-id="element.id" @goto="setFocused" @toggle-expanded="toggleExpanded" @suspendable-state-change="changeSuspendStateLinear" diff --git a/src/components/status/status.js b/src/components/status/status.js index 92c887386..7b4649b6a 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -121,19 +121,15 @@ const Status = { ], inject: { profileUserId: { - type: String, default: null, }, isPage: { - type: Boolean, default: false, }, isExpanded: { - type: Boolean, default: false, }, expandable: { - type: Boolean, default: false, }, }, diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 2adbf5d41..c0a1b0238 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -199,7 +199,7 @@ />