Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85713038
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
37 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 55d70d20b8..c89cfd33a6 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,394 +1,322 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
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'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
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)
const streamingEnabled = computed(
() =>
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))
const setFocused = (id) => {
if (!id) return
focusedId.value = id
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id)
useStatusesStore().fetchEmojiReactions(id)
}
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 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 {
+ currentStatus,
+ conversation,
+ replies,
+ getReplies,
+ fetchConversation,
+ loadError,
+ } = useConversation(focusedId, isExpanded)
+
const resetDisplayState = () => {
setFocused(statusId.value)
resetThreadDisplay()
}
watch(statusId, (newVal, oldVal) => {
const newConversationId = getConversationId(newVal)
const oldConversationId = getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
setFocused(newVal)
} else {
resetDisplayState()
fetchConversation()
}
})
- 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()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(
() => conversation.value[conversation.value.legnth - 1],
)
const getStatusClasses = (status, active) => ({
'-first': status.id === firstStatus.value?.id,
'-last': status.id === lastStatus.value?.id,
})
// # 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')
const {
topLevel,
currentAncestors,
threadDisplay,
showThreadRecursively,
resetThreadDisplay,
} = 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,
)
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && currentAncestors.value.size > 0,
)
const shouldFadeAncestors = computed(
() => mergedConfig.value.conversationTreeFadeAncestors,
)
const shouldShowOtherRepliesButton = computed(
() => 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) {
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)
}
return {
// # Misc
- loadStatusError,
+ loadError,
mobileLayout,
// # Focus
focused,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # Conversation Expansion
isPage,
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,
currentAncestors,
// ### Thread Display
showThreadRecursively,
- // ## Derived values and config
+ // ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
shouldFadeAncestors,
shouldShowOtherRepliesButton,
// # Scrolling
diveToTopLevel,
diveIntoStatus,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 832dba93d8..fd45ec0578 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,189 +1,205 @@
<template>
<div
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</h1>
<button
v-if="!isPage"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="isPage && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="isPage"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !status"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
- <p v-if="!loadStatusError">
+ <p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
- {{ $t('status.load_error', { error: loadStatusError }) }}
+ {{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="currentStatus.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
+ ref="ancestors"
class="thread-ancestors"
>
<article
v-for="status in currentAncestors"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': statusReplies.size > 1, '-faded': shouldFadeAncestors}"
>
<Status
class="conversation-status panel-body"
:class="getStatusClasses(status)"
:status-id="status.id"
:replies="getReplies(status.id)"
:focused="focused === status.id"
can-dive
@goto="setFocused"
@dive="diveIntoStatus(status.id)"
- @suspendable-state-change="onStatusSuspendStateChange"
- @height-change="updateVirtualHeight"
+ @suspendable-state-change="changeSuspendStateAncestors"
+ @height-change="updateVirtualHeightAncestors"
/>
<div
v-if="shouldShowOtherRepliesButton && getReplies(status.id).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: getReplies(status.id).size - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
- <ThreadTree
- :status-id="currentStatus.id"
- :depth="0"
+ <div
+ class="currentStatus"
+ 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"
class="thread-body"
>
<article
v-for="element in heightChartLinear"
class="panel-body"
:key="element.id ?? element.ids"
>
<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"
+ @suspendable-state-change="changeSuspendStateLinear"
+ @height-change="updateVirtualHeightLinear"
/>
</article>
</div>
</div>
</div>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
index 9dbb4ff779..2dacf42123 100644
--- a/src/components/thread_tree/thread_tree.js
+++ b/src/components/thread_tree/thread_tree.js
@@ -1,94 +1,114 @@
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleRight,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleRight)
const ThreadTree = {
components: {},
name: 'ThreadTree',
props: {
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',
'toggleExpanded',
'showThreadRecursively',
],
inject: [
'conversation',
'focused',
'replies',
'threadDisplay',
'isExpanded',
'isPage',
],
computed: {
currentReplies() {
return [...this.getReplies(this.statusId)].map(({ id }) => id)
},
simple() {
return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced
},
threadShowing() {
return this.threadDisplay.get(this.statusId) === 'showing'
},
canDive() {
return this.isExpanded
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
[...this.getReplies(id)]
.map(({ id }) => id)
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).forEach(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
[...this.getReplies(id)]
.map(({ id }) => id)
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).forEach(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
},
methods: {
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,
+ })
+ },
},
}
export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index 8d8f0af331..b8d35e3dff 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,96 +1,99 @@
<template>
- <article class="thread-tree">
+<article
+ ref="root"
+ class="thread-tree"
+>
<Status
:key="statusId"
class="conversation-status conversation-status-treeview panel-body"
:status-id="statusId"
:replies="getReplies(statusId)"
:focused="focused === statusId"
: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"
class="thread-tree-replies"
>
<ThreadTree
v-for="replyStatusId in currentReplies"
:key="replyStatusId"
:depth="depth + 1"
:status-id="replyStatusId"
@show-thread-recursively="(e) => $emit('showThreadRecursively', e)"
@goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
@toggle-expanded="(e) => $emit('toggleExpanded', e)"
/>
</div>
<div
v-if="currentReplies.length && !threadShowing"
class="thread-tree-replies thread-tree-replies-hidden"
>
<i18n-t
v-if="simple"
scope="global"
tag="button"
keypath="status.thread_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('dive', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_follow', { numStatus: totalReplyCount[statusId] }, totalReplyCount[statusId]) }}
</span>
</template>
</i18n-t>
<i18n-t
v-else
scope="global"
tag="button"
keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="$emit('showThreadRecursively', statusId)"
>
<template #icon>
<FAIcon
icon="angle-double-down"
/>
</template>
<template #text>
<span>
{{ $t('status.thread_show_full', { numStatus: totalReplyCount[statusId], depth: totalReplyDepth[statusId] }, totalReplyCount[statusId]) }}
</span>
</template>
</i18n-t>
</div>
</article>
</template>
<script src="./thread_tree.js"></script>
<style lang="scss">
.thread-tree-replies {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-tree-replies-hidden {
padding: var(--status-margin);
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
</style>
diff --git a/src/composables/useConversation.js b/src/composables/useConversation.js
new file mode 100644
index 0000000000..989e12c61a
--- /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/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 9b04508781..b6cb769fc1 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,169 +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 { y: scrollY } = useScrollPosition()
const { height: windowHeight } = useWindowSize()
- const realTopScrollBoundary = ref(0)
- const realBottomScrollBoundary = ref(0)
+ 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
- realTopScrollBoundary.value = distanceItemTopToWindowTop
- realBottomScrollBoundary.value = distanceItemTopToWindowBottom
+ topScrollBoundary.value = distanceItemTopToWindowTop
+ bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
watch(windowHeight, updateBoundaries)
- watch(topScrollBoundary, updateBoundaries)
+ watch(scrollY, 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 finalTopScrollBoundary = topScrollBoundary.value - buffer.value
const finalBottomScrollBoundary =
- realBottomScrollBoundary.value + buffer.value
+ bottomScrollBoundary.value + buffer.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
- const belowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
+ const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
- const aboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
+ const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
- heightChartItem.visible = belowTopBoundary && aboveBottomBoundary
+ 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,
changeSuspendState,
updateVirtualHeight,
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 3:11 PM (21 h, 40 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769586
Default Alt Text
(37 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment