forked from AkkomaGang/akkoma-fe
Merge branch 'from/develop/tusooa/tree-threading' into 'develop'
Add the option to display threads as trees See merge request pleroma/pleroma-fe!1407
This commit is contained in:
commit
e34d71fc1f
21 changed files with 1168 additions and 95 deletions
|
@ -30,3 +30,5 @@ $fallback--attachmentRadius: 10px;
|
|||
$fallback--chatMessageRadius: 10px;
|
||||
|
||||
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
|
||||
|
||||
$status-margin: 0.75em;
|
||||
|
|
|
@ -1,5 +1,19 @@
|
|||
import { reduce, filter, findIndex, clone, get } from 'lodash'
|
||||
import Status from '../status/status.vue'
|
||||
import ThreadTree from '../thread_tree/thread_tree.vue'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAngleDoubleDown,
|
||||
faAngleDoubleLeft,
|
||||
faChevronLeft
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faAngleDoubleDown,
|
||||
faAngleDoubleLeft,
|
||||
faChevronLeft
|
||||
)
|
||||
|
||||
const sortById = (a, b) => {
|
||||
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
|
||||
|
@ -35,7 +49,10 @@ const conversation = {
|
|||
data () {
|
||||
return {
|
||||
highlight: null,
|
||||
expanded: false
|
||||
expanded: false,
|
||||
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
|
||||
statusContentPropertiesObject: {},
|
||||
inlineDivePosition: null
|
||||
}
|
||||
},
|
||||
props: [
|
||||
|
@ -53,12 +70,50 @@ const conversation = {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
hideStatus () {
|
||||
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
|
||||
return this.virtualHidden && this.$refs.statusComponent[0].suspendable
|
||||
} else {
|
||||
return this.virtualHidden
|
||||
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.$store.getters.mergedConfig.maxDepthInThread - 2
|
||||
return maxDepth >= 1 ? maxDepth : 1
|
||||
},
|
||||
displayStyle () {
|
||||
return this.$store.getters.mergedConfig.conversationDisplay
|
||||
},
|
||||
isTreeView () {
|
||||
return !this.isLinearView
|
||||
},
|
||||
treeViewIsSimple () {
|
||||
return !this.$store.getters.mergedConfig.conversationTreeAdvanced
|
||||
},
|
||||
isLinearView () {
|
||||
return this.displayStyle === 'linear'
|
||||
},
|
||||
shouldFadeAncestors () {
|
||||
return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
|
||||
},
|
||||
otherRepliesButtonPosition () {
|
||||
return this.$store.getters.mergedConfig.conversationOtherRepliesButton
|
||||
},
|
||||
showOtherRepliesButtonBelowStatus () {
|
||||
return this.otherRepliesButtonPosition === 'below'
|
||||
},
|
||||
showOtherRepliesButtonInsideStatus () {
|
||||
return this.otherRepliesButtonPosition === 'inside'
|
||||
},
|
||||
suspendable () {
|
||||
if (this.isTreeView) {
|
||||
return Object.entries(this.statusContentProperties)
|
||||
.every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
|
||||
}
|
||||
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
|
||||
return this.$refs.statusComponent.every(s => s.suspendable)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
hideStatus () {
|
||||
return this.virtualHidden && this.suspendable
|
||||
},
|
||||
status () {
|
||||
return this.$store.state.statuses.allStatusesObject[this.statusId]
|
||||
|
@ -90,6 +145,121 @@ const conversation = {
|
|||
|
||||
return sortAndFilterConversation(conversation, this.status)
|
||||
},
|
||||
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)].reduce((a, b) => a.concat(b), [])
|
||||
}).reduce((a, b) => a.concat(b), [])
|
||||
|
||||
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).indexOf(k.id) === -1), 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
|
||||
// eslint-disable-next-line camelcase
|
||||
|
@ -109,15 +279,71 @@ const conversation = {
|
|||
}, {})
|
||||
},
|
||||
isExpanded () {
|
||||
return this.expanded || this.isPage
|
||||
return !!(this.expanded || this.isPage)
|
||||
},
|
||||
hiddenStyle () {
|
||||
const height = (this.status && this.status.virtualHeight) || '120px'
|
||||
return this.virtualHidden ? { height } : {}
|
||||
},
|
||||
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
|
||||
}, {})
|
||||
},
|
||||
statusContentProperties () {
|
||||
return this.conversation.reduce((a, k) => {
|
||||
const id = k.id
|
||||
const props = (() => {
|
||||
const def = {
|
||||
showingTall: false,
|
||||
expandingSubject: false,
|
||||
showingLongSubject: false,
|
||||
isReplying: false,
|
||||
mediaPlaying: []
|
||||
}
|
||||
|
||||
if (this.statusContentPropertiesObject[id]) {
|
||||
return {
|
||||
...def,
|
||||
...this.statusContentPropertiesObject[id]
|
||||
}
|
||||
}
|
||||
return def
|
||||
})()
|
||||
|
||||
a[id] = props
|
||||
return a
|
||||
}, {})
|
||||
},
|
||||
canDive () {
|
||||
return this.isTreeView && this.isExpanded
|
||||
},
|
||||
focused () {
|
||||
return (id) => {
|
||||
return (this.isExpanded) && id === this.highlight
|
||||
}
|
||||
},
|
||||
maybeHighlight () {
|
||||
return this.isExpanded ? this.highlight : null
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Status
|
||||
Status,
|
||||
ThreadTree
|
||||
},
|
||||
watch: {
|
||||
statusId (newVal, oldVal) {
|
||||
|
@ -132,6 +358,8 @@ const conversation = {
|
|||
expanded (value) {
|
||||
if (value) {
|
||||
this.fetchConversation()
|
||||
} else {
|
||||
this.resetDisplayState()
|
||||
}
|
||||
},
|
||||
virtualHidden (value) {
|
||||
|
@ -161,8 +389,8 @@ const conversation = {
|
|||
getReplies (id) {
|
||||
return this.replies[id] || []
|
||||
},
|
||||
focused (id) {
|
||||
return (this.isExpanded) && id === this.statusId
|
||||
getHighlight () {
|
||||
return this.isExpanded ? this.highlight : null
|
||||
},
|
||||
setHighlight (id) {
|
||||
if (!id) return
|
||||
|
@ -170,15 +398,139 @@ const conversation = {
|
|||
this.$store.dispatch('fetchFavsAndRepeats', id)
|
||||
this.$store.dispatch('fetchEmojiReactionsBy', id)
|
||||
},
|
||||
getHighlight () {
|
||||
return this.isExpanded ? this.highlight : null
|
||||
},
|
||||
toggleExpanded () {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
getConversationId (statusId) {
|
||||
const status = this.$store.state.statuses.allStatusesObject[statusId]
|
||||
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
|
||||
},
|
||||
setThreadDisplay (id, nextStatus) {
|
||||
this.threadDisplayStatusObject = {
|
||||
...this.threadDisplayStatusObject,
|
||||
[id]: nextStatus
|
||||
}
|
||||
},
|
||||
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')
|
||||
},
|
||||
setStatusContentProperty (id, name, value) {
|
||||
this.statusContentPropertiesObject = {
|
||||
...this.statusContentPropertiesObject,
|
||||
[id]: {
|
||||
...this.statusContentPropertiesObject[id],
|
||||
[name]: value
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleStatusContentProperty (id, name) {
|
||||
this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
|
||||
},
|
||||
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, preventScroll) {
|
||||
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.setHighlight(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 `highlight` 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 `highlight` watcher.
|
||||
this.$nextTick(() => {
|
||||
this.setHighlight(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 = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,24 +18,168 @@
|
|||
{{ $t('timeline.collapse') }}
|
||||
</button>
|
||||
</div>
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status status-fadein panel-body"
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
<div class="conversation-body panel-body">
|
||||
<div
|
||||
v-if="isTreeView"
|
||||
class="thread-body"
|
||||
>
|
||||
<div
|
||||
v-if="shouldShowAllConversationButton"
|
||||
class="conversation-dive-to-top-level-box"
|
||||
>
|
||||
<i18n
|
||||
path="status.show_all_conversation_with_icon"
|
||||
tag="button"
|
||||
class="button-unstyled -link"
|
||||
@click.prevent="diveToTopLevel"
|
||||
>
|
||||
<FAIcon
|
||||
place="icon"
|
||||
icon="angle-double-left"
|
||||
/>
|
||||
<span place="text">
|
||||
{{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
|
||||
</span>
|
||||
</i18n>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowAncestors"
|
||||
class="thread-ancestors"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status status-fadein panel-body"
|
||||
|
||||
:simple-tree="treeViewIsSimple"
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
|
||||
:dive="() => diveIntoStatus(status.id)"
|
||||
|
||||
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
|
||||
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
|
||||
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
|
||||
:controlled-replying="statusContentProperties[status.id].replying"
|
||||
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
|
||||
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
|
||||
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
|
||||
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
|
||||
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
|
||||
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
|
||||
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
<div
|
||||
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
|
||||
class="thread-ancestor-dive-box"
|
||||
>
|
||||
<div
|
||||
class="thread-ancestor-dive-box-inner"
|
||||
>
|
||||
<i18n
|
||||
tag="button"
|
||||
path="status.ancestor_follow_with_icon"
|
||||
class="button-unstyled -link thread-tree-show-replies-button"
|
||||
@click.prevent="diveIntoStatus(status.id)"
|
||||
>
|
||||
<FAIcon
|
||||
place="icon"
|
||||
icon="angle-double-right"
|
||||
/>
|
||||
<span place="text">
|
||||
{{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
|
||||
</span>
|
||||
</i18n>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<thread-tree
|
||||
v-for="status in showingTopLevel"
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:depth="0"
|
||||
|
||||
:status="status"
|
||||
:in-profile="inProfile"
|
||||
:conversation="conversation"
|
||||
:collapsable="collapsable"
|
||||
:is-expanded="isExpanded"
|
||||
:pinned-status-ids-object="pinnedStatusIdsObject"
|
||||
:profile-user-id="profileUserId"
|
||||
|
||||
:focused="focused"
|
||||
:get-replies="getReplies"
|
||||
:highlight="maybeHighlight"
|
||||
:set-highlight="setHighlight"
|
||||
:toggle-expanded="toggleExpanded"
|
||||
|
||||
:simple="treeViewIsSimple"
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:status-content-properties="statusContentProperties"
|
||||
:set-status-content-property="setStatusContentProperty"
|
||||
:toggle-status-content-property="toggleStatusContentProperty"
|
||||
:dive="canDive ? diveIntoStatus : undefined"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLinearView"
|
||||
class="thread-body"
|
||||
>
|
||||
<status
|
||||
v-for="status in conversation"
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="getHighlight()"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status status-fadein panel-body"
|
||||
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:status-content-properties="statusContentProperties"
|
||||
:set-status-content-property="setStatusContentProperty"
|
||||
:toggle-status-content-property="toggleStatusContentProperty"
|
||||
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
|
@ -49,6 +193,45 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.Conversation {
|
||||
.conversation-dive-to-top-level-box {
|
||||
padding: var(--status-margin, $status-margin);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: var(--border, $fallback--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, $status-margin);
|
||||
border-left: 2px solid var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
.thread-ancestor.-faded .StatusContent {
|
||||
--link: var(--faintLink);
|
||||
--text: var(--faint);
|
||||
color: var(--text);
|
||||
}
|
||||
.thread-ancestor-dive-box {
|
||||
padding-left: var(--status-margin, $status-margin);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: var(--border, $fallback--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, $status-margin);
|
||||
}
|
||||
|
||||
.conversation-status {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
|
@ -56,12 +239,28 @@
|
|||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.-expanded {
|
||||
.conversation-status:last-child {
|
||||
border-bottom: none;
|
||||
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
|
||||
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
|
||||
}
|
||||
.thread-ancestor-has-other-replies .conversation-status,
|
||||
.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-width: 1px;
|
||||
border-top-style: solid;
|
||||
border-top-color: var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
/* expanded conversation in timeline */
|
||||
&.status-fadein.-expanded .thread-body {
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
border-left-color: $fallback--cRed;
|
||||
border-left-color: var(--cRed, $fallback--cRed);
|
||||
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
|
||||
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
|
||||
border-bottom: 1px solid var(--border, $fallback--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
37
src/components/settings_modal/helpers/integer_setting.js
Normal file
37
src/components/settings_modal/helpers/integer_setting.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { get, set } from 'lodash'
|
||||
import ModifiedIndicator from './modified_indicator.vue'
|
||||
export default {
|
||||
components: {
|
||||
ModifiedIndicator
|
||||
},
|
||||
props: {
|
||||
path: String,
|
||||
disabled: Boolean,
|
||||
min: Number
|
||||
},
|
||||
computed: {
|
||||
pathDefault () {
|
||||
const [firstSegment, ...rest] = this.path.split('.')
|
||||
return [firstSegment + 'DefaultValue', ...rest].join('.')
|
||||
},
|
||||
state () {
|
||||
const value = get(this.$parent, this.path)
|
||||
if (value === undefined) {
|
||||
return this.defaultState
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
},
|
||||
defaultState () {
|
||||
return get(this.$parent, this.pathDefault)
|
||||
},
|
||||
isChanged () {
|
||||
return this.state !== this.defaultState
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update (e) {
|
||||
set(this.$parent, this.path, parseInt(e.target.value))
|
||||
}
|
||||
}
|
||||
}
|
20
src/components/settings_modal/helpers/integer_setting.vue
Normal file
20
src/components/settings_modal/helpers/integer_setting.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<span class="IntegerSetting">
|
||||
<label :for="path">
|
||||
<slot />
|
||||
</label>
|
||||
<input
|
||||
:id="path"
|
||||
class="number-input"
|
||||
type="number"
|
||||
step="1"
|
||||
:disabled="disabled"
|
||||
:min="min || 0"
|
||||
:value="state"
|
||||
@change="update"
|
||||
>
|
||||
<ModifiedIndicator :changed="isChanged" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script src="./integer_setting.js"></script>
|
|
@ -1,6 +1,7 @@
|
|||
import { filter, trim } from 'lodash'
|
||||
import BooleanSetting from '../helpers/boolean_setting.vue'
|
||||
import ChoiceSetting from '../helpers/choice_setting.vue'
|
||||
import IntegerSetting from '../helpers/integer_setting.vue'
|
||||
|
||||
import SharedComputedObject from '../helpers/shared_computed_object.js'
|
||||
|
||||
|
@ -17,7 +18,8 @@ const FilteringTab = {
|
|||
},
|
||||
components: {
|
||||
BooleanSetting,
|
||||
ChoiceSetting
|
||||
ChoiceSetting,
|
||||
IntegerSetting
|
||||
},
|
||||
computed: {
|
||||
...SharedComputedObject(),
|
||||
|
|
|
@ -70,17 +70,12 @@
|
|||
</li>
|
||||
<h3>{{ $t('settings.attachments') }}</h3>
|
||||
<li>
|
||||
<label for="maxThumbnails">
|
||||
{{ $t('settings.max_thumbnails') }}
|
||||
</label>
|
||||
<input
|
||||
id="maxThumbnails"
|
||||
path.number="maxThumbnails"
|
||||
class="number-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
<IntegerSetting
|
||||
path="maxThumbnails"
|
||||
:min="0"
|
||||
>
|
||||
{{ $t('settings.max_thumbnails') }}
|
||||
</IntegerSetting>
|
||||
</li>
|
||||
<li>
|
||||
<BooleanSetting path="hideAttachments">
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import BooleanSetting from '../helpers/boolean_setting.vue'
|
||||
import ChoiceSetting from '../helpers/choice_setting.vue'
|
||||
import IntegerSetting from '../helpers/integer_setting.vue'
|
||||
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
|
||||
|
||||
import SharedComputedObject from '../helpers/shared_computed_object.js'
|
||||
|
@ -20,6 +21,16 @@ const GeneralTab = {
|
|||
value: mode,
|
||||
label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
|
||||
})),
|
||||
conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
|
||||
key: mode,
|
||||
value: mode,
|
||||
label: this.$t(`settings.conversation_display_${mode}`)
|
||||
})),
|
||||
conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
|
||||
key: mode,
|
||||
value: mode,
|
||||
label: this.$t(`settings.conversation_other_replies_button_${mode}`)
|
||||
})),
|
||||
mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
|
||||
key: mode,
|
||||
value: mode,
|
||||
|
@ -37,6 +48,7 @@ const GeneralTab = {
|
|||
components: {
|
||||
BooleanSetting,
|
||||
ChoiceSetting,
|
||||
IntegerSetting,
|
||||
InterfaceLanguageSwitcher
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -152,6 +152,47 @@
|
|||
{{ $t('settings.show_yous') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<ChoiceSetting
|
||||
id="conversationDisplay"
|
||||
path="conversationDisplay"
|
||||
:options="conversationDisplayOptions"
|
||||
>
|
||||
{{ $t('settings.conversation_display') }}
|
||||
</ChoiceSetting>
|
||||
</li>
|
||||
<ul
|
||||
v-if="conversationDisplay !== 'linear'"
|
||||
class="setting-list suboptions"
|
||||
>
|
||||
<li>
|
||||
<BooleanSetting path="conversationTreeAdvanced">
|
||||
{{ $t('settings.tree_advanced') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<BooleanSetting path="conversationTreeFadeAncestors">
|
||||
{{ $t('settings.tree_fade_ancestors') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
<li>
|
||||
<IntegerSetting
|
||||
path="maxDepthInThread"
|
||||
:min="3"
|
||||
>
|
||||
{{ $t('settings.max_depth_in_thread') }}
|
||||
</IntegerSetting>
|
||||
</li>
|
||||
<li>
|
||||
<ChoiceSetting
|
||||
id="conversationOtherRepliesButton"
|
||||
path="conversationOtherRepliesButton"
|
||||
:options="conversationOtherRepliesButtonOptions"
|
||||
>
|
||||
{{ $t('settings.conversation_other_replies_button') }}
|
||||
</ChoiceSetting>
|
||||
</li>
|
||||
</ul>
|
||||
<li>
|
||||
<ChoiceSetting
|
||||
id="mentionLinkDisplay"
|
||||
|
|
|
@ -35,7 +35,10 @@ import {
|
|||
faStar,
|
||||
faEyeSlash,
|
||||
faEye,
|
||||
faThumbtack
|
||||
faThumbtack,
|
||||
faChevronUp,
|
||||
faChevronDown,
|
||||
faAngleDoubleRight
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
|
@ -52,9 +55,47 @@ library.add(
|
|||
faEllipsisH,
|
||||
faEyeSlash,
|
||||
faEye,
|
||||
faThumbtack
|
||||
faThumbtack,
|
||||
faChevronUp,
|
||||
faChevronDown,
|
||||
faAngleDoubleRight
|
||||
)
|
||||
|
||||
const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
|
||||
const camelized = camelCase(name)
|
||||
const toggle = `controlledToggle${camelized}`
|
||||
const controlledName = `controlled${camelized}`
|
||||
const uncontrolledName = `uncontrolled${camelized}`
|
||||
res[name] = function () {
|
||||
return this[toggle] ? this[controlledName] : this[uncontrolledName]
|
||||
}
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const controlledOrUncontrolledToggle = (obj, name) => {
|
||||
const camelized = camelCase(name)
|
||||
const toggle = `controlledToggle${camelized}`
|
||||
const uncontrolledName = `uncontrolled${camelized}`
|
||||
if (obj[toggle]) {
|
||||
obj[toggle]()
|
||||
} else {
|
||||
obj[uncontrolledName] = !obj[uncontrolledName]
|
||||
}
|
||||
}
|
||||
|
||||
const controlledOrUncontrolledSet = (obj, name, val) => {
|
||||
const camelized = camelCase(name)
|
||||
const set = `controlledSet${camelized}`
|
||||
const uncontrolledName = `uncontrolled${camelized}`
|
||||
if (obj[set]) {
|
||||
obj[set](val)
|
||||
} else {
|
||||
obj[uncontrolledName] = val
|
||||
}
|
||||
}
|
||||
|
||||
const Status = {
|
||||
name: 'Status',
|
||||
components: {
|
||||
|
@ -89,20 +130,38 @@ const Status = {
|
|||
'inlineExpanded',
|
||||
'showPinned',
|
||||
'inProfile',
|
||||
'profileUserId'
|
||||
'profileUserId',
|
||||
|
||||
'simpleTree',
|
||||
'controlledThreadDisplayStatus',
|
||||
'controlledToggleThreadDisplay',
|
||||
'showOtherRepliesAsButton',
|
||||
|
||||
'controlledShowingTall',
|
||||
'controlledToggleShowingTall',
|
||||
'controlledExpandingSubject',
|
||||
'controlledToggleExpandingSubject',
|
||||
'controlledShowingLongSubject',
|
||||
'controlledToggleShowingLongSubject',
|
||||
'controlledReplying',
|
||||
'controlledToggleReplying',
|
||||
'controlledMediaPlaying',
|
||||
'controlledSetMediaPlaying',
|
||||
'dive'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
replying: false,
|
||||
uncontrolledReplying: false,
|
||||
unmuted: false,
|
||||
userExpanded: false,
|
||||
mediaPlaying: [],
|
||||
uncontrolledMediaPlaying: [],
|
||||
suspendable: true,
|
||||
error: null,
|
||||
headTailLinks: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
|
||||
muteWords () {
|
||||
return this.mergedConfig.muteWords
|
||||
},
|
||||
|
@ -318,6 +377,12 @@ const Status = {
|
|||
},
|
||||
isSuspendable () {
|
||||
return !this.replying && this.mediaPlaying.length === 0
|
||||
},
|
||||
inThreadForest () {
|
||||
return !!this.controlledThreadDisplayStatus
|
||||
},
|
||||
threadShowing () {
|
||||
return this.controlledThreadDisplayStatus === 'showing'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -340,7 +405,7 @@ const Status = {
|
|||
this.error = undefined
|
||||
},
|
||||
toggleReplying () {
|
||||
this.replying = !this.replying
|
||||
controlledOrUncontrolledToggle(this, 'replying')
|
||||
},
|
||||
gotoOriginal (id) {
|
||||
if (this.inConversation) {
|
||||
|
@ -360,17 +425,19 @@ const Status = {
|
|||
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
|
||||
},
|
||||
addMediaPlaying (id) {
|
||||
this.mediaPlaying.push(id)
|
||||
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
|
||||
},
|
||||
removeMediaPlaying (id) {
|
||||
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
|
||||
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
|
||||
},
|
||||
setHeadTailLinks (headTailLinks) {
|
||||
this.headTailLinks = headTailLinks
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'highlight': function (id) {
|
||||
},
|
||||
toggleThreadDisplay () {
|
||||
this.controlledToggleThreadDisplay()
|
||||
},
|
||||
scrollIfHighlighted (highlightId) {
|
||||
const id = highlightId
|
||||
if (this.status.id === id) {
|
||||
let rect = this.$el.getBoundingClientRect()
|
||||
if (rect.top < 100) {
|
||||
|
@ -384,6 +451,11 @@ const Status = {
|
|||
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'highlight': function (id) {
|
||||
this.scrollIfHighlighted(id)
|
||||
},
|
||||
'status.repeat_num': function (num) {
|
||||
// refetch repeats when repeat_num is changed in any way
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
$status-margin: 0.75em;
|
||||
|
||||
.Status {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
|
@ -28,15 +26,8 @@ $status-margin: 0.75em;
|
|||
--icon: var(--selectedPostIcon, $fallback--icon);
|
||||
}
|
||||
|
||||
&.-conversation {
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
border-left-color: $fallback--cRed;
|
||||
border-left-color: var(--cRed, $fallback--cRed);
|
||||
}
|
||||
|
||||
.gravestone {
|
||||
padding: $status-margin;
|
||||
padding: var(--status-margin, $status-margin);
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
display: flex;
|
||||
|
@ -49,7 +40,7 @@ $status-margin: 0.75em;
|
|||
|
||||
.status-container {
|
||||
display: flex;
|
||||
padding: $status-margin;
|
||||
padding: var(--status-margin, $status-margin);
|
||||
|
||||
&.-repeat {
|
||||
padding-top: 0;
|
||||
|
@ -57,7 +48,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.pin {
|
||||
padding: $status-margin $status-margin 0;
|
||||
padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
@ -73,7 +64,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.left-side {
|
||||
margin-right: $status-margin;
|
||||
margin-right: var(--status-margin, $status-margin);
|
||||
}
|
||||
|
||||
.right-side {
|
||||
|
@ -82,7 +73,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.usercard {
|
||||
margin-bottom: $status-margin;
|
||||
margin-bottom: var(--status-margin, $status-margin);
|
||||
}
|
||||
|
||||
.status-username {
|
||||
|
@ -248,7 +239,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.repeat-info {
|
||||
padding: 0.4em $status-margin;
|
||||
padding: 0.4em var(--status-margin, $status-margin);
|
||||
|
||||
.repeat-icon {
|
||||
color: $fallback--cGreen;
|
||||
|
@ -294,7 +285,7 @@ $status-margin: 0.75em;
|
|||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-top: $status-margin;
|
||||
margin-top: var(--status-margin, $status-margin);
|
||||
|
||||
> * {
|
||||
max-width: 4em;
|
||||
|
@ -362,7 +353,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.favs-repeated-users {
|
||||
margin-top: $status-margin;
|
||||
margin-top: var(--status-margin, $status-margin);
|
||||
}
|
||||
|
||||
.stats {
|
||||
|
@ -389,7 +380,7 @@ $status-margin: 0.75em;
|
|||
}
|
||||
|
||||
.stat-count {
|
||||
margin-right: $status-margin;
|
||||
margin-right: var(--status-margin, $status-margin);
|
||||
user-select: none;
|
||||
|
||||
.stat-title {
|
||||
|
|
|
@ -221,6 +221,31 @@
|
|||
class="fa-scale-110"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="inThreadForest && replies && replies.length && !simpleTree"
|
||||
class="button-unstyled"
|
||||
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
|
||||
:aria-expanded="threadShowing ? 'true' : 'false'"
|
||||
@click.prevent="toggleThreadDisplay"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="dive && !simpleTree"
|
||||
class="button-unstyled"
|
||||
:title="$t('status.show_only_conversation_under_this')"
|
||||
@click.prevent="dive"
|
||||
>
|
||||
<FAIcon
|
||||
fixed-width
|
||||
class="fa-scale-110"
|
||||
:icon="'angle-double-right'"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -308,6 +333,12 @@
|
|||
:no-heading="noHeading"
|
||||
:highlight="highlight"
|
||||
:focused="isFocused"
|
||||
:controlled-showing-tall="controlledShowingTall"
|
||||
:controlled-expanding-subject="controlledExpandingSubject"
|
||||
:controlled-showing-long-subject="controlledShowingLongSubject"
|
||||
:controlled-toggle-showing-tall="controlledToggleShowingTall"
|
||||
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
|
||||
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
|
||||
@mediaplay="addMediaPlaying($event)"
|
||||
@mediapause="removeMediaPlaying($event)"
|
||||
@parseReady="setHeadTailLinks"
|
||||
|
@ -317,7 +348,20 @@
|
|||
v-if="inConversation && !isPreview && replies && replies.length"
|
||||
class="replies"
|
||||
>
|
||||
<span class="faint">{{ $t('status.replies_list') }}</span>
|
||||
<button
|
||||
v-if="showOtherRepliesAsButton && replies.length > 1"
|
||||
class="button-unstyled -link faint"
|
||||
:title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
|
||||
@click.prevent="dive"
|
||||
>
|
||||
{{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="faint"
|
||||
>
|
||||
{{ $t('status.replies_list') }}
|
||||
</span>
|
||||
<StatusPopover
|
||||
v-for="reply in replies"
|
||||
:key="reply.id"
|
||||
|
|
|
@ -26,14 +26,16 @@ const StatusContent = {
|
|||
'focused',
|
||||
'noHeading',
|
||||
'fullContent',
|
||||
'singleLine'
|
||||
'singleLine',
|
||||
'showingTall',
|
||||
'expandingSubject',
|
||||
'showingLongSubject',
|
||||
'toggleShowingTall',
|
||||
'toggleExpandingSubject',
|
||||
'toggleShowingLongSubject'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
showingTall: this.fullContent || (this.inConversation && this.focused),
|
||||
showingLongSubject: false,
|
||||
// not as computed because it sets the initial state which will be changed later
|
||||
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
|
||||
postLength: this.status.text.length,
|
||||
parseReadyDone: false
|
||||
}
|
||||
|
@ -115,9 +117,9 @@ const StatusContent = {
|
|||
},
|
||||
toggleShowMore () {
|
||||
if (this.mightHideBecauseTall) {
|
||||
this.showingTall = !this.showingTall
|
||||
this.toggleShowingTall()
|
||||
} else if (this.mightHideBecauseSubject) {
|
||||
this.expandingSubject = !this.expandingSubject
|
||||
this.toggleExpandingSubject()
|
||||
}
|
||||
},
|
||||
generateTagLink (tag) {
|
||||
|
|
|
@ -17,14 +17,14 @@
|
|||
<button
|
||||
v-if="longSubject && showingLongSubject"
|
||||
class="button-unstyled -link tall-subject-hider"
|
||||
@click.prevent="showingLongSubject=false"
|
||||
@click.prevent="toggleShowingLongSubject"
|
||||
>
|
||||
{{ $t("status.hide_full_subject") }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="longSubject"
|
||||
class="button-unstyled -link tall-subject-hider"
|
||||
@click.prevent="showingLongSubject=true"
|
||||
@click.prevent="toggleShowingLongSubject"
|
||||
>
|
||||
{{ $t("status.show_full_subject") }}
|
||||
</button>
|
||||
|
|
|
@ -23,6 +23,30 @@ library.add(
|
|||
faPollH
|
||||
)
|
||||
|
||||
const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
|
||||
const camelized = camelCase(name)
|
||||
const toggle = `controlledToggle${camelized}`
|
||||
const controlledName = `controlled${camelized}`
|
||||
const uncontrolledName = `uncontrolled${camelized}`
|
||||
res[name] = function () {
|
||||
return this[toggle] ? this[controlledName] : this[uncontrolledName]
|
||||
}
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const controlledOrUncontrolledToggle = (obj, name) => {
|
||||
const camelized = camelCase(name)
|
||||
const toggle = `controlledToggle${camelized}`
|
||||
const uncontrolledName = `uncontrolled${camelized}`
|
||||
if (obj[toggle]) {
|
||||
obj[toggle]()
|
||||
} else {
|
||||
obj[uncontrolledName] = !obj[uncontrolledName]
|
||||
}
|
||||
}
|
||||
|
||||
const StatusContent = {
|
||||
name: 'StatusContent',
|
||||
props: [
|
||||
|
@ -31,9 +55,22 @@ const StatusContent = {
|
|||
'focused',
|
||||
'noHeading',
|
||||
'fullContent',
|
||||
'singleLine'
|
||||
'singleLine',
|
||||
'controlledShowingTall',
|
||||
'controlledExpandingSubject',
|
||||
'controlledToggleShowingTall',
|
||||
'controlledToggleExpandingSubject'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused),
|
||||
uncontrolledShowingLongSubject: false,
|
||||
// not as computed because it sets the initial state which will be changed later
|
||||
uncontrolledExpandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']),
|
||||
hideAttachments () {
|
||||
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
|
||||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
|
||||
|
@ -71,6 +108,21 @@ const StatusContent = {
|
|||
Gallery,
|
||||
LinkPreview,
|
||||
StatusBody
|
||||
},
|
||||
methods: {
|
||||
toggleShowingTall () {
|
||||
controlledOrUncontrolledToggle(this, 'showingTall')
|
||||
},
|
||||
toggleExpandingSubject () {
|
||||
controlledOrUncontrolledToggle(this, 'expandingSubject')
|
||||
},
|
||||
toggleShowingLongSubject () {
|
||||
controlledOrUncontrolledToggle(this, 'showingLongSubject')
|
||||
},
|
||||
setMedia () {
|
||||
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
|
||||
return () => this.$store.dispatch('setMedia', attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,12 @@
|
|||
:status="status"
|
||||
:compact="compact"
|
||||
:single-line="singleLine"
|
||||
:showing-tall="showingTall"
|
||||
:expanding-subject="expandingSubject"
|
||||
:showing-long-subject="showingLongSubject"
|
||||
:toggle-showing-tall="toggleShowingTall"
|
||||
:toggle-expanding-subject="toggleExpandingSubject"
|
||||
:toggle-showing-long-subject="toggleShowingLongSubject"
|
||||
@parseReady="$emit('parseReady', $event)"
|
||||
>
|
||||
<div v-if="status.poll && status.poll.options && !compact">
|
||||
|
@ -52,10 +58,6 @@
|
|||
|
||||
<script src="./status_content.js" ></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
$status-margin: 0.75em;
|
||||
|
||||
.StatusContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
90
src/components/thread_tree/thread_tree.js
Normal file
90
src/components/thread_tree/thread_tree.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
import Status from '../status/status.vue'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAngleDoubleDown,
|
||||
faAngleDoubleRight
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faAngleDoubleDown,
|
||||
faAngleDoubleRight
|
||||
)
|
||||
|
||||
const ThreadTree = {
|
||||
components: {
|
||||
Status
|
||||
},
|
||||
name: 'ThreadTree',
|
||||
props: {
|
||||
depth: Number,
|
||||
status: Object,
|
||||
inProfile: Boolean,
|
||||
conversation: Array,
|
||||
collapsable: Boolean,
|
||||
isExpanded: Boolean,
|
||||
pinnedStatusIdsObject: Object,
|
||||
profileUserId: String,
|
||||
|
||||
focused: Function,
|
||||
highlight: String,
|
||||
getReplies: Function,
|
||||
setHighlight: Function,
|
||||
toggleExpanded: Function,
|
||||
|
||||
simple: Boolean,
|
||||
// to control display of the whole thread forest
|
||||
toggleThreadDisplay: Function,
|
||||
threadDisplayStatus: Object,
|
||||
showThreadRecursively: Function,
|
||||
totalReplyCount: Object,
|
||||
totalReplyDepth: Object,
|
||||
statusContentProperties: Object,
|
||||
setStatusContentProperty: Function,
|
||||
toggleStatusContentProperty: Function,
|
||||
dive: Function
|
||||
},
|
||||
computed: {
|
||||
suspendable () {
|
||||
const selfSuspendable = this.$refs.statusComponent ? this.$refs.statusComponent.suspendable : true
|
||||
if (this.$refs.childComponent) {
|
||||
return selfSuspendable && this.$refs.childComponent.every(s => s.suspendable)
|
||||
}
|
||||
return selfSuspendable
|
||||
},
|
||||
reverseLookupTable () {
|
||||
return this.conversation.reduce((table, status, index) => {
|
||||
table[status.id] = index
|
||||
return table
|
||||
}, {})
|
||||
},
|
||||
currentReplies () {
|
||||
return this.getReplies(this.status.id).map(({ id }) => this.statusById(id))
|
||||
},
|
||||
threadShowing () {
|
||||
return this.threadDisplayStatus[this.status.id] === 'showing'
|
||||
},
|
||||
currentProp () {
|
||||
return this.statusContentProperties[this.status.id]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
statusById (id) {
|
||||
return this.conversation[this.reverseLookupTable[id]]
|
||||
},
|
||||
collapseThread () {
|
||||
},
|
||||
showThread () {
|
||||
},
|
||||
showAllSubthreads () {
|
||||
},
|
||||
toggleCurrentProp (name) {
|
||||
this.toggleStatusContentProperty(this.status.id, name)
|
||||
},
|
||||
setCurrentProp (name, newVal) {
|
||||
this.setStatusContentProperty(this.status.id, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ThreadTree
|
127
src/components/thread_tree/thread_tree.vue
Normal file
127
src/components/thread_tree/thread_tree.vue
Normal file
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div class="thread-tree panel-body">
|
||||
<status
|
||||
:key="status.id"
|
||||
ref="statusComponent"
|
||||
:inline-expanded="collapsable && isExpanded"
|
||||
:statusoid="status"
|
||||
:expandable="!isExpanded"
|
||||
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
|
||||
:focused="focused(status.id)"
|
||||
:in-conversation="isExpanded"
|
||||
:highlight="highlight"
|
||||
:replies="getReplies(status.id)"
|
||||
:in-profile="inProfile"
|
||||
:profile-user-id="profileUserId"
|
||||
class="conversation-status conversation-status-treeview status-fadein panel-body"
|
||||
|
||||
:simple-tree="simple"
|
||||
:controlled-thread-display-status="threadDisplayStatus[status.id]"
|
||||
:controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)"
|
||||
|
||||
:controlled-showing-tall="currentProp.showingTall"
|
||||
:controlled-expanding-subject="currentProp.expandingSubject"
|
||||
:controlled-showing-long-subject="currentProp.showingLongSubject"
|
||||
:controlled-replying="currentProp.replying"
|
||||
:controlled-media-playing="currentProp.mediaPlaying"
|
||||
:controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')"
|
||||
:controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')"
|
||||
:controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')"
|
||||
:controlled-toggle-replying="() => toggleCurrentProp('replying')"
|
||||
:controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)"
|
||||
:dive="dive ? () => dive(status.id) : undefined"
|
||||
|
||||
@goto="setHighlight"
|
||||
@toggleExpanded="toggleExpanded"
|
||||
/>
|
||||
<div
|
||||
v-if="currentReplies.length && threadShowing"
|
||||
class="thread-tree-replies"
|
||||
>
|
||||
<thread-tree
|
||||
v-for="replyStatus in currentReplies"
|
||||
:key="replyStatus.id"
|
||||
ref="childComponent"
|
||||
:depth="depth + 1"
|
||||
:status="replyStatus"
|
||||
|
||||
:in-profile="inProfile"
|
||||
:conversation="conversation"
|
||||
:collapsable="collapsable"
|
||||
:is-expanded="isExpanded"
|
||||
:pinned-status-ids-object="pinnedStatusIdsObject"
|
||||
:profile-user-id="profileUserId"
|
||||
|
||||
:focused="focused"
|
||||
:get-replies="getReplies"
|
||||
:highlight="highlight"
|
||||
:set-highlight="setHighlight"
|
||||
:toggle-expanded="toggleExpanded"
|
||||
|
||||
:simple="simple"
|
||||
:toggle-thread-display="toggleThreadDisplay"
|
||||
:thread-display-status="threadDisplayStatus"
|
||||
:show-thread-recursively="showThreadRecursively"
|
||||
:total-reply-count="totalReplyCount"
|
||||
:total-reply-depth="totalReplyDepth"
|
||||
:status-content-properties="statusContentProperties"
|
||||
:set-status-content-property="setStatusContentProperty"
|
||||
:toggle-status-content-property="toggleStatusContentProperty"
|
||||
:dive="dive"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentReplies.length && !threadShowing"
|
||||
class="thread-tree-replies thread-tree-replies-hidden"
|
||||
>
|
||||
<i18n
|
||||
v-if="simple"
|
||||
tag="button"
|
||||
path="status.thread_follow_with_icon"
|
||||
class="button-unstyled -link thread-tree-show-replies-button"
|
||||
@click.prevent="dive(status.id)"
|
||||
>
|
||||
<FAIcon
|
||||
place="icon"
|
||||
icon="angle-double-right"
|
||||
/>
|
||||
<span place="text">
|
||||
{{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
|
||||
</span>
|
||||
</i18n>
|
||||
<i18n
|
||||
v-else
|
||||
tag="button"
|
||||
path="status.thread_show_full_with_icon"
|
||||
class="button-unstyled -link thread-tree-show-replies-button"
|
||||
@click.prevent="showThreadRecursively(status.id)"
|
||||
>
|
||||
<FAIcon
|
||||
place="icon"
|
||||
icon="angle-double-down"
|
||||
/>
|
||||
<span place="text">
|
||||
{{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
|
||||
</span>
|
||||
</i18n>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./thread_tree.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
.thread-tree-replies {
|
||||
margin-left: var(--status-margin, $status-margin);
|
||||
border-left: 2px solid var(--border, $fallback--border);
|
||||
}
|
||||
|
||||
.thread-tree-replies-hidden {
|
||||
padding: var(--status-margin, $status-margin);
|
||||
/* Make the button stretch along the whole row */
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
|
@ -468,6 +468,15 @@
|
|||
"subject_line_email": "Like email: \"re: subject\"",
|
||||
"subject_line_mastodon": "Like mastodon: copy as is",
|
||||
"subject_line_noop": "Do not copy",
|
||||
"conversation_display": "Conversation display style",
|
||||
"conversation_display_tree": "Tree-style",
|
||||
"tree_advanced": "Allow more flexible navigation in tree view",
|
||||
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
|
||||
"conversation_display_linear": "Linear-style",
|
||||
"conversation_other_replies_button": "Show the \"other replies\" button",
|
||||
"conversation_other_replies_button_below": "Below statuses",
|
||||
"conversation_other_replies_button_inside": "Inside statuses",
|
||||
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
|
||||
"post_status_content_type": "Post status content type",
|
||||
"sensitive_by_default": "Mark posts as sensitive by default",
|
||||
"stop_gifs": "Pause animated images until you hover on them",
|
||||
|
@ -724,6 +733,7 @@
|
|||
"reply_to": "Reply to",
|
||||
"mentions": "Mentions",
|
||||
"replies_list": "Replies:",
|
||||
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
|
||||
"mute_conversation": "Mute conversation",
|
||||
"unmute_conversation": "Unmute conversation",
|
||||
"status_unavailable": "Status unavailable",
|
||||
|
@ -750,7 +760,18 @@
|
|||
"attachment_stop_flash": "Stop Flash player",
|
||||
"move_up": "Shift attachment left",
|
||||
"move_down": "Shift attachment right",
|
||||
"open_gallery": "Open gallery"
|
||||
"open_gallery": "Open gallery",
|
||||
"thread_hide": "Hide this thread",
|
||||
"thread_show": "Show this thread",
|
||||
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
|
||||
"thread_show_full_with_icon": "{icon} {text}",
|
||||
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
|
||||
"thread_follow_with_icon": "{icon} {text}",
|
||||
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
|
||||
"ancestor_follow_with_icon": "{icon} {text}",
|
||||
"show_all_conversation_with_icon": "{icon} {text}",
|
||||
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
|
||||
"show_only_conversation_under_this": "Only show replies to this status"
|
||||
},
|
||||
"user_card": {
|
||||
"approve": "Approve",
|
||||
|
|
|
@ -12,6 +12,8 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0]
|
|||
export const multiChoiceProperties = [
|
||||
'postContentType',
|
||||
'subjectLineBehavior',
|
||||
'conversationDisplay', // tree | linear
|
||||
'conversationOtherRepliesButton', // below | inside
|
||||
'mentionLinkDisplay' // short | full_for_remote | full
|
||||
]
|
||||
|
||||
|
@ -83,7 +85,12 @@ export const defaultState = {
|
|||
hideBotIndication: undefined, // instance default
|
||||
hideUserStats: undefined, // instance default
|
||||
virtualScrolling: undefined, // instance default
|
||||
sensitiveByDefault: undefined // instance default
|
||||
sensitiveByDefault: undefined, // instance default
|
||||
conversationDisplay: undefined, // instance default
|
||||
conversationTreeAdvanced: undefined, // instance default
|
||||
conversationOtherRepliesButton: undefined, // instance default
|
||||
conversationTreeFadeAncestors: undefined, // instance default
|
||||
maxDepthInThread: undefined // instance default
|
||||
}
|
||||
|
||||
// caching the instance default properties
|
||||
|
|
|
@ -55,6 +55,11 @@ const defaultState = {
|
|||
theme: 'pleroma-dark',
|
||||
virtualScrolling: true,
|
||||
sensitiveByDefault: false,
|
||||
conversationDisplay: 'linear',
|
||||
conversationTreeAdvanced: false,
|
||||
conversationOtherRepliesButton: 'below',
|
||||
conversationTreeFadeAncestors: false,
|
||||
maxDepthInThread: 6,
|
||||
|
||||
// Nasty stuff
|
||||
customEmoji: [],
|
||||
|
|
Loading…
Reference in a new issue