Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85712147
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
24 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 152289098a..eb759a7121 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,457 +1,470 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, nextTick, provide, ref, toRefs, 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 {
fetchConversation as apiFetchConversation,
- fetchStatus,
+ 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,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
emits: ['update:virtualHeight'],
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
setup(props, ctx) {
const { emit } = ctx
const { statusId } = toRefs(props)
const router = useRouter()
// # Main Configuration
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')
+
+ // # 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 status = computed(() => getStatusObject(focusedId.value))
+
+ const fetchConversation = async () => {
+ if (status.value) {
+ const {
+ data: { ancestors, descendants },
+ timestamp,
+ } = await apiFetchConversation({
+ id: statusId.value,
+ credentials: useOAuthStore().token,
+ })
- const status = computed(() => getStatusObject(statusId.value))
- const mainStatusId = computed(() => {
- if (status.value.retweeted_status) {
- return status.value.retweeted_status.id
+ 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 resetDisplayState = () => {
+ setFocused(statusId.value)
+ threadDisplay.value = new Map()
+ }
+ watch(statusId, (newVal, oldVal) => {
+ const newConversationId = getConversationId(newVal)
+ const oldConversationId = getConversationId(oldVal)
+ if (
+ newConversationId &&
+ oldConversationId &&
+ newConversationId === oldConversationId
+ ) {
+ setFocused(newVal)
} else {
- return statusId.value
+ 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 (!status.value) {
return []
}
if (!isExpanded.value) {
return [status.value]
}
const conversation = useStatusesStore().conversations.get(
conversationId.value,
)
return [...conversation.keys()]
.map((k) => useStatusesStore().allStatuses.get(k))
.filter((status) => status.type != 'repeat') // Old backend behavior?
.toSorted(sortById)
})
const replies = computed(() =>
conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }, index) => {
if (irid) {
if (!result.has(irid)) {
result.set(irid, new Set())
}
result.get(irid).add({
name: `#${index}`,
id,
})
}
return result
},
new Map(),
),
)
const getReplies = (id) => replies.value.get(id) ?? new Set()
+ const statusReplies = computed(() => {
+ return getReplies(status.value.id)
+ })
provide('conversation', conversation)
provide('replies', replies)
- 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)
+ // # 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 {
- 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
- }
+ resetDisplayState()
}
- }
- const resetDisplayState = () => {
- setFocused(statusId.value)
- threadDisplay.value = new Map()
+ })
+ provide('isExpanded', isExpanded)
+ provide('isPage', isPage)
+
+ // Component created
+ if (isPage.value) {
+ fetchConversation()
}
// # Virtual scrolling stuff
const { virtualHidden } = toRefs(props)
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) => ({
'-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()
- }
- })
- 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)
- }
- watch(statusId, (newVal, oldVal) => {
- const newConversationId = getConversationId(newVal)
- const oldConversationId = getConversationId(oldVal)
- if (
- newConversationId &&
- oldConversationId &&
- newConversationId === oldConversationId
- ) {
- setFocused(mainStatusId.value)
- } else {
- fetchConversation()
- }
- })
- provide('focused', focused)
-
- // 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 showOtherRepliesButtonBelowStatus = computed(
- () => mergedConfig.value.conversationOtherRepliesButton === 'below',
- )
- 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
+ // ## Tree state
// ### Topology
const ancestors = computed(() => {
// First we fill map with empty sets and add given id's parent
// as set's only element (if any)
const parentMap = conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }) => {
if (!result.has(id)) {
result.set(id, new Set())
}
if (irid) {
// Setting parent for current item
result.get(id).add(irid)
}
return result
},
new Map(),
)
// Next we iterate over each entry and fill the whole ancestry chain
parentMap.entries().forEach(([originId, originSet]) => {
let current = originSet.values().next().value
while (current) {
originSet.add(current)
const parent = parentMap.get(current) ?? new Set()
current = parent.values().next().value
}
})
return parentMap
})
const topLevel = computed(() =>
[...ancestors.value.entries()]
.filter(([id, ancestors]) => ancestors.size === 0)
.map(([id]) => getStatusObject(id)),
)
const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
const getAncestors = (id) =>
[...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
const currentAncestors = computed(() =>
getAncestors(focusedId.value).reverse(),
)
const currentDepth = computed(() => currentAncestors.value.length)
- const currentStatus = computed(() => getStatusObject(focusedId.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())
})
provide('threadDisplay', threadDisplay)
provide('threadDisplayDefault', threadDisplayDefault)
const setThreadDisplayRecursively = (id, value) => {
threadDisplay.value.set(id, value)
;[...getReplies(id)]
.map((k) => k.id)
.map((id) => setThreadDisplayRecursively(id, value))
}
const showThreadRecursively = (id) => {
setThreadDisplayRecursively(id, 'showing')
}
- // ## Derived values
+ // ## Derived values and config
+ const treeViewIsSimple = computed(
+ () => !mergedConfig.value.conversationTreeAdvanced,
+ )
+ const maxDepthToShowByDefault = computed(() => {
+ // maxDepthInThread = max number of depths that is *visible*
+ // since our depth starts with 0 and "showing" means "showing children"
+ // there is a -2 here
+ const maxDepth = mergedConfig.value.maxDepthInThread - 2
+ return Math.min(1, maxDepth)
+ })
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && ancestors.value.get(focusedId.value) != null,
)
+ const shouldFadeAncestors = computed(
+ () => mergedConfig.value.conversationTreeFadeAncestors,
+ )
+ const shouldShowOtherRepliesButton = computed(
+ () => mergedConfig.value.conversationOtherRepliesButton === 'below',
+ )
- // # Scrolling / diving
+ // # 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 {
- hide,
- isLinearView,
- isTreeView,
- isExpanded,
- conversation,
- mobileLayout,
- toggleExpanded,
- isPage,
- status,
+ // # Misc
loadStatusError,
+ mobileLayout,
+
+ // # Focus
focused,
setFocused,
- showOtherRepliesButtonBelowStatus,
+
+ // # Main things
+ status,
+ statusReplies,
+ conversation,
+
+ // # Conversation Expansion
+ isPage,
+ isExpanded,
+ toggleExpanded,
+
+ // # Virtual scrolling stuff
+ hide,
onStatusSuspendStateChange,
+ virtualHidden,
+
+ // # Misc UI things
getStatusClasses,
- getReplies,
+
+ // # Linear style stuff
+ isLinearView,
+
+ // # Tree style stuff
+ isTreeView,
+
+ // ## Tree state
+ // ### Topology
+ topLevel,
currentAncestors,
- statusId,
- virtualHidden,
- shouldShowAncestors,
+
+ // ### Thread Display
+ showThreadRecursively,
+
+ // ## Derived values and config
+ treeViewIsSimple,
shouldShowAllConversationButton,
- topLevel,
+ shouldShowAncestors,
shouldFadeAncestors,
- currentStatus,
- treeViewIsSimple,
+ shouldShowOtherRepliesButton,
+
+ // # Scrolling
diveToTopLevel,
- threadDisplay,
- threadDisplayDefault,
- replies,
diveIntoStatus,
- showThreadRecursively,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index b5d92401cd..18ddce63ed 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,195 +1,195 @@
<template>
<div
v-if="!hide"
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">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</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="status.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"
class="thread-ancestors"
>
<article
v-for="status in currentAncestors"
class="thread-ancestor"
- :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
+ :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)"
+ :replies="statusReplies"
:focused="focused === status.id"
can-dive
@goto="setFocused"
- @dive="() => diveIntoStatus(status.id)"
+ @dive="diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange"
/>
<div
- v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).size > 1"
+ v-if="shouldShowOtherRepliesButton && statusReplies.size > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
- {{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).size - 1).size - 1 }) }}
+ {{ $t('status.ancestor_follow', { numReplies: statusReplies.size - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<ThreadTree
- :status-id="currentStatus.id"
+ :status-id="status.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</div>
<DynamicScroller
v-else-if="isLinearView"
class="thread-body"
:min-item-size="15"
:buffer="500"
:items="conversation"
page-mode
flow-mode
role="feed"
item-tag="article"
item-class="panel-body"
>
<template #default="{ item: status, active }">
<DynamicScrollerItem
:item="status"
:active="active"
>
<Status
:key="status.id"
class="conversation-status"
:class="getStatusClasses(status)"
:status-id="status.id"
:replies="getReplies(status.id)"
:focused="focused === status.id || focused === status.retweeted_status?.id"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 10:15 AM (1 d, 22 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769266
Default Alt Text
(24 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment