Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85712841
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
21 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 92c93eadee..bbb55cd044 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,471 +1,502 @@
import { get } from 'lodash-es'
import { storeToRefs } from 'pinia'
-import { computed, nextTick, provide, ref, toRefs, watch } from 'vue'
+import {
+ computed,
+ nextTick,
+ onMounted,
+ 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 {
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,
},
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,
})
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 {
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)
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = () => {
expanded.value = !expanded.value
}
watch(expanded, (value) => {
if (value) {
fetchConversation()
} else {
resetDisplayState()
}
})
provide('isExpanded', isExpanded)
provide('isPage', isPage)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Virtual scrolling stuff
+ const body = useTemplateRef('body')
const { virtualHidden } = toRefs(props)
+ const virtualHeight = ref(120)
+ const hiddenStyle = computed(() => ({
+ height: this.virtualHeight + 'px',
+ }))
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)
}
}
+ const updateVirtualHeight = () => {
+ if (hide) return // no updates when not rendering
+ if (!status.value) return // not loaded yet
+ nextTick(() => {
+ virtualHeight.value = body.value.getBoundingClientRect().height
+ emit('update:virtualHeight', {
+ id: status.value.id,
+ height: virtualHeight.value,
+ top: body.value.clientTop,
+ })
+ })
+ }
+ onMounted(() => {
+ updateVirtualHeight()
+ })
// # 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')
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
// ## Tree state
// ### Topology
const ancestors = computed(() => {
// First we fill map with empty sets and add given id's parent
// as set's only element (if any)
const parentMap = conversation.value.reduce(
(result, { id, in_reply_to_status_id: irid }) => {
if (!result.has(id)) {
result.set(id, new Set())
}
if (irid) {
// Setting parent for current item
result.get(id).add(irid)
}
return result
},
new Map(),
)
// Next we iterate over each entry and fill the whole ancestry chain
parentMap.entries().forEach(([originId, originSet]) => {
let current = originSet.values().next().value
while (current) {
originSet.add(current)
const parent = parentMap.get(current) ?? new Set()
current = parent.values().next().value
}
})
return parentMap
})
const topLevel = computed(() =>
[...ancestors.value.entries()]
.filter(([id, ancestors]) => ancestors.size === 0)
.map(([id]) => getStatusObject(id)),
)
const getAncestorIds = (id) => ancestors.value.get(id) ?? new Set()
const getAncestors = (id) =>
[...getAncestorIds(id)].map(getStatusObject).filter(Boolean)
const currentAncestors = computed(() =>
getAncestors(focusedId.value).reverse(),
)
const currentDepth = computed(() => currentAncestors.value.length)
// ### Thread Display
const threadDisplay = ref(new Map()) // id => 'showing' | 'hidden'
const threadDisplayDefault = computed(() => {
return conversation.value.reduce((map, status) => {
const { id } = status
const depth = ancestors.value.get(id).size
const state = (() => {
if (depth - currentDepth.value <= maxDepthToShowByDefault.value) {
return 'showing'
} else {
return 'hidden'
}
})()
map.set(id, state)
return map
}, new Map())
})
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 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
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,
mobileLayout,
// # Focus
focused,
setFocused,
// # Main things
status,
statusReplies,
getReplies,
conversation,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Virtual scrolling stuff
hide,
onStatusSuspendStateChange,
+ updateVirtualHeight,
virtualHidden,
+ hiddenStyle,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// # Tree style stuff
isTreeView,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
// ## 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 03bcfef866..cebb9a10d6 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,185 +1,187 @@
<template>
<div
v-if="!hide"
+ ref="body"
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': statusReplies.size > 1, '-faded': shouldFadeAncestors}"
>
<Status
class="conversation-status panel-body"
:class="getStatusClasses(status)"
:status-id="status.id"
:replies="statusReplies"
:focused="focused === status.id"
can-dive
@goto="setFocused"
@dive="diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange"
+ @height-change="updateVirtualHeight"
/>
<div
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: statusReplies.size - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<ThreadTree
:status-id="status.id"
:depth="0"
@goto="setFocused"
@dive="diveIntoStatus"
@toggle-expanded="toggleExpanded"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="onStatusSuspendStateChange"
+ @height-change="updateVirtualHeight"
/>
</div>
<div
v-else-if="isLinearView"
class="thread-body"
>
<article
v-for="status in conversation"
class="panel-body"
>
<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"
+ @height-change="updateVirtualHeight"
/>
</article>
</div>
</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, 12:51 PM (1 d, 22 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1767134
Default Alt Text
(21 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment