massive revision of conversation component, specificially threading stuff

This commit is contained in:
Henry Jameson 2026-09-08 03:01:22 +03:00
commit 74f8ef0719
11 changed files with 469 additions and 577 deletions

View file

@ -83,8 +83,9 @@ export default () => {
}, },
{ {
name: 'conversation', name: 'conversation',
path: '/notice/:id', path: '/notice/:statusId',
component: ConversationPage, component: ConversationPage,
props: true,
meta: { dontScroll: true }, meta: { dontScroll: true },
}, },
{ {

View file

@ -1,5 +1,7 @@
import { get, reduce } from 'lodash-es' import { get, reduce } from 'lodash-es'
import { mapState } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, watch, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue' 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 PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
@ -14,7 +16,7 @@ import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js' import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js' import { useStreamingStore } from 'src/stores/streaming.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js' import { fetchConversation as apiFetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js' import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
@ -52,7 +54,7 @@ const sortById = (a, b) => {
} }
} }
const conversation = { export default {
props: { props: {
statusId: { statusId: {
// Main thing // Main thing
@ -94,313 +96,6 @@ const conversation = {
}, },
}, },
emits: ['update:virtualHeight'], emits: ['update:virtualHeight'],
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
virtualHeight: 120,
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
mounted() {
this.updateVirtualHeight()
},
computed: {
status() {
return useStatusesStore().allStatuses.get(this.statusId)
},
maxDepthToShowByDefault() {
// 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 = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle !== 'tree'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size === 0
},
hide() {
return this.virtualHidden && this.suspendable
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = useStatusesStore().conversations.get(
this.conversationId,
)
return [...conversation.keys()]
.map((k) => useStatusesStore().allStatuses.get(k))
.filter((status) => status.type != 'repeat') // Old backend behavior?
.toSorted(sortById)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].flat()
})
.flat()
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(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.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
!this.getReplies(cur.id)
.map((v) => v.id)
.includes(k.id),
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
return { height: this.virtualHeight + 'px' }
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useStreamingStore, {
mastoUserSocketStatus: (state) => state.state,
}),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: { components: {
ThreadTree, ThreadTree,
QuickFilterSettings, QuickFilterSettings,
@ -409,149 +104,316 @@ const conversation = {
PostStatusForm, PostStatusForm,
RichContent, RichContent,
}, },
watch: { setup(props, ctx) {
statusId(newVal, oldVal) { const { emit } = ctx
const newConversationId = this.getConversationId(newVal) const {
const oldConversationId = this.getConversationId(oldVal) statusId,
if ( collapsable,
newConversationId && pinnedStatusIdsObject,
oldConversationId && inProfile,
newConversationId === oldConversationId profileUserId,
) { virtualHidden,
this.setFocused(this.originalStatusId) } = toRefs(props)
} else { const router = useRouter()
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.updateVirtualHeight()
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants }, timestamp }) => {
useStatusesStore().addNewStatuses({ statuses: ancestors, timestamp })
useStatusesStore().addNewStatuses({
statuses: descendants,
timestamp,
})
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
useStatusesStore().addNewStatuses({ statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) { const hoisted = {}
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id) // # Main Configuration
useStatusesStore().fetchEmojiReactions(id) const { mergedConfig } = storeToRefs(useMergedConfigStore())
}, const { mastoUserSocketStatus } = storeToRefs(useStreamingStore())
toggleExpanded() { const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
this.expanded = !this.expanded const streamingEnabled = computed(() =>
}, mergedConfig.value.useStreamingApi &&
getConversationId(statusId) { mastoUserSocketStatus === WSConnectionStatus.JOINED
const status = useStatusesStore().allStatuses.get(statusId) )
// # Main things
const getStatusObject = (id) => useStatusesStore().allStatuses.get(id)
const getConversationId = (statusId) => {
const status = getStatusObject(statusId)
return get( return get(
status, status,
'retweeted_status.statusnet_conversation_id', 'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'), get(status, 'statusnet_conversation_id'),
) )
}, }
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = { const status = computed(() => getStatusObject(statusId.value))
...this.threadDisplayStatusObject, const mainStatusId = computed(() => {
[id]: nextStatus, if (status.value.retweeted_status) {
return status.value.retweeted_status.id
} else {
return statusId.value
} }
}, })
getStatusClasses(status, active) { const conversationId = computed(() => getConversationId(statusId.value))
return { const conversation = computed(() => {
'-virtual-active': active, if (!status.value) {
'-last': status.id === this.conversation[this.conversation.length - 1].id, return []
'-first': status.id === this.conversation[0].id,
} }
},
toggleThreadDisplay(id) { if (!isExpanded.value) {
const curStatus = this.threadDisplayStatus[id] return [status.value]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
} }
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined const conversation = useStatusesStore().conversations.get(
}, conversationId.value,
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
) )
},
// only used when we are not on a page return [...conversation.keys()]
undive() { .map((k) => useStatusesStore().allStatuses.get(k))
this.inlineDivePosition = null .filter((status) => status.type != 'repeat') // Old backend behavior?
this.setFocused(this.statusId) .toSorted(sortById)
}, })
tryScrollTo(id) { 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 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,
})
setFocused(mainStatusId.value)
} else {
try {
loadStatusError.value = null
const { data: status } = await fetchStatus({
id: statusId.value,
credentials: useOAuthStore().token,
})
useStatusesStore().addNewStatuses({ statuses: [status] })
fetchConversation()
} catch (error) {
console.error(error)
loadStatusError.value = error
}
}
}
const resetDisplayState = () => {
hoisted.undive()
threadDisplay.value = new Map()
}
// # Virtual scrolling stuff
const unsuspendibleIds = ref(new Set())
const suspendable = computed(() => unsuspendibleIds.value.size === 0)
const hide = computed(() => virtualHidden.value && suspendable.value)
const onStatusSuspendStateChange = ({ id, suspend }) => {
if (!suspend) {
unsuspendibleIds.value.add(id)
} else {
unsuspendibleIds.value.delete(id)
}
}
// # Misc UI things
const loadStatusError = ref(null)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => conversation.value[conversation.value.legnth - 1])
const getStatusClasses = (status, active) => ({
'-virtual-active': active,
'-first': status.id === firstStatus.value?.id,
'-last': status.id === lastStatus.value?.id,
})
// # 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()
}
})
// # Focus
const focused = ref(null)
const maybeFocused = computed(() => isExpanded.value ? focused.value : null)
const setFocused = (id) => {
if (!id) return
focused.value = id
if (!streamingEnabled.value) {
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id)
useStatusesStore().fetchEmojiReactions(id)
}
watch(statusId, (newVal, oldVal) => {
const newConversationId = getConversationId(newVal)
const oldConversationId = getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
setFocused(mainStatusId.value)
} else {
fetchConversation()
}
})
// Component created
if (isPage.value) {
fetchConversation()
}
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
// ## Tree view settings
const treeViewIsSimple = computed(() => !mergedConfig.value.conversationTreeAdvanced)
const shouldFadeAncestors = computed(() => mergedConfig.value.conversationTreeFadeAncestors)
const otherRepliesButtonPosition = computed(() => mergedConfig.value.conversationOtherRepliesButton)
const showOtherRepliesButtonBelowStatus = computed(() => otherRepliesButtonPosition.value === 'below')
const showOtherRepliesButtonInsideStatus = computed(() => otherRepliesButtonPosition.value === 'inside')
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)
})
// ## Tree style state
// ### Dive
const inlineDivePosition = ref(null)
const currentStatusId = computed(() => inlineDivePosition.value ?? statusId.value)
// ### 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 getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
const getAncestors = (id) => [...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
const currentAncestors = computed(() => getAncestors(currentStatusId.value).reverse())
const currentDepth = computed(() => currentAncestors.value.length)
const topLevel = computed(() => [...ancestors.value.entries()]
.filter(([id, ancestors]) => ancestors.size === 0)
.map(([id]) => getStatusObject(id))
)
const currentStatus = computed(() => getStatusObject(currentStatusId.value))
// ### 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 setThreadDisplayRecursively = (id, value) => {
threadDisplay.value.set(id, value)
;[...getReplies(id)]
.map((k) => k.id)
.map((id) => setThreadDisplayRecursively(id, value))
}
const showThreadRecursively = (id) => {
setThreadDisplayRecursively(id, 'showing')
}
// ## Derived values
const shouldShowAllConversationButton = computed(() => currentAncestors.value.length > 0 && topLevel.value.length > 1)
const shouldShowAncestors = computed(() => isExpanded.value && ancestors.value.get(currentStatusId.value) != null)
// # Scrolling / diving
const tryScrollTo = (id) => {
if (!id) { if (!id) {
return return
} }
if (this.isPage) { if (isPage.value) {
// set statusId router.push({ name: 'conversation', params: { statusId: id } })
this.$router.push({ name: 'conversation', params: { id } })
} else { } else {
this.inlineDivePosition = id inlineDivePosition.value = id
} }
// Because the conversation can be unmounted when out of sight // Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight, // and mounted again when it comes into sight,
@ -568,78 +430,64 @@ const conversation = {
// different). // different).
// Here, let the components be rendered first, in order to trigger // Here, let the components be rendered first, in order to trigger
// the `focused` watcher. // the `focused` watcher.
this.$nextTick(() => { nextTick(() => {
this.setFocused(id) setFocused(id)
}) })
}, }
goToCurrent() { const goToCurrent = () => {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id) tryScrollTo(diveRoot)
}, }
statusById(id) { const diveIntoStatus = (id) => {
return this.statusMap[id] tryScrollTo(id)
}, }
parentOf(id) { const diveToTopLevel = () => {
const status = this.statusById(id) tryScrollTo(currentAncestors.value[0].id)
if (!status) { }
return undefined const undive = () => {
} inlineDivePosition.value = null
const { in_reply_to_status_id: parentId } = status setFocused(statusId.value)
if (!this.statusMap[parentId]) { }
return undefined hoisted.undive = undive
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
onPosted(data) {
if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } })
}
},
updateVirtualHeight() {
if (this.hide) return // no updates when not rendering
if (!this.status) return // not loaded yet
this.$nextTick(() => {
this.virtualHeight = this.$refs.body.getBoundingClientRect().height
this.$emit('update:virtualHeight', {
id: this.status.id,
height: this.virtualHeight,
top: this.$el.clientTop,
})
})
},
},
}
export default conversation return {
...hoisted,
isLinearView,
isTreeView,
isExpanded,
conversation,
hide,
collapsable,
mobileLayout,
toggleExpanded,
isPage,
status,
loadStatusError,
maybeFocused,
setFocused,
showOtherRepliesButtonBelowStatus,
showOtherRepliesButtonInsideStatus,
onStatusSuspendStateChange,
getStatusClasses,
getReplies,
currentAncestors,
statusId,
collapsable,
pinnedStatusIdsObject,
inProfile,
profileUserId,
virtualHidden,
shouldShowAncestors,
shouldShowAllConversationButton,
topLevel,
shouldFadeAncestors,
currentStatus,
treeViewIsSimple,
diveToTopLevel,
threadDisplay,
threadDisplayDefault,
replies,
diveIntoStatus,
showThreadRecursively,
}
}
}

View file

@ -81,7 +81,7 @@
</template> </template>
<template #text> <template #text>
<span> <span>
{{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }} {{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span> </span>
</template> </template>
</i18n-t> </i18n-t>
@ -91,11 +91,11 @@
class="thread-ancestors" class="thread-ancestors"
:min-item-size="15" :min-item-size="15"
:buffer="500" :buffer="500"
:items="ancestorsOf(diveRoot)" :items="currentAncestors"
role="feed" role="feed"
list-tag="article" list-tag="article"
item-tag="article" item-tag="article"
:item-class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors, 'thread-ancestor': true }" :item-class="{'thread-ancestor-has-other-replies': getReplies(status.id).size > 1, '-faded': shouldFadeAncestors, 'thread-ancestor': true }"
flow-mode flow-mode
page-mode page-mode
> >
@ -105,7 +105,6 @@
:active="active" :active="active"
> >
<Status <Status
ref="statusComponent"
class="conversation-status panel-body" class="conversation-status panel-body"
:class="getStatusClasses(status, active)" :class="getStatusClasses(status, active)"
@ -119,17 +118,15 @@
:in-profile="inProfile" :in-profile="inProfile"
:in-conversation="isExpanded" :in-conversation="isExpanded"
:profile-user-id="profileUserId" :profile-user-id="profileUserId"
:simple-tree="treeViewIsSimple"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus" :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
can-dive can-dive
@goto="setFocused" @goto="setFocused"
@dive="() => diveIntoStatus(status.id)" @dive="() => diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
<div <div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1" v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).size > 1"
class="thread-ancestor-dive-box" class="thread-ancestor-dive-box"
> >
<div <div
@ -149,7 +146,7 @@
</template> </template>
<template #text> <template #text>
<span> <span>
{{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }} {{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).size - 1).size - 1 }) }}
</span> </span>
</template> </template>
</i18n-t> </i18n-t>
@ -159,12 +156,10 @@
</template> </template>
</DynamicScroller> </DynamicScroller>
<ThreadTree <ThreadTree
v-for="status in showingTopLevel" :key="currentStatus.id"
:key="status.id"
ref="statusComponent"
:depth="0" :depth="0"
:status-id="status.id" :status-id="currentStatus.id"
:in-profile="inProfile" :in-profile="inProfile"
:conversation="conversation" :conversation="conversation"
:collapsable="collapsable" :collapsable="collapsable"
@ -172,21 +167,18 @@
:pinned-status-ids-object="pinnedStatusIdsObject" :pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId" :profile-user-id="profileUserId"
:get-replies="getReplies" :replies="replies"
:focused="maybeFocused" :focused="maybeFocused"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple" :thread-display="threadDisplay"
:thread-display-status="threadDisplayStatus" :thread-display-default="threadDisplayDefault"
:show-thread-recursively="showThreadRecursively" :can-dive="isExpanded"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="setFocused" @goto="setFocused"
@dive="diveIntoStatus" @dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
</div> </div>
<DynamicScroller <DynamicScroller
@ -208,7 +200,6 @@
> >
<Status <Status
:key="status.id" :key="status.id"
ref="statusComponent"
class="conversation-status" class="conversation-status"
:class="getStatusClasses(status, active)" :class="getStatusClasses(status, active)"
:status-id="status.id" :status-id="status.id"
@ -225,7 +216,6 @@
@goto="setFocused" @goto="setFocused"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
</DynamicScrollerItem> </DynamicScrollerItem>
</template> </template>

View file

@ -13,7 +13,7 @@
<template #statusLink> <template #statusLink>
<router-link <router-link
class="faint-link" class="faint-link"
:to="{ name: 'conversation', params: { id: draft.refId } }" :to="{ name: 'conversation', params: { statusId: draft.refId } }"
> >
{{ refStatus ? refStatus.external_url : $t('drafts.unavailable') }} {{ refStatus ? refStatus.external_url : $t('drafts.unavailable') }}
</router-link> </router-link>

View file

@ -166,7 +166,7 @@
> >
<router-link <router-link
v-if="notification.status" v-if="notification.status"
:to="{ name: 'conversation', params: { id: notification.status.id } }" :to="{ name: 'conversation', params: { statusId: notification.status.id } }"
class="timeago-link faint" class="timeago-link faint"
> >
<Timeago <Timeago

View file

@ -38,7 +38,7 @@
<router-link <router-link
v-for="status in report.statuses" v-for="status in report.statuses"
:key="status.id" :key="status.id"
:to="{ name: 'conversation', params: { id: status.id } }" :to="{ name: 'conversation', params: { statusId: status.id } }"
class="reported-status" class="reported-status"
> >
<div class="reported-status-heading"> <div class="reported-status-heading">

View file

@ -96,7 +96,7 @@ const Status = {
props: { props: {
statusId: String, statusId: String,
statusoid: Object, statusoid: Object,
replies: Array, replies: Set,
expandable: Boolean, expandable: Boolean,
focused: Boolean, focused: Boolean,
@ -109,12 +109,11 @@ const Status = {
inQuote: Boolean, inQuote: Boolean,
profileUserId: String, profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean, showOtherRepliesAsButton: Boolean,
canDive: Boolean, canDive: Boolean,
ignoreMute: Boolean, ignoreMute: Boolean,
threadDisplayStatus: String, threadDisplayState: String,
}, },
emits: [ emits: [
'goto', 'goto',
@ -166,6 +165,9 @@ const Status = {
user() { user() {
return useUsersStore().findUser(this.mainStatus.user.id) return useUsersStore().findUser(this.mainStatus.user.id)
}, },
simpleTree() {
return !this.mergedConfig.conversationTreeAdvanced
},
showReasonMutedThread() { showReasonMutedThread() {
return ( return (
(this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) && (this.mainStatus.thread_muted || this.repeatStatus?.thread_muted) &&
@ -436,10 +438,10 @@ const Status = {
return !this.replying && this.mediaPlaying.size === 0 return !this.replying && this.mediaPlaying.size === 0
}, },
inThreadForest() { inThreadForest() {
return !!this.threadDisplayStatus return !!this.threadDisplayState
}, },
threadShowing() { threadShowing() {
return this.threadDisplayStatus === 'showing' return this.threadDisplayState === 'showing'
}, },
visibilityLocalized() { visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)

View file

@ -180,7 +180,7 @@
</span> </span>
<router-link <router-link
class="timeago faint" class="timeago faint"
:to="{ name: 'conversation', params: { id: status.id } }" :to="{ name: 'conversation', params: { statusId: status.id } }"
> >
<Timeago <Timeago
:time="mainStatus.created_at" :time="mainStatus.created_at"
@ -222,7 +222,7 @@
/> />
</button> </button>
<button <button
v-if="inThreadForest && replies?.length && !simpleTree" v-if="inThreadForest && replies?.size && !simpleTree"
class="button-unstyled" class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')" :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'" :aria-expanded="threadShowing ? 'true' : 'false'"
@ -423,16 +423,16 @@
/> />
<div <div
v-if="inConversation && !isPreview && replies?.length" v-if="inConversation && !isPreview && replies?.size"
class="replies" class="replies"
> >
<button <button
v-if="showOtherRepliesAsButton && replies.length > 1" v-if="showOtherRepliesAsButton && replies.size > 1"
class="button-unstyled -link" class="button-unstyled -link"
:title="$t('status.ancestor_follow', { numReplies: replies.length - 1 }, replies.length - 1)" :title="$t('status.ancestor_follow', { numReplies: replies.size - 1 }, replies.size - 1)"
@click.prevent="$emit('dive')" @click.prevent="$emit('dive')"
> >
{{ $t('status.replies_list_with_others', { numReplies: replies.length - 1 }, replies.length - 1) }} {{ $t('status.replies_list_with_others', { numReplies: replies.size - 1 }, replies.size - 1) }}
</button> </button>
<span <span
v-else v-else
@ -441,7 +441,7 @@
{{ $t('status.replies_list') }} {{ $t('status.replies_list') }}
</span> </span>
<StatusPopover <StatusPopover
v-for="reply in replies" v-for="reply in replies.values()"
:key="reply.id" :key="reply.id"
:status-id="reply.id" :status-id="reply.id"
> >

View file

@ -234,7 +234,7 @@ export const BUTTONS = [
return chatView return chatView
}, },
action({ router, status }) { action({ router, status }) {
router.push({ name: 'conversation', params: { id: status.id } }) router.push({ name: 'conversation', params: { statusId: status.id } })
}, },
}, },
{ {
@ -293,7 +293,7 @@ export const BUTTONS = [
navigator.clipboard.writeText( navigator.clipboard.writeText(
[ [
useInstanceStore().server, useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } }) router.resolve({ name: 'conversation', params: { statusId: status.id } })
.href, .href,
].join(''), ].join(''),
) )

View file

@ -4,41 +4,95 @@ import {
faAngleDoubleRight, faAngleDoubleRight,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
library.add(faAngleDoubleDown, faAngleDoubleRight) library.add(faAngleDoubleDown, faAngleDoubleRight)
const ThreadTree = { const ThreadTree = {
components: {}, components: {},
name: 'ThreadTree', name: 'ThreadTree',
props: { props: {
depth: Number,
statusId: String, statusId: String,
inProfile: Boolean, inProfile: Boolean,
conversation: Array,
collapsable: Boolean, collapsable: Boolean,
isExpanded: Boolean, isExpanded: Boolean,
pinnedStatusIdsObject: Object, pinnedStatusIdsObject: Object,
profileUserId: String, profileUserId: String,
depth: Number,
conversation: Array,
focused: String, focused: String,
getReplies: Function, replies: Map,
toggleExpanded: Function,
simple: Boolean,
canDive: Boolean, canDive: Boolean,
threadDisplayStatus: Object, threadDisplay: Map,
showThreadRecursively: Function, threadDisplayDefault: Map,
totalReplyCount: Object,
totalReplyDepth: Object,
}, },
emits: ['suspendableStateChange', 'goto', 'dive', 'heightChange'], emits: [
'suspendableStateChange',
'goto',
'dive',
'heightChange',
'toggleExpanded',
'showThreadRecursively',
],
computed: { computed: {
currentReplies() { currentReplies() {
return this.getReplies(this.statusId).map(({ id }) => id) return [...this.getReplies(this.statusId)].map(({ id }) => id)
},
simple() {
return !useMergedConfigStore().mergedConfig.conversationTreeAdvanced
}, },
threadShowing() { threadShowing() {
return this.threadDisplayStatus[this.statusId] === 'showing' const result = this.threadDisplay.get(this.statusId) ?? this.threadDisplayDefault.get(this.statusId)
return result === 'showing'
},
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()
},
}
} }
export default ThreadTree export default ThreadTree

View file

@ -14,15 +14,14 @@
:profile-user-id="profileUserId" :profile-user-id="profileUserId"
class="conversation-status conversation-status-treeview panel-body" class="conversation-status conversation-status-treeview panel-body"
:simple-tree="simple" :thread-display-state="threadDisplay.get(statusId)"
:thread-display-status="threadDisplayStatus[statusId]"
:can-dive="canDive" :can-dive="canDive"
@dive="$emit('dive', statusId)" @dive="$emit('dive', statusId)"
@goto="$emit('goto', statusId)" @goto="$emit('goto', statusId)"
@toggle-expanded="toggleExpanded" @toggle-expanded="$emit('toggleExpanded', statusId)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)" @suspendable-state-change="$emit('suspendableStateChange', e)"
@height-change="e => $emit('heightChange', e)" @height-change="$emit('heightChange', e)"
/> />
<div <div
v-if="currentReplies.length > 0 && threadShowing" v-if="currentReplies.length > 0 && threadShowing"
@ -42,20 +41,18 @@
:pinned-status-ids-object="pinnedStatusIdsObject" :pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId" :profile-user-id="profileUserId"
:get-replies="getReplies" :replies="replies"
:focused="focused" :focused="focused"
:toggle-expanded="toggleExpanded"
:simple="simple" :thread-display="threadDisplay"
:thread-display-status="threadDisplayStatus" :thread-display-default="threadDisplayDefault"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive" :can-dive="canDive"
@show-thread-recursively="(e) => $emit('showThreadRecursively', e)"
@goto="(e) => $emit('goto', e)" @goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)" @dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)" @suspendable-state-change="e => $emit('suspendableStateChange', e)"
@toggle-expanded="(e) => $emit('toggleExpanded', e)"
@height-change="e => $emit('heightChange', e)" @height-change="e => $emit('heightChange', e)"
/> />
</div> </div>
@ -88,7 +85,7 @@
tag="button" tag="button"
keypath="status.thread_show_full_with_icon" keypath="status.thread_show_full_with_icon"
class="button-unstyled -link thread-tree-show-replies-button" class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="showThreadRecursively(statusId)" @click.prevent="$emit('showThreadRecursively', statusId)"
> >
<template #icon> <template #icon>
<FAIcon <FAIcon