Page MenuHomePhorge

No OneTemporary

Size
50 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 7f0686491c..cef442de25 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,638 +1,645 @@
import { get, reduce } from 'lodash-es'
import { mapState } from 'pinia'
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, fetchStatus } 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,
)
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 conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
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: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
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) {
useStatusesStore().fetchStatus(id)
}
useStatusesStore().fetchFavsAndRepeats(id)
useStatusesStore().fetchEmojiReactions(id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = useStatusesStore().allStatuses.get(statusId)
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
+ getStatusClasses(status, active) {
+ return {
+ '-virtual-active': active,
+ '-last': status.id === this.conversation[this.conversation.length - 1].id,
+ '-first': status.id === this.conversation[0].id,
+ }
+ },
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
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
},
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
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = 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.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
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
diff --git a/src/components/conversation/conversation.scss b/src/components/conversation/conversation.scss
index 99ecb338a0..ae06dbfa7b 100644
--- a/src/components/conversation/conversation.scss
+++ b/src/components/conversation/conversation.scss
@@ -1,95 +1,90 @@
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
+ .conversation-status:not(.-last) {
+ border-bottom: 1px solid var(--border);
+ }
+
+ .conversation-status:not(.-last)
+ .conversation-status:not(.-first) {
+ border-radius: 0;
+ }
+
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
- .conversation-status {
- border-bottom: 1px solid var(--border);
- border-radius: 0;
- }
-
- .thread-ancestor-has-other-replies .conversation-status,
- &:last-child:not(.-expanded) .conversation-status,
- &.-expanded .conversation-status:last-child,
- .thread-ancestor:last-child .conversation-status,
- .thread-ancestor:last-child .thread-ancestor-dive-box,
- &.-expanded .thread-tree .conversation-status {
- border-bottom: none;
- }
-
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
- &.status-fadein.-expanded .thread-body {
+ &.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
- &.-expanded.status-fadein {
+ &.-expanded:not(.-page) {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 94c4db188a..5199f44433 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,220 +1,243 @@
<template>
<div
v-if="!hide"
class="Conversation"
- :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
+ :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="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
: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: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
</div>
- <div
+ <DynamicScroller
v-if="shouldShowAncestors"
class="thread-ancestors"
+ :min-item-size="15"
+ :buffer="500"
+ :items="ancestorsOf(diveRoot)"
+ role="feed"
+ list-tag="article"
+ item-tag="article"
+ :item-class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors, 'thread-ancestor': true }"
+ flow-mode
+ page-mode
>
- <article
- v-for="status in ancestorsOf(diveRoot)"
- :key="status.id"
- class="thread-ancestor"
- :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
- >
- <Status
- ref="statusComponent"
- class="conversation-status status-fadein panel-body"
+ <template #default="{ item: status, active }">
+ <DynamicScrollerItem
+ :item="status"
+ :active="active"
+ >
+ <Status
+ ref="statusComponent"
+ class="conversation-status panel-body"
+ :class="getStatusClasses(status, active)"
- :status-id="status.id"
- :replies="getReplies(status.id)"
+ :status-id="status.id"
+ :replies="getReplies(status.id)"
- :expandable="!isExpanded"
- :focused="maybeFocused === status.id"
- :inline-expanded="collapsable && isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
- :in-profile="inProfile"
- :in-conversation="isExpanded"
- :profile-user-id="profileUserId"
- :simple-tree="treeViewIsSimple"
- :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
- can-dive
+ :expandable="!isExpanded"
+ :focused="maybeFocused === status.id"
+ :inline-expanded="collapsable && isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :in-profile="inProfile"
+ :in-conversation="isExpanded"
+ :profile-user-id="profileUserId"
+ :simple-tree="treeViewIsSimple"
+ :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
+ can-dive
- @goto="setFocused"
- @dive="() => diveIntoStatus(status.id)"
- @suspendable-state-change="onStatusSuspendStateChange"
- @height-change="updateVirtualHeight"
- />
- <div
- v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
- class="thread-ancestor-dive-box"
- >
+ @goto="setFocused"
+ @dive="() => diveIntoStatus(status.id)"
+ @suspendable-state-change="onStatusSuspendStateChange"
+ @height-change="updateVirtualHeight"
+ />
<div
- class="thread-ancestor-dive-box-inner"
+ v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
+ class="thread-ancestor-dive-box"
>
- <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)"
+ <div
+ class="thread-ancestor-dive-box-inner"
>
- <template #icon>
- <FAIcon
- icon="angle-double-right"
- />
- </template>
- <template #text>
- <span>
- {{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
- </span>
- </template>
- </i18n-t>
+ <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).length - 1).length - 1 }) }}
+ </span>
+ </template>
+ </i18n-t>
+ </div>
</div>
- </div>
- </article>
- </div>
+ </DynamicScrollerItem>
+ </template>
+ </DynamicScroller>
<ThreadTree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
:status-id="status.id"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:get-replies="getReplies"
:focused="maybeFocused"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="setFocused"
@dive="diveIntoStatus"
@suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/>
</div>
- <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"
>
- <article>
- <Status
- v-for="status in conversation"
- :key="status.id"
- ref="statusComponent"
- class="conversation-status status-fadein panel-body"
- :status-id="status.id"
- :replies="getReplies(status.id)"
+ <template #default="{ item: status, active }">
+ <DynamicScrollerItem
+ :item="status"
+ :active="active"
+ >
+ <Status
+ :key="status.id"
+ ref="statusComponent"
+ class="conversation-status"
+ :class="getStatusClasses(status, active)"
+ :status-id="status.id"
+ :replies="getReplies(status.id)"
- :expandable="!isExpanded"
- :focused="maybeFocused === status.id || maybeFocused === status.retweeted_status?.id"
- :inline-expanded="collapsable && isExpanded"
- :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
- :in-profile="inProfile"
- :in-conversation="isExpanded"
- :profile-user-id="profileUserId"
+ :expandable="!isExpanded"
+ :focused="maybeFocused === status.id || maybeFocused === status.retweeted_status?.id"
+ :inline-expanded="collapsable && isExpanded"
+ :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+ :in-profile="inProfile"
+ :in-conversation="isExpanded"
+ :profile-user-id="profileUserId"
- @goto="setFocused"
- @toggle-expanded="toggleExpanded"
- @suspendable-state-change="onStatusSuspendStateChange"
- @height-change="updateVirtualHeight"
- />
- </article>
- </div>
+ @goto="setFocused"
+ @toggle-expanded="toggleExpanded"
+ @suspendable-state-change="onStatusSuspendStateChange"
+ @height-change="updateVirtualHeight"
+ />
+ </DynamicScrollerItem>
+ </template>
+ </DynamicScroller>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 1b0e3cf3e1..74a50355af 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,393 +1,378 @@
.Status {
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
text-wrap: pretty;
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
.gravestone {
padding: var(--status-margin);
display: flex;
.deleted-text {
margin: 0.5em 0;
align-items: center;
}
}
.status-container {
display: flex;
padding: var(--status-margin);
gap: var(--status-margin);
> * {
min-width: 0;
}
}
.pin {
display: flex;
align-items: center;
justify-content: flex-end;
margin-right: 0.5em;
}
._misclick-prevention & {
pointer-events: none;
.attachments {
pointer-events: initial;
cursor: initial;
}
}
.left-side {
flex: 0 0 auto;
}
.right-side {
flex: 1 1 auto;
}
.usercard {
margin-bottom: var(--status-margin);
}
.status-username {
white-space: nowrap;
overflow: hidden;
max-width: 85%;
font-weight: bold;
flex-shrink: 1;
margin-right: 0.4em;
text-overflow: ellipsis;
--_still_image-label-scale: 0.25;
--emoji-size: 1em;
}
.status-favicon {
height: 1.2em;
width: 1.2em;
margin-right: 0.4em;
object-fit: contain;
}
.status-heading {
margin-bottom: 0.5em;
}
.heading-name-row {
display: flex;
justify-content: space-between;
line-height: 1.3;
a {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
width: 100%
}
}
.account-name {
display: inline-block;
min-width: 1em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
&.unknown {
min-width: 8em;
}
}
.heading-left {
display: flex;
min-width: 0;
}
.heading-right {
display: flex;
flex-shrink: 0;
align-self: baseline;
word-break: keep-all;
.button-unstyled {
padding: 0.2em;
margin: -0.2em;
}
.svg-inline--fa {
margin-left: 0.25em;
}
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
.timeago {
margin-right: 0.2em;
}
& .heading-reply-row,
& .heading-edited-row {
position: relative;
align-content: baseline;
font-size: 0.85em;
margin-top: 0.2em;
line-height: 130%;
max-width: 100%;
align-items: stretch;
}
& .reply-to-popover,
& .reply-to-no-popover,
& .mentions {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
.reply-glued-label {
margin-right: 0.5em;
}
.reply-to-popover {
.reply-to:hover::before {
content: "";
display: block;
position: absolute;
bottom: 0;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
.faint-link:hover {
// override default
text-decoration: none;
}
&.-strikethrough {
.reply-to::after {
content: "";
display: block;
position: absolute;
top: 50%;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
}
}
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
}
& .mentions-text,
& .reply-to-text {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 1.3;
font-size: 0.85em;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
.repeat-info {
display: flex;
align-items: center;
padding: 0.4em var(--status-margin);
.repeater-avatar {
flex: 0 0 1.5em;
border-radius: var(--roundness);
margin-left: 2em; // 3.5 (poster avatar size) - 1.5 (repeater avatar size)
width: 1.5em;
height: 1.5em;
}
.right-side {
display: flex;
flex: 1 1 auto;
overflow-x: hidden;
text-overflow: ellipsis;
margin-right: 0;
gap: 0.5em;
.repeater-name {
flex: 0 1 auto;
margin: 0;
}
.repeat-label {
white-space: nowrap;
flex: 0 0 auto;
.repeat-icon {
vertical-align: middle;
color: var(--cGreen);
}
}
.emoji {
width: 1em;
height: 1em;
vertical-align: middle;
object-fit: contain;
}
}
}
- .status-fadein {
- animation-duration: 0.4s;
- animation-name: fadein;
- }
-
- @keyframes fadein {
- from {
- opacity: 0;
- }
-
- to {
- opacity: 1;
- }
- }
-
.status-actions {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 1fr;
grid-auto-columns: 1fr;
grid-auto-flow: column;
margin-top: var(--status-margin);
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
gap: 1ex;
& .status-username,
& .mute-reason {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
display: flex;
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.reply-form {
padding-top: 0;
padding-bottom: 0;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: var(--status-margin);
}
.stats {
width: 100%;
display: flex;
line-height: 1em;
}
.avatar-row {
flex: 1;
position: relative;
display: flex;
align-items: center;
overflow: hidden;
&::before {
content: "";
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--textFaint);
}
}
.stat-count {
margin-right: var(--status-margin);
user-select: none;
.stat-title {
color: var(--textFaint);
font-size: 0.85em;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 1.1em;
line-height: 1em;
color: var(--text);
}
&:hover .stat-title {
text-decoration: underline;
}
}
.status-action-buttons {
margin-top: var(--status-margin);
}
}
diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue
index ee5f77fd27..5403844d38 100644
--- a/src/components/status_history_modal/status_history_modal.vue
+++ b/src/components/status_history_modal/status_history_modal.vue
@@ -1,49 +1,49 @@
<template>
<Modal
v-if="modalActivated"
class="status-history-modal-view"
@backdrop-clicked="closeModal"
>
<div class="status-history-modal-panel panel">
<div class="panel-heading">
<h1 class="title">
{{ $t('status.status_history') }} ({{ historyCount }})
</h1>
</div>
<div class="panel-body">
<div
v-if="historyCount > 0"
class="history-body"
>
<Status
v-for="status in history"
:key="status.id"
:statusoid="status"
:is-preview="true"
- class="conversation-status status-fadein panel-body"
+ class="conversation-status panel-body"
/>
</div>
</div>
</div>
</Modal>
</template>
<script src="./status_history_modal.js"></script>
<style lang="scss">
.modal-view.status-history-modal-view {
align-items: flex-start;
}
.status-history-modal-panel {
flex-shrink: 0;
margin-top: 25%;
margin-bottom: 2em;
width: 100%;
max-width: 700px;
@media (orientation: landscape) {
margin-top: 8%;
}
}
</style>
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
index be95f86983..d7ae9cd106 100644
--- a/src/components/thread_tree/thread_tree.vue
+++ b/src/components/thread_tree/thread_tree.vue
@@ -1,124 +1,124 @@
<template>
<article class="thread-tree">
<Status
:key="statusId"
ref="statusComponent"
:status-id="statusId"
:replies="getReplies(statusId)"
:inline-expanded="collapsable && isExpanded"
:expandable="!isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-conversation="isExpanded"
:focused="focused === statusId"
:in-profile="inProfile"
:profile-user-id="profileUserId"
- class="conversation-status conversation-status-treeview status-fadein panel-body"
+ class="conversation-status conversation-status-treeview panel-body"
:simple-tree="simple"
:thread-display-status="threadDisplayStatus[statusId]"
:can-dive="canDive"
@dive="$emit('dive', statusId)"
@goto="$emit('goto', statusId)"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
@height-change="e => $emit('heightChange', e)"
/>
<div
v-if="currentReplies.length > 0 && threadShowing"
class="thread-tree-replies"
>
<ThreadTree
v-for="replyStatusId in currentReplies"
:key="replyStatusId"
ref="childComponent"
:depth="depth + 1"
:status-id="replyStatusId"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:get-replies="getReplies"
:focused="focused"
:toggle-expanded="toggleExpanded"
:simple="simple"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)"
@height-change="e => $emit('heightChange', 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="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/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 7ee4bcfc53..a0087a68db 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,167 +1,164 @@
<template>
<!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div v-if="timeline.fetcher" :class="['Timeline', classes.root]">
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
/>
<div
v-if="timeline.fetcher.loadingNewer && !showLoadButton"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
@click.prevent="showNewStatuses"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else-if="!timeline.fetcher.loadingNewer"
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
@click.prevent="showNewStatuses"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div :class="classes.body">
<DynamicScroller
class="timeline"
ref="timeline"
- :min-item-size="120"
+ :min-item-size="15"
:buffer="120"
:items="filteredVisibleStatuses"
- :emit-update="true"
flow-mode
+ page-mode
role="feed"
>
- <template #default="{ item: status, index, active }">
+ <template #default="{ item: status, active }">
<DynamicScrollerItem
:item="status"
:active="active"
- :index="index"
- :size-dependencies="[status.status]"
>
<Conversation
:key="status.id"
role="listitem"
- class="status-fadein"
:status-id="status.id"
:in-profile="inProfile"
:profile-user-id="timelineRef.argument"
collapsable
/>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<template v-if="!hideEmpty && count === 0">
<div
v-if="timeline.fetcher.loadingNewer || timeline.fetcher.loadingOlder"
class="timeline-placeholder"
>
<FAIcon
icon="circle-notch"
spin
size="4x"
/>
</div>
<div
v-else
class="timeline-placeholder faint"
>
{{ $t('timeline.no_statuses') }}
</div>
</template>
</div>
<div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
v-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<div
v-else-if="timeline.fetcher.loadingOlder"
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<button
v-else-if="timeline.minId !== ''"
class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>

File Metadata

Mime Type
text/x-diff
Expires
Sat, Sep 19, 12:31 PM (1 d, 23 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769481
Default Alt Text
(50 KB)

Event Timeline