Page MenuHomePhorge

No OneTemporary

Size
37 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Sat, Sep 19, 3:11 PM (20 h, 19 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769586
Default Alt Text
(37 KB)

Event Timeline