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 919017387..e707008d1 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, @@ -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, diff --git a/src/components/conversation/conversation.scss b/src/components/conversation/conversation.scss index ae06dbfa7..daf3cb11e 100644 --- a/src/components/conversation/conversation.scss +++ b/src/components/conversation/conversation.scss @@ -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); } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 9b8d342c2..3a61decf5 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -37,12 +37,12 @@ />
-

+

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

-
- - - - -
-
+ v-if="element.type === 'spacer'" + class="virtual-spacer" + :style="{ height: element.height + 'px' }" + />
- + + + +
+
-
diff --git a/src/components/conversation/useScrollPosition.js b/src/components/conversation/useScrollPosition.js deleted file mode 100644 index 7772a35a4..000000000 --- a/src/components/conversation/useScrollPosition.js +++ /dev/null @@ -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 } -} diff --git a/src/components/status/status.js b/src/components/status/status.js index 495503491..7b4649b6a 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -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) && diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 74a50355a..d198f8d7e 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -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); } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index d78c52eaa..c0a1b0238 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -3,7 +3,7 @@ v-if="!hideStatus" ref="root" class="Status" - :class="[{ '-focused': focused }, { '-conversation': !isPage && isExpanded }]" + :class="rootClasses" >
+ + + + diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js index 714b2b3f5..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 { @@ -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, + }) + }, }, } diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index 8d8f0af33..a13e3d692 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/composables/useConversation.js b/src/composables/useConversation.js new file mode 100644 index 000000000..989e12c61 --- /dev/null +++ b/src/composables/useConversation.js @@ -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, + } +} diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js new file mode 100644 index 000000000..662a9d355 --- /dev/null +++ b/src/composables/useScrollPosition.js @@ -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 } +} diff --git a/src/composables/useTreeConversationTopology.js b/src/composables/useTreeConversationTopology.js new file mode 100644 index 000000000..b2ce7e23d --- /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( + [...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, + } +} diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js new file mode 100644 index 000000000..1568b0a41 --- /dev/null +++ b/src/composables/useVirtualScrolling.js @@ -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, + } +} 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