Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85830635
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
80 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 423930afb0..d4972fbc74 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,510 +1,519 @@
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 debug = console.log
const debug = () => {}
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 sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter(_ => _).sort(sortById)
}
const conversation = {
data () {
return {
highlight: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
statusContentPropertiesObject: {},
inlineDivePosition: null
}
},
props: [
'statusId',
'collapsable',
'isPage',
'pinnedStatusIdsObject',
'inProfile',
'profileUserId',
'virtualHidden'
],
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
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.displayStyle === 'tree' || this.displayStyle === 'simple_tree'
},
treeViewIsSimple () {
return this.displayStyle === 'simple_tree'
},
isLinearView () {
return this.displayStyle === 'linear'
},
+ otherRepliesButtonPosition () {
+ return this.$store.getters.mergedConfig.conversationOtherRepliesButton
+ },
+ showOtherRepliesButtonBelowStatus () {
+ return this.otherRepliesButtonPosition === 'below'
+ },
+ showOtherRepliesButtonInsideStatus () {
+ return this.otherRepliesButtonPosition === 'inside'
+ },
hideStatus () {
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.virtualHidden && this.$refs.statusComponent[0].suspendable
} else {
return this.virtualHidden
}
},
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
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 = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
conversationDive () {
},
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: {}
})
debug('threads = ', threads)
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 () {
debug('replyIds=', this.replyIds)
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)
debug('totalReplyCount=', sizes)
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 () {
debug('threadTree', this.threadTree)
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)
debug('toplevel =', topLevel)
return topLevel
},
otherTopLevelCount () {
return this.topLevel.length - 1
},
showingTopLevel () {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot () {
(() => {})(this.conversation)
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.diveMode && this.topLevel.length > 1
},
replies () {
let i = 1
// eslint-disable-next-line camelcase
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id: id
})
}
i++
return result
}, {})
},
isExpanded () {
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 = (() => {
if (this.statusContentPropertiesObject[id]) {
return this.statusContentPropertiesObject[id]
}
return {
showingTall: false,
expandingSubject: false,
showingLongSubject: false
}
})()
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,
ThreadTree
},
watch: {
statusId (newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded (value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden (value) {
this.$store.dispatch(
'setVirtualHeight',
{ statusId: this.statusId, height: `${this.$el.clientHeight}px` }
)
}
},
methods: {
fetchConversation () {
if (this.status) {
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
.then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setHighlight(this.originalStatusId)
})
} else {
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
}
},
getReplies (id) {
return this.replies[id] || []
},
getHighlight () {
return this.isExpanded ? this.highlight : null
},
setHighlight (id) {
if (!id) return
this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
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 depth = this.depths[id]
debug('depth = ', depth)
debug(
'threadDisplayStatus = ', this.threadDisplayStatus,
'threadDisplayStatusObject = ', this.threadDisplayStatusObject)
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
debug('toggling', id, 'to', nextStatus)
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
}
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 = {}
}
}
}
export default conversation
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index f0eb88c124..b3d97075d3 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,267 +1,268 @@
<template>
<div
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading"
>
<span class="title"> {{ $t('timeline.conversation') }} </span>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
</div>
<div class="conversation-body panel-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="isTreeView"
class="thread-body"
>
<div
v-if="ancestorsOf(diveRoot).length"
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}"
>
<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="treeViewIsSimple"
+ :simple-tree="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
- :dive="(!treeViewIsSimple) ? () => diveIntoStatus(status.id) : null"
+ :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-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
<div
- v-if="getReplies(status.id).length > 1"
+ 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
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.Conversation {
.conversation-dive-to-top-level-box {
padding: $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: $status-margin;
border-left: 2px solid var(--border, $fallback--border);
}
.thread-ancestor .StatusContent {
--link: var(--faintLink);
--text: var(--faint);
color: var(--text);
}
.thread-ancestor-dive-box {
padding-left: $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: $status-margin;
//border-left: 2px solid var(--border, $fallback--border);
}
.conversation-status {
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
}
.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);
}
/* &.-expanded { */
/* .conversation-status:last-child { */
/* border-bottom: none; */
/* } */
/* } */
}
</style>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index c43abe8ae4..a963d2047a 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,68 +1,73 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
library.add(
faGlobe
)
const GeneralTab = {
data () {
return {
subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
})),
conversationDisplayOptions: ['tree', 'simple_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,
label: this.$t(`settings.mention_link_display_${mode}`)
})),
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
// Chrome-likes
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
// Future spec, still not supported in Nightly 63 as of 08/2018
Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks')
}
},
components: {
BooleanSetting,
ChoiceSetting,
InterfaceLanguageSwitcher
},
computed: {
postFormats () {
return this.$store.state.instance.postFormats || []
},
postContentOptions () {
return this.postFormats.map(format => ({
key: format,
value: format,
label: this.$t(`post_status.content_type["${format}"]`)
}))
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () {
return this.$store.state.instance.background &&
!this.$store.state.users.currentUser.background_image
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
...SharedComputedObject()
}
}
export default GeneralTab
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index f97d92c352..d5ae781087 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -1,292 +1,306 @@
<template>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher />
</li>
<li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sidebarRight">
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li v-if="instanceWallpaperUsed">
<BooleanSetting path="hideInstanceWallpaper">
{{ $t('settings.hide_wallpaper') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="streaming">
{{ $t('settings.streaming') }}
</BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<BooleanSetting
path="pauseOnUnfocused"
:disabled="!streaming"
>
{{ $t('settings.pause_on_unfocused') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting path="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</BooleanSetting>
</li>
<li>
<BooleanSetting path="virtualScrolling">
{{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li v-if="instanceShoutboxPresent">
<BooleanSetting path="hideShoutbox">
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.post_look_feel') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<BooleanSetting path="useContainFit">
{{ $t('settings.use_contain_fit') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideNsfw">
{{ $t('settings.nsfw_clickthrough') }}
</BooleanSetting>
</li>
<ul class="setting-list suboptions">
<li>
<BooleanSetting
path="preloadImage"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="useOneClickNsfw"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
</BooleanSetting>
</li>
</ul>
<li>
<BooleanSetting path="loopVideo">
{{ $t('settings.loop_video') }}
</BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !streaming}]"
>
<li>
<BooleanSetting
path="loopVideoSilentOnly"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
</BooleanSetting>
<div
v-if="!loopSilentAvailable"
class="unavailable"
>
<FAIcon icon="globe" />! {{ $t('settings.limited_availability') }}
</div>
</li>
</ul>
</li>
<li>
<BooleanSetting path="playVideosInModal">
{{ $t('settings.play_videos_in_modal') }}
</BooleanSetting>
</li>
<h3>{{ $t('settings.fun') }}</h3>
<li>
<BooleanSetting path="greentext">
{{ $t('settings.greentext') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkShowYous">
{{ $t('settings.show_yous') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="conversationDisplay"
path="conversationDisplay"
:options="conversationDisplayOptions"
>
{{ $t('settings.conversation_display') }}
</ChoiceSetting>
</li>
- <li v-if="conversationDisplay !== 'linear'">
- <label for="maxDepthInThread">
- {{ $t('settings.max_depth_in_thread') }}
- </label>
- <input
- id="maxDepthInThread"
- path.number="maxDepthInThread"
- class="number-input"
- type="number"
- min="3"
- step="1"
- >
- </li>
+ <ul
+ v-if="conversationDisplay !== 'linear'"
+ class="setting-list suboptions"
+ >
+ <li>
+ <label for="maxDepthInThread">
+ {{ $t('settings.max_depth_in_thread') }}
+ </label>
+ <input
+ id="maxDepthInThread"
+ path.number="maxDepthInThread"
+ class="number-input"
+ type="number"
+ min="3"
+ step="1"
+ >
+ </li>
+ <li>
+ <ChoiceSetting
+ id="conversationOtherRepliesButton"
+ path="conversationOtherRepliesButton"
+ :options="conversationOtherRepliesButtonOptions"
+ >
+ {{ $t('settings.conversation_other_replies_button') }}
+ </ChoiceSetting>
+ </li>
+ </ul>
<li>
<ChoiceSetting
id="mentionLinkDisplay"
path="mentionLinkDisplay"
:options="mentionLinkDisplayOptions"
>
{{ $t('settings.mention_link_display') }}
</ChoiceSetting>
</li>
<ul
class="setting-list suboptions"
>
<li
v-if="mentionLinkDisplay === 'short'"
>
<BooleanSetting path="mentionLinkShowTooltip">
{{ $t('settings.mention_link_show_tooltip') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="useAtIcon">
{{ $t('settings.use_at_icon') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkShowAvatar">
{{ $t('settings.mention_link_show_avatar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkFadeDomain">
{{ $t('settings.mention_link_fade_domain') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkBoldenYou">
{{ $t('settings.mention_link_bolden_you') }}
</BooleanSetting>
</li>
</ul>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="scopeCopy">
{{ $t('settings.scope_copy') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowSubjectInput">
{{ $t('settings.subject_input_always_show') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
id="subjectLineBehavior"
path="subjectLineBehavior"
:options="subjectLineOptions"
>
{{ $t('settings.subject_line_behavior') }}
</ChoiceSetting>
</li>
<li v-if="postFormats.length > 0">
<ChoiceSetting
id="postContentType"
path="postContentType"
:options="postContentOptions"
>
{{ $t('settings.post_status_content_type') }}
</ChoiceSetting>
</li>
<li>
<BooleanSetting path="minimalScopesMode">
{{ $t('settings.minimal_scopes_mode') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowNewPostButton">
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="padEmoji">
{{ $t('settings.pad_emoji') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="webPushNotifications">
{{ $t('settings.enable_web_push_notifications') }}
</BooleanSetting>
</li>
</ul>
</div>
</div>
</template>
<script src="./general_tab.js"></script>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 15c2d02958..700b97640e 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,431 +1,432 @@
import ReplyButton from '../reply_button/reply_button.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import ReactButton from '../react_button/react_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import ExtraButtons from '../extra_buttons/extra_buttons.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
import { unescape, uniqBy } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEnvelope,
faLock,
faLockOpen,
faGlobe,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faSmileBeam,
faEllipsisH,
faStar,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight
)
const Status = {
name: 'Status',
components: {
ReplyButton,
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
UserCard,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
RichContent,
MentionLink,
MentionsLine
},
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'isPreview',
'noHeading',
'inlineExpanded',
'showPinned',
'inProfile',
'profileUserId',
'simpleTree',
'controlledThreadDisplayStatus',
'controlledToggleThreadDisplay',
+ 'showOtherRepliesAsButton',
'controlledShowingTall',
'controlledToggleShowingTall',
'controlledExpandingSubject',
'controlledToggleExpandingSubject',
'controlledShowingLongSubject',
'controlledToggleShowingLongSubject',
'dive'
],
data () {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: [],
suspendable: true,
error: null,
headTailLinks: null
}
},
computed: {
muteWords () {
return this.mergedConfig.muteWords
},
showReasonMutedThread () {
return (
this.status.thread_muted ||
(this.status.reblog && this.status.reblog.thread_muted)
) && !this.inConversation
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.mergedConfig.highlight
return highlightStyle(highlight[user.screen_name])
},
userProfileLink () {
return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
},
replyProfileLink () {
if (this.isReply) {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeterUser () { return this.statusoid.user },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository () {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn () {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
mentionsLine () {
if (!this.headTailLinks) return []
const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
return this.status.attentions.filter(attn => {
// no reply user
return attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
}).map(attn => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id
}))
},
hasMentionsLine () {
return this.mentionsLine.length > 0
},
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const reasonsToMute = this.userIsMuted ||
// Thread is muted
status.thread_muted ||
// Wordfiltered
this.muteWordHits.length > 0
return !this.unmuted && !this.shouldNotMute && reasonsToMute
},
userIsMuted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id)
return status.muted ||
// Reprööt of a muted post according to BE
(reblog && reblog.muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
(relationshipReblog && relationshipReblog.muting)
},
shouldNotMute () {
const { status } = this
const { reblog } = status
return (
(
this.inProfile && (
// Don't mute user's posts on user timeline (except reblogs)
(!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
(reblog && reblog.user.id === this.profileUserId)
)
) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)
// No excuses if post has muted words
) && !this.muteWordHits.length > 0
},
hideMutedUsers () {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads () {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses () {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts () {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus () {
return (this.virtualHidden || !this.shouldNotMute) && (
(this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteWordHits.length > 0 && this.hideWordFilteredPosts)
)
},
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
isReply () {
return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id)
},
replyToName () {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
return user && user.screen_name_ui
}
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers () {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy
)
return uniqBy(combinedUsers, 'id')
},
tags () {
return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ')
},
hidePostStats () {
return this.mergedConfig.hidePostStats
},
currentUser () {
return this.$store.state.users.currentUser
},
betterShadow () {
return this.$store.state.interface.browserSupport.cssFilter
},
mergedConfig () {
return this.$store.getters.mergedConfig
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest () {
return !!this.controlledThreadDisplayStatus
},
threadShowing () {
return this.controlledThreadDisplayStatus === 'showing'
}
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
default:
return 'globe'
}
},
showError (error) {
this.error = error
},
clearError () {
this.error = undefined
},
toggleReplying () {
this.replying = !this.replying
},
gotoOriginal (id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
addMediaPlaying (id) {
this.mediaPlaying.push(id)
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay () {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted (highlightId) {
const id = highlightId
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= (window.innerHeight - 50)) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
},
mounted () {
this.scrollIfHighlighted(this.highlight)
},
watch: {
'highlight': function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.rebloggedBy && this.statusFromGlobalRepository.rebloggedBy.length !== num) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
'isSuspendable': function (val) {
this.suspendable = val
}
},
filters: {
capitalize: function (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index e48127119a..bc0aeaf06c 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,475 +1,488 @@
<template>
<div
v-if="!hideStatus"
class="Status"
:class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
class="alert error"
>
{{ error }}
<span
class="fa-scale-110 fa-old-padding"
@click="clearError"
>
<FAIcon icon="times" />
</span>
</div>
<template v-if="muted && !isPreview">
<div class="status-container muted">
<small class="status-username">
<FAIcon
v-if="muted && retweet"
class="fa-scale-110 fa-old-padding repeat-icon"
icon="retweet"
/>
<router-link :to="userProfileLink">
{{ status.user.screen_name_ui }}
</router-link>
</small>
<small
v-if="showReasonMutedThread"
class="mute-thread"
>
{{ $t('status.thread_muted') }}
</small>
<small
v-if="showReasonMutedThread && muteWordHits.length > 0"
class="mute-thread"
>
{{ $t('status.thread_muted_and_words') }}
</small>
<small
class="mute-words"
:title="muteWordHits.join(', ')"
>
{{ muteWordHits.join(', ') }}
</small>
<button
class="unmute button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
icon="eye-slash"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</template>
<template v-else>
<div
v-if="showPinned"
class="pin"
>
<FAIcon
icon="thumbtack"
class="faint"
/>
<span class="faint">{{ $t('status.pinned') }}</span>
</div>
<div
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
<div class="right-side faint">
<span
class="status-username repeater-name"
:title="retweeter"
>
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
>{{ retweeter }}</router-link>
</span>
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
</div>
<div
v-if="!deleted"
:class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
class="left-side"
>
<router-link
:to="userProfileLink"
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
/>
</router-link>
</div>
<div class="right-side">
<UserCard
v-if="userExpanded"
:user-id="status.user.id"
:rounded="true"
:bordered="true"
class="usercard"
/>
<div
v-if="!noHeading"
class="status-heading"
>
<div class="heading-name-row">
<div class="heading-left">
<h4
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
/>
</h4>
<h4
v-else
class="status-username"
:title="status.user.name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
:title="status.user.screen_name_ui"
:to="userProfileLink"
>
{{ status.user.screen_name_ui }}
</router-link>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div>
<span class="heading-right">
<router-link
class="timeago faint-link"
:to="{ name: 'conversation', params: { id: status.id } }"
>
<Timeago
:time="status.created_at"
:auto-update="60"
/>
</router-link>
<span
v-if="status.visibility"
class="visibility-icon"
:title="status.visibility | capitalize"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(status.visibility)"
/>
</span>
<button
v-if="expandable && !isPreview"
class="button-unstyled"
:title="$t('status.expand')"
@click.prevent="toggleExpanded"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="plus-square"
/>
</button>
<button
v-if="unmuted"
class="button-unstyled"
@click.prevent="toggleMute"
>
<FAIcon
fixed-width
icon="eye-slash"
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
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="glued-label reply-glued-label"
>
<StatusPopover
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
class="button-unstyled reply-to"
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
<span
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</button>
</StatusPopover>
<span
v-else
class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
:first-mention="false"
/>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<span
class="mentions-text"
>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
</div>
<StatusContent
ref="content"
:status="status"
: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"
/>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
- <span class="faint">{{ $t('status.replies_list') }}</span>
+ <button
+ v-if="showOtherRepliesAsButton"
+ class="button-unstyled -link faint"
+ :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
+ @click.prevent="dive"
+ >
+ {{ $t('status.replies_list') }}
+ </button>
+ <span
+ v-else
+ class="faint"
+ >
+ {{ $t('status.replies_list') }}
+ </span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
class="favs-repeated-users"
>
<div class="stats">
<UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
:users="statusFromGlobalRepository.rebloggedBy"
>
<div class="stat-count">
<a class="stat-title">{{ $t('status.repeats') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div>
</UserListPopover>
<UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
:users="statusFromGlobalRepository.favoritedBy"
>
<div
class="stat-count"
>
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div>
</UserListPopover>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
</div>
</div>
</transition>
<EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
:status="status"
/>
<div
v-if="!noHeading && !isPreview"
class="status-actions"
>
<reply-button
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
<retweet-button
:visibility="status.visibility"
:logged-in="loggedIn"
:status="status"
/>
<favorite-button
:logged-in="loggedIn"
:status="status"
/>
<ReactButton
v-if="loggedIn"
:status="status"
/>
<extra-buttons
:status="status"
@onError="showError"
@onSuccess="clearError"
/>
</div>
</div>
</div>
<div
v-else
class="gravestone"
>
<div class="left-side">
<UserAvatar :compact="compact" />
</div>
<div class="right-side">
<div class="deleted-text">
{{ $t('status.status_deleted') }}
</div>
<reply-button
v-if="replying"
:replying="replying"
:status="status"
@toggle="toggleReplying"
/>
</div>
</div>
<div
v-if="replying"
class="status-container reply-form"
>
<PostStatusForm
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
/>
</div>
</template>
</div>
</template>
<script src="./status.js" ></script>
<style src="./status.scss" lang="scss"></style>
diff --git a/src/modules/config.js b/src/modules/config.js
index b3c7e5abb2..a111abca4a 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -1,165 +1,167 @@
import { set, delete as del } from 'vue'
import { setPreset, applyTheme } from '../services/style_setter/style_setter.js'
import messages from '../i18n/messages'
const browserLocale = (window.navigator.language || 'en').split('-')[0]
/* TODO this is a bit messy.
* We need to declare settings with their types and also deal with
* instance-default settings in some way, hopefully try to avoid copy-pasta
* in general.
*/
export const multiChoiceProperties = [
'postContentType',
'subjectLineBehavior',
'conversationDisplay', // tree | linear
+ 'conversationOtherRepliesButton', // below | inside
'mentionLinkDisplay' // short | full_for_remote | full
]
export const defaultState = {
colors: {},
theme: undefined,
customTheme: undefined,
customThemeSource: undefined,
hideISP: false,
hideInstanceWallpaper: false,
hideShoutbox: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: undefined, // instance default
hideMutedThreads: undefined, // instance default
hideWordFilteredPosts: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default
padEmoji: true,
hideAttachments: false,
hideAttachmentsInConv: false,
maxThumbnails: 16,
hideNsfw: true,
preloadImage: true,
loopVideo: true,
loopVideoSilentOnly: true,
streaming: false,
emojiReactionsOnTimeline: true,
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: false,
replyVisibility: 'all',
notificationVisibility: {
follows: true,
mentions: true,
likes: true,
repeats: true,
moves: true,
emojiReactions: true,
followRequest: true,
chatMention: true
},
webPushNotifications: false,
muteWords: [],
highlight: {},
interfaceLanguage: browserLocale,
hideScopeNotice: false,
useStreamingApi: false,
sidebarRight: undefined, // instance default
scopeCopy: undefined, // instance default
subjectLineBehavior: undefined, // instance default
alwaysShowSubjectInput: undefined, // instance default
postContentType: undefined, // instance default
minimalScopesMode: undefined, // instance default
// This hides statuses filtered via a word filter
hideFilteredStatuses: undefined, // instance default
playVideosInModal: false,
useOneClickNsfw: false,
useContainFit: false,
greentext: undefined, // instance default
useAtIcon: undefined, // instance default
mentionLinkDisplay: undefined, // instance default
mentionLinkShowTooltip: undefined, // instance default
mentionLinkShowAvatar: undefined, // instance default
mentionLinkFadeDomain: undefined, // instance default
mentionLinkShowYous: undefined, // instance default
mentionLinkBoldenYou: undefined, // instance default
hidePostStats: undefined, // instance default
hideUserStats: undefined, // instance default
virtualScrolling: undefined, // instance default
sensitiveByDefault: undefined, // instance default
conversationDisplay: undefined, // instance default
+ conversationOtherRepliesButton: undefined, // instance default
maxDepthInThread: 6
}
// caching the instance default properties
export const instanceDefaultProperties = Object.entries(defaultState)
.filter(([key, value]) => value === undefined)
.map(([key, value]) => key)
const config = {
state: { ...defaultState },
getters: {
defaultConfig (state, getters, rootState, rootGetters) {
const { instance } = rootState
return {
...defaultState,
...Object.fromEntries(
instanceDefaultProperties.map(key => [key, instance[key]])
)
}
},
mergedConfig (state, getters, rootState, rootGetters) {
const { defaultConfig } = rootGetters
return {
...defaultConfig,
// Do not override with undefined
...Object.fromEntries(Object.entries(state).filter(([k, v]) => v !== undefined))
}
}
},
mutations: {
setOption (state, { name, value }) {
set(state, name, value)
},
setHighlight (state, { user, color, type }) {
const data = this.state.config.highlight[user]
if (color || type) {
set(state.highlight, user, { color: color || data.color, type: type || data.type })
} else {
del(state.highlight, user)
}
}
},
actions: {
loadSettings ({ dispatch }, data) {
const knownKeys = new Set(Object.keys(defaultState))
const presentKeys = new Set(Object.keys(data))
const intersection = new Set()
for (let elem of presentKeys) {
if (knownKeys.has(elem)) {
intersection.add(elem)
}
}
intersection.forEach(
name => dispatch('setOption', { name, value: data[name] })
)
},
setHighlight ({ commit, dispatch }, { user, color, type }) {
commit('setHighlight', { user, color, type })
},
setOption ({ commit, dispatch }, { name, value }) {
commit('setOption', { name, value })
switch (name) {
case 'theme':
setPreset(value)
break
case 'customTheme':
case 'customThemeSource':
applyTheme(value)
break
case 'interfaceLanguage':
messages.setLanguage(this.getters.i18n, value)
break
}
}
}
}
export default config
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 853be73706..cd17c23da5 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -1,226 +1,227 @@
import { set } from 'vue'
import { getPreset, applyTheme } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import apiService from '../services/api/api.service.js'
import { instanceDefaultProperties } from './config.js'
const defaultState = {
// Stuff from apiConfig
name: 'Pleroma FE',
registrationOpen: true,
server: 'http://localhost:4040/',
textlimit: 5000,
themeData: undefined,
vapidPublicKey: undefined,
// Stuff from static/config.json
alwaysShowSubjectInput: true,
defaultAvatar: '/images/avi.png',
defaultBanner: '/images/banner.png',
background: '/static/aurora_borealis.jpg',
collapseMessageWithSubject: false,
greentext: false,
useAtIcon: false,
mentionLinkDisplay: 'short',
mentionLinkShowTooltip: true,
mentionLinkShowAvatar: false,
mentionLinkFadeDomain: true,
mentionLinkShowYous: false,
mentionLinkBoldenYou: true,
hideFilteredStatuses: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: false,
hideMutedThreads: true,
hideWordFilteredPosts: false,
hidePostStats: false,
hideSitename: false,
hideUserStats: false,
loginMethod: 'password',
logo: '/static/logo.svg',
logoMargin: '.2em',
logoMask: true,
logoLeft: false,
minimalScopesMode: false,
nsfwCensorImage: undefined,
postContentType: 'text/plain',
redirectRootLogin: '/main/friends',
redirectRootNoLogin: '/main/all',
scopeCopy: true,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
sidebarRight: false,
subjectLineBehavior: 'email',
theme: 'pleroma-dark',
virtualScrolling: true,
sensitiveByDefault: false,
conversationDisplay: 'simple_tree',
+ conversationOtherRepliesButton: 'below',
maxDepthInThread: 6,
// Nasty stuff
customEmoji: [],
customEmojiFetched: false,
emoji: [],
emojiFetched: false,
pleromaBackend: true,
postFormats: [],
restrictedNicknames: [],
safeDM: true,
knownDomains: [],
// Feature-set, apparently, not everything here is reported...
shoutAvailable: false,
pleromaChatMessagesAvailable: false,
gopherAvailable: false,
mediaProxyAvailable: false,
suggestionsEnabled: false,
suggestionsWeb: '',
// Html stuff
instanceSpecificPanelContent: '',
tos: '',
// Version Information
backendVersion: '',
frontendVersion: '',
pollsAvailable: false,
pollLimits: {
max_options: 4,
max_option_chars: 255,
min_expiration: 60,
max_expiration: 60 * 60 * 24
}
}
const instance = {
state: defaultState,
mutations: {
setInstanceOption (state, { name, value }) {
if (typeof value !== 'undefined') {
set(state, name, value)
}
},
setKnownDomains (state, domains) {
state.knownDomains = domains
}
},
getters: {
instanceDefaultConfig (state) {
return instanceDefaultProperties
.map(key => [key, state[key]])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
},
instanceDomain (state) {
return new URL(state.server).hostname
}
},
actions: {
setInstanceOption ({ commit, dispatch }, { name, value }) {
commit('setInstanceOption', { name, value })
switch (name) {
case 'name':
dispatch('setPageTitle')
break
case 'shoutAvailable':
if (value) {
dispatch('initializeSocket')
}
break
case 'theme':
dispatch('setTheme', value)
break
}
},
async getStaticEmoji ({ commit }) {
try {
const res = await window.fetch('/static/emoji.json')
if (res.ok) {
const values = await res.json()
const emoji = Object.keys(values).map((key) => {
return {
displayText: key,
imageUrl: false,
replacement: values[key]
}
}).sort((a, b) => a.name > b.name ? 1 : -1)
commit('setInstanceOption', { name: 'emoji', value: emoji })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load static emoji")
console.warn(e)
}
},
async getCustomEmoji ({ commit, state }) {
try {
const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result
const emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = value.image_url
return {
displayText: key,
imageUrl: imageUrl ? state.server + imageUrl : value,
tags: imageUrl ? value.tags.sort((a, b) => a > b ? 1 : 0) : ['utf'],
replacement: `:${key}: `
}
// Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful
}).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : -1)
commit('setInstanceOption', { name: 'customEmoji', value: emoji })
} else {
throw (res)
}
} catch (e) {
console.warn("Can't load custom emojis")
console.warn(e)
}
},
setTheme ({ commit, rootState }, themeName) {
commit('setInstanceOption', { name: 'theme', value: themeName })
getPreset(themeName)
.then(themeData => {
commit('setInstanceOption', { name: 'themeData', value: themeData })
// No need to apply theme if there's user theme already
const { customTheme } = rootState.config
if (customTheme) return
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
applyTheme(themeSource)
} else {
applyTheme(themeData.theme)
}
})
},
fetchEmoji ({ dispatch, state }) {
if (!state.customEmojiFetched) {
state.customEmojiFetched = true
dispatch('getCustomEmoji')
}
if (!state.emojiFetched) {
state.emojiFetched = true
dispatch('getStaticEmoji')
}
},
async getKnownDomains ({ commit, rootState }) {
try {
const result = await apiService.fetchKnownDomains({
credentials: rootState.users.currentUser.credentials
})
commit('setKnownDomains', result)
} catch (e) {
console.warn("Can't load known domains")
console.warn(e)
}
}
}
}
export default instance
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Nov 1, 5:43 AM (10 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1802591
Default Alt Text
(80 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline