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

This commit is contained in:
Henry Jameson 2026-09-09 19:39:45 +03:00
commit 4d0aaafb4b
15 changed files with 765 additions and 543 deletions

View file

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

View file

@ -3,7 +3,6 @@ import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
onMounted,
provide,
ref,
toRefs,
@ -13,8 +12,6 @@ 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 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'
@ -23,14 +20,14 @@ 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 {
fetchConversation as apiFetchConversation,
fetchStatus as apiFetchStatus,
} from 'src/api/public.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { useScrollPosition } from 'src/composables/useScrollPosition.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -73,11 +70,35 @@ 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 scroller = useScrollPosition()
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}]`)
return await scroller.scrollIntoView(target, { block: 'center' })
}
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 +107,20 @@ 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
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
// # Focus
const focusedId = ref(statusId.value)
const focused = computed(() => (isExpanded.value ? focusedId.value : null))
@ -109,52 +138,28 @@ 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 status = computed(() => getStatusObject(focusedId.value))
const {
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(focusedId, isExpanded)
const fetchConversation = async () => {
if (status.value) {
const {
data: { ancestors, descendants },
timestamp,
} = await apiFetchConversation({
id: statusId.value,
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
watch(expanded, async (value) => {
if (value) {
await fetchConversation()
} 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
}
resetDisplayState()
}
}
if (isPage.value) return
await tryScrollTo(currentStatus.value.id)
}, { flush: 'post' })
const resetDisplayState = () => {
setFocused(statusId.value)
threadDisplay.value = new Map()
resetThreadDisplay()
}
watch(statusId, (newVal, oldVal) => {
const newConversationId = getConversationId(newVal)
@ -171,243 +176,11 @@ 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 (!status.value) {
return []
}
if (!isExpanded.value) {
return [status.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()
const statusReplies = computed(() => {
return getReplies(status.value.id)
})
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()
}
// # 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
// 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)
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(
@ -420,179 +193,100 @@ export default {
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(() => isLinearView.value && isExpanded.value)
const {
heightChart: heightChartLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
} = useVirtualScrolling(conversation, linearElement, scroller, linearScrollCompensation, currentStatus)
// # Tree style stuff
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())
})
const {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
} = useTreeConversationTopology(conversation, replies, focusedId)
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')
}
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(() => isTreeView.value && isExpanded.value)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
} = useVirtualScrolling(currentAncestors, ancestorsElement, scroller, treeScrollCompensation)
const currentLevel = computed(() => [currentStatus.value].filter(Boolean))
const currentLevelElement = useTemplateRef('currentLevel')
const {
heightChart: heightChartCurrentLevel,
totalHeight: totalHeightCurrentLevel,
changeSuspendState: changeSuspendStateCurrentLevel,
updateVirtualHeight: updateVirtualHeightCurrentLevel,
} = useVirtualScrolling(currentLevel, currentLevelElement, scroller, false)
// ## 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)
})
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && ancestors.value.get(focusedId.value) != null,
() => isExpanded.value && heightChartAncestors.value.length > 0,
)
const shouldFadeAncestors = computed(
() => mergedConfig.value.conversationTreeFadeAncestors,
)
const shouldShowOtherRepliesButton = computed(
() => mergedConfig.value.conversationOtherRepliesButton === 'below',
)
// # 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)
}
const diveToTopLevel = () => {
tryScrollTo(currentAncestors.value[0].id)
}
const diveIntoStatus = (id) => tryScrollTo(id)
const diveToTopLevel = () => tryScrollTo(currentAncestors.value[0].id)
return {
// # Misc
loadStatusError,
loadError,
mobileLayout,
// # Focus
focused,
setFocused,
// # Main things
status,
statusReplies,
getReplies,
conversation,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Virtual scrolling stuff
onStatusSuspendStateChange,
updateVirtualHeight,
// # Focus
focused,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # 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,
@ -601,12 +295,11 @@ export default {
// ### Thread Display
showThreadRecursively,
// ## Derived values and config
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
shouldFadeAncestors,
shouldShowOtherRepliesButton,
// # Scrolling
diveToTopLevel,

View file

@ -40,24 +40,6 @@
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}

View file

@ -37,12 +37,12 @@
/>
</div>
<div
v-if="isPage && !status"
v-if="isPage && !currentStatus"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
@ -50,7 +50,7 @@
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
{{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
@ -68,7 +68,7 @@
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
keypath="currentStatus.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@ -88,71 +88,68 @@
</div>
<div
v-if="shouldShowAncestors"
ref="ancestors"
class="thread-ancestors"
>
<article
v-for="status in currentAncestors"
v-for="element in heightChartAncestors"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': statusReplies.size > 1, '-faded': shouldFadeAncestors}"
:class="{'thread-ancestor-has-other-replies': getReplies(element.id).size > 1, '-faded': shouldFadeAncestors}"
>
<Status
v-if="element.type === 'status'"
class="conversation-status panel-body"
:class="getStatusClasses(status)"
:class="getStatusClasses(element.status)"
:status-id="status.id"
:replies="statusReplies"
:status-id="element.status.id"
:replies="getReplies(element.status.id)"
:focused="focused === status.id"
can-dive
:focused="focused === element.status.id"
conversation-rank="ancestor"
:data-status-id="element.id"
@goto="setFocused"
@dive="diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
@dive="diveIntoStatus(element.status.id)"
@suspendable-state-change="changeSuspendStateAncestors"
@height-change="updateVirtualHeightAncestors"
/>
<div
v-if="shouldShowOtherRepliesButton && statusReplies.size > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: statusReplies.size - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</article>
</div>
<ThreadTree
:status-id="status.id"
:depth="0"
<div
class="currentLevel"
ref="currentLevel"
>
<!-- Technically this will always have a single element but -->
<!-- it's more convenient for us to use a v-for here -->
<template v-for="element in heightChartCurrentLevel">
<ThreadTree
v-if="element.type === 'status'"
:status-id="currentStatus.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/>
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevel"
@height-change="updateVirtualHeightCurrentLevel"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div>
<div
v-else-if="isLinearView"
ref="linear"
class="thread-body"
>
<article
@ -160,25 +157,26 @@
class="panel-body"
:key="element.id ?? element.ids"
>
<Status
v-if="element.type === 'status'"
class="conversation-status"
:class="getStatusClasses(element.status)"
:status-id="element.status.id"
:replies="getReplies(element.status.id)"
:focused="focused === element.id || focused === element.status.retweeted_status?.id"
:data-status-id="element.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="changeSuspendStateLinear"
@height-change="updateVirtualHeightLinear"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
<Status
v-if="element.type === 'status'"
class="conversation-status"
:class="getStatusClasses(status)"
:status-id="element.status.id"
:replies="getReplies(status.id)"
:focused="focused === element.id || focused === element.status.retweeted_status?.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/>
</article>
</div>
</div>

View file

@ -1,21 +0,0 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useScrollPosition() {
const x = ref(0)
const y = ref(0)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
return { x, y }
}

View file

@ -107,6 +107,10 @@ const Status = {
ignoreMute: Boolean,
threadDisplayState: String,
conversationRank: {
type: String,
default: 'linear',
},
},
emits: [
'goto',
@ -116,9 +120,18 @@ const Status = {
'heightChange',
],
inject: {
profileUserId: { default: null },
isPage: { default: false },
isExpanded: { default: false },
profileUserId: {
default: null,
},
isPage: {
default: false,
},
isExpanded: {
default: false,
},
expandable: {
default: false,
},
},
data() {
return {
@ -134,6 +147,12 @@ const Status = {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
rootClasses() {
return [
{'-focused': this.focused, '-conversation': !this.isPage && this.isExpanded },
`-conversation-rank-${this.conversationRank}`,
]
},
// Whatever we're given to work with
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
@ -173,9 +192,12 @@ const Status = {
simpleTree() {
return !this.mergedConfig.conversationTreeAdvanced
},
showOtherRepliesAsButton() {
showOtherRepliesInside() {
return this.mergedConfig.conversationOtherRepliesButton === 'inside'
},
showOtherRepliesBelow() {
return this.mergedConfig.conversationOtherRepliesButton === 'below'
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&

View file

@ -3,6 +3,8 @@
white-space: normal;
overflow-wrap: break-word;
text-wrap: pretty;
display: flex;
flex-direction: column;
&:hover {
--_still-image-img-visibility: visible;
@ -281,6 +283,10 @@
margin-top: var(--status-margin);
}
.status-action-buttons {
margin-top: var(--status-margin);
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
@ -372,7 +378,9 @@
}
}
.status-action-buttons {
margin-top: var(--status-margin);
.thread-tree-show-replies-button {
display: block;
padding: var(--status-margin);
padding-left: var(--status-margin);
}
}

View file

@ -3,7 +3,7 @@
v-if="!hideStatus"
ref="root"
class="Status"
:class="[{ '-focused': focused }, { '-conversation': !isPage && isExpanded }]"
:class="rootClasses"
>
<div
v-if="error"
@ -199,7 +199,7 @@
/>
</span>
<button
v-if="!isExpanded && !isPreview"
v-if="expandable && !isExpanded && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
@ -427,7 +427,7 @@
class="replies"
>
<button
v-if="showOtherRepliesAsButton && replies.size > 1"
v-if="showOtherRepliesInside && replies.size > 1"
class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.size - 1 }, replies.size - 1)"
@click.prevent="$emit('dive')"
@ -548,6 +548,25 @@
@close-accepted="closeReplyForm"
/>
</div>
<i18n-t
v-if="inConversation && conversationRank === 'ancestor' && !isPreview && showOtherRepliesBelow && replies?.size > 1"
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive')"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: replies.size - 1 }) }}
</span>
</template>
</i18n-t>
</template>
</div>
</template>

View file

@ -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 {
@ -15,7 +16,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',
@ -27,22 +40,25 @@ const ThreadTree = {
'focused',
'replies',
'threadDisplay',
'threadDisplayDefault',
'isExpanded',
'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() {
const result =
this.threadDisplay.get(this.statusId) ??
this.threadDisplayDefault.get(this.statusId)
return result === 'showing'
return this.threadDisplay.get(this.status.id) === 'showing'
},
canDive() {
return this.isExpanded
@ -92,6 +108,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,
})
},
},
}

View file

@ -1,5 +1,8 @@
<template>
<article class="thread-tree">
<article
ref="root"
class="thread-tree"
>
<Status
:key="statusId"
class="conversation-status conversation-status-treeview panel-body"
@ -7,12 +10,14 @@
:replies="getReplies(statusId)"
:focused="focused === statusId"
:data-status-id="statusId"
:conversation-rank="depth === 0 ? 'current' : 'child'"
:thread-display-state="threadDisplay.get(statusId)"
@dive="$emit('dive', statusId)"
@goto="$emit('goto', statusId)"
@toggle-expanded="$emit('toggleExpanded', statusId)"
@suspendable-state-change="$emit('suspendableStateChange', e)"
@suspendable-state-change="(e) => $emit('suspendableStateChange', e)"
/>
<div
v-if="currentReplies.length > 0 && threadShowing"
@ -50,7 +55,7 @@
</template>
<template #text>
<span>
{{ $t('status.thread_follow', { numStatus: totalReplyCount[statusId] }, totalReplyCount[statusId]) }}
{{ $t('status.thread_follow', { numStatus: totalReplyCount[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>
@ -69,7 +74,7 @@
</template>
<template #text>
<span>
{{ $t('status.thread_show_full', { numStatus: totalReplyCount[statusId], depth: totalReplyDepth[statusId] }, totalReplyCount[statusId]) }}
{{ $t('status.thread_show_full', { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }, totalReplyCount[status.id]) }}
</span>
</template>
</i18n-t>

View file

@ -0,0 +1,124 @@
import { get } from 'lodash-es'
import { computed, provide, ref } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import {
fetchConversation as apiFetchConversation,
fetchStatus as apiFetchStatus,
} from 'src/api/public.js'
export function useConversation(statusId, expanded) {
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 loadError = ref(null)
const currentStatus = computed(() => getStatusObject(statusId.value))
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 (!expanded.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)
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 {
loadError.value = null
const { data: status } = await apiFetchStatus({
id: statusId.value,
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: [status] })
fetchConversation()
} catch (error) {
console.error(error)
loadError.value = error
}
}
}
return {
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
}
}

View file

@ -0,0 +1,34 @@
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
export function useScrollPosition() {
const x = ref(0)
const y = ref(0)
const inProgress = ref(false)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
const scrollBy = async (x1, y1, options) => {
inProgress.value = true
await window.scrollBy(x1, y1, options)
inProgress.value = false
}
const scrollIntoView = async (element, options) => {
inProgress.value = true
await element.scrollIntoView(options)
inProgress.value = false
}
return { x, y, scrollBy, scrollIntoView, inProgress }
}

View file

@ -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(
[...threadDisplayDefault.value.entries()].map(([k, v]) => [
k,
threadDisplayOverride.value.get(k) ?? threadDisplayDefault.value.get(k),
]),
)
})
const setThreadDisplayRecursively = (id, value) => {
threadDisplayOverride.value.set(id, value)
;[...getReplies(id)]
.map((k) => k.id)
.map((id) => setThreadDisplayRecursively(id, value))
}
const showThreadRecursively = (id) => {
setThreadDisplayRecursively(id, 'showing')
}
const resetThreadDisplay = () => {
threadDisplayOverride.value = new Map()
}
return {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
}
}

View file

@ -0,0 +1,215 @@
import { storeToRefs } from 'pinia'
import { computed, ref, watch, nextTick, toValue } from 'vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useWindowSize } from 'src/composables/useWindowSize.js'
export function useVirtualScrolling(
conversation,
body,
scrollPosition,
scrollCompensation,
anchorStatus,
) {
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const anchor = computed(() => anchorStatus?.value.id)
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, { immediate: true })
// 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: scrollY, inProgress: scrollInProgress, scrollBy } = scrollPosition
const { height: windowHeight } = useWindowSize()
const topScrollBoundary = ref(0)
const bottomScrollBoundary = 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
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
const windowWatcher = watch(windowHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(totalHeight, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = () => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
}
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)
return chart
})
watch(heightChart, async (newVal, oldVal) => {
if (!toValue(scrollCompensation)) return
if (scrollInProgress.value) return
pauseWatchers()
const getAnchoredEl = (list) => anchor.value
? list.find(({ id }) => id === anchor.value)
: list[list.length - 1]
const oldElement = getAnchoredEl(oldVal)
const newElement = getAnchoredEl(newVal)
const oldOffset = oldElement?.top ?? 0
const newOffset = newElement?.top ?? 0
const diff = newOffset - oldOffset // Positive = down, Negative = up
if (diff !== 0) {
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
scrollBy(0, diff)
}
updateBoundaries()
resumeWatchers()
})
const heightChartGrouped = computed(() => {
// Determine visibility state
const chart = heightChart.value.map((heightChartItem) => {
const itemBottomBoundary = heightChartItem.top + heightChartItem.height
const itemTopBoundary = heightChartItem.top
const finalTopScrollBoundary = topScrollBoundary.value - buffer.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + buffer.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return { ...heightChartItem, visible: isBelowTopBoundary && isAboveBottomBoundary }
})
// 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: heightChartGrouped,
changeSuspendState,
updateVirtualHeight,
}
}