Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into feat/report-notification

This commit is contained in:
Ilja 2022-03-20 09:53:57 +01:00
commit d0bfd9a808
57 changed files with 3745 additions and 1467 deletions

View file

@ -36,19 +36,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Attachments are truncated just like post contents
- Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading
- Enabled users to zoom and pan images in media viewer with mouse and touch
## [2.4.2] - 2022-01-09
### Added
### Added
- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
- Implemented user option to always show floating New Post button (normally mobile-only)
- Display reasons for instance specific policies
- Display reasons for instance specific policies
- Added functionality to cancel follow request
### Fixed
- Fixed link to external profile not working on user profiles
- Fixed mobile shoutbox display
- Fixed mobile shoutbox display
- Fixed favicon badge not working in Chrome
- Escape html more properly in subject/display name
- Escape html more properly in subject/display name
## [2.4.0] - 2021-08-08

View file

@ -16,107 +16,108 @@
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "^7.7.6",
"@chenfengyuan/vue-qrcode": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^2.0.0",
"body-scroll-lock": "^2.6.4",
"chromatism": "^3.0.0",
"cropperjs": "^1.4.3",
"diff": "^3.0.1",
"escape-html": "^1.0.3",
"localforage": "^1.5.0",
"parse-link-header": "^1.0.1",
"phoenix": "^1.3.0",
"portal-vue": "^2.1.4",
"punycode.js": "^2.1.0",
"ruffle-mirror": "^2021.4.10",
"v-click-outside": "^2.1.1",
"vue": "^2.6.11",
"vue-i18n": "^7.3.2",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.6.11",
"vuelidate": "^0.7.4",
"vuex": "^3.0.1"
"@babel/runtime": "7.17.7",
"@chenfengyuan/vue-qrcode": "1.0.2",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "2.0.6",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"body-scroll-lock": "2.7.1",
"chromatism": "3.0.0",
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
"localforage": "1.7.3",
"parse-link-header": "1.0.1",
"phoenix": "1.4.0",
"portal-vue": "2.1.7",
"punycode.js": "2.1.0",
"ruffle-mirror": "2021.4.11",
"v-click-outside": "2.1.5",
"vue": "2.6.11",
"vue-i18n": "7.8.1",
"vue-router": "3.0.2",
"vue-template-compiler": "2.6.11",
"vuelidate": "0.7.7",
"vuex": "3.0.1"
},
"devDependencies": {
"@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6",
"@babel/register": "^7.7.4",
"@ungap/event-target": "^0.1.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.2.1",
"@vue/babel-preset-jsx": "^1.2.4",
"@vue/test-utils": "^1.0.0-beta.26",
"autoprefixer": "^6.4.0",
"babel-eslint": "^7.0.0",
"babel-loader": "^8.0.6",
"babel-plugin-lodash": "^3.3.4",
"chai": "^3.5.0",
"chalk": "^1.1.3",
"chromedriver": "^87.0.1",
"connect-history-api-fallback": "^1.1.0",
"copy-webpack-plugin": "^6.4.1",
"cross-spawn": "^4.0.2",
"css-loader": "^0.28.0",
"custom-event-polyfill": "^1.0.7",
"eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0",
"eslint-friendly-formatter": "^2.0.5",
"eslint-loader": "^2.1.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^7.0.0",
"eslint-plugin-promise": "^4.0.0",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.2",
"eventsource-polyfill": "^0.9.6",
"express": "^4.13.3",
"file-loader": "^3.0.1",
"function-bind": "^1.0.2",
"html-webpack-plugin": "^3.0.0",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"iso-639-1": "^2.0.3",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^3.0.0",
"karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0",
"karma-mocha-reporter": "^2.2.1",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^4.0.0-rc.3",
"lodash": "^4.16.4",
"lolex": "^1.4.0",
"mini-css-extract-plugin": "^0.5.0",
"mocha": "^3.1.0",
"nightwatch": "^0.9.8",
"opn": "^4.0.2",
"ora": "^0.3.0",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1",
"sass": "^1.17.3",
"sass-loader": "git://github.com/webpack-contrib/sass-loader",
"@babel/core": "7.17.7",
"@babel/plugin-transform-runtime": "7.17.0",
"@babel/preset-env": "7.16.11",
"@babel/register": "7.17.7",
"@ungap/event-target": "0.2.3",
"@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
"@vue/babel-preset-jsx": "1.2.4",
"@vue/test-utils": "1.0.0-beta.28",
"autoprefixer": "6.7.7",
"babel-eslint": "7.2.3",
"babel-loader": "8.2.3",
"babel-plugin-lodash": "3.3.4",
"chai": "3.5.0",
"chalk": "1.1.3",
"chromedriver": "87.0.7",
"connect-history-api-fallback": "1.6.0",
"copy-webpack-plugin": "6.4.1",
"cross-spawn": "4.0.2",
"css-loader": "0.28.11",
"custom-event-polyfill": "1.0.7",
"eslint": "5.16.0",
"eslint-config-standard": "12.0.0",
"eslint-friendly-formatter": "2.0.7",
"eslint-loader": "2.1.2",
"eslint-plugin-import": "2.17.2",
"eslint-plugin-node": "7.0.1",
"eslint-plugin-promise": "4.1.1",
"eslint-plugin-standard": "4.0.0",
"eslint-plugin-vue": "5.2.3",
"eventsource-polyfill": "0.9.6",
"express": "4.16.4",
"file-loader": "3.0.1",
"function-bind": "1.1.1",
"html-webpack-plugin": "3.2.0",
"http-proxy-middleware": "0.17.4",
"inject-loader": "2.0.1",
"iso-639-1": "2.0.3",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7",
"karma": "3.1.4",
"karma-coverage": "1.1.2",
"karma-firefox-launcher": "1.1.0",
"karma-mocha": "1.3.0",
"karma-mocha-reporter": "2.2.5",
"karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.33",
"karma-webpack": "4.0.2",
"lodash": "4.17.21",
"lolex": "1.6.0",
"mini-css-extract-plugin": "0.5.0",
"mocha": "3.5.3",
"nightwatch": "0.9.21",
"opn": "4.0.2",
"ora": "0.3.0",
"postcss-loader": "3.0.0",
"raw-loader": "0.5.1",
"sass": "1.20.1",
"sass-loader": "7.2.0",
"selenium-server": "2.53.1",
"semver": "^5.3.0",
"serviceworker-webpack-plugin": "^1.0.0",
"shelljs": "^0.8.4",
"sinon": "^2.1.0",
"sinon-chai": "^2.8.0",
"stylelint": "^13.6.1",
"stylelint-config-standard": "^20.0.0",
"stylelint-rscss": "^0.4.0",
"url-loader": "^1.1.2",
"vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0",
"webpack": "^4.44.0",
"webpack-dev-middleware": "^3.6.0",
"webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1"
"semver": "5.6.0",
"serviceworker-webpack-plugin": "1.0.1",
"shelljs": "0.8.5",
"sinon": "2.4.1",
"sinon-chai": "2.14.0",
"stylelint": "13.6.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
"url-loader": "1.1.2",
"vue-loader": "14.2.4",
"vue-style-loader": "4.1.2",
"webpack": "4.46.0",
"webpack-dev-middleware": "3.7.3",
"webpack-hot-middleware": "2.24.3",
"webpack-merge": "0.14.1"
},
"engines": {
"node": ">= 4.0.0",

6
renovate.json Normal file
View file

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}

View file

@ -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;

View file

@ -115,6 +115,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')

View file

@ -12,6 +12,7 @@
:href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent
>
<FAIcon :icon="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}

View file

@ -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 = {}
}
}
}

View file

@ -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>

View file

@ -1,32 +1,45 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
import SwipeClick from '../swipe_click/swipe_click.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import Flash from 'src/components/flash/flash.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
faChevronRight,
faCircleNotch
faCircleNotch,
faTimes
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
faChevronRight,
faCircleNotch
faCircleNotch,
faTimes
)
const MediaModal = {
components: {
StillImage,
VideoAttachment,
PinchZoom,
SwipeClick,
Modal,
Flash
},
data () {
return {
loading: false
loading: false,
swipeDirection: GestureService.DIRECTION_LEFT,
swipeThreshold: () => {
const considerableMoveRatio = 1 / 4
return window.innerWidth * considerableMoveRatio
},
pinchZoomMinScale: 1,
pinchZoomScaleResetLimit: 1.2
}
},
computed: {
@ -52,32 +65,26 @@ const MediaModal = {
return this.currentMedia ? this.getType(this.currentMedia) : null
}
},
created () {
this.mediaSwipeGestureRight = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
this.goPrev,
50
)
this.mediaSwipeGestureLeft = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.goNext,
50
)
},
methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
},
mediaTouchStart (e) {
GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
},
mediaTouchMove (e) {
GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
},
hide () {
this.$store.dispatch('closeMediaViewer')
// HACK: Closing immediately via a touch will cause the click
// to be processed on the content below the overlay
const transitionTime = 100 // ms
setTimeout(() => {
this.$store.dispatch('closeMediaViewer')
}, transitionTime)
},
hideIfNotSwiped (event) {
// If we have swiped over SwipeClick, do not trigger hide
const comp = this.$refs.swipeClick
if (!comp) {
this.hide()
} else {
comp.$gesture.click(event)
}
},
goPrev () {
if (this.canNavigate) {
@ -102,6 +109,17 @@ const MediaModal = {
onImageLoaded () {
this.loading = false
},
handleSwipePreview (offsets) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
},
handleSwipeEnd (sign) {
this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
if (sign > 0) {
this.goNext()
} else if (sign < 0) {
this.goPrev()
}
},
handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape
this.hide()

View file

@ -2,20 +2,38 @@
<Modal
v-if="showing"
class="media-modal-view"
@backdropClicked="hide"
@backdropClicked="hideIfNotSwiped"
>
<img
<SwipeClick
v-if="type === 'image'"
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
@load="onImageLoaded"
ref="swipeClick"
class="modal-image-container"
:direction="swipeDirection"
:threshold="swipeThreshold"
@preview-requested="handleSwipePreview"
@swipe-finished="handleSwipeEnd"
@swipeless-clicked="hide"
>
<PinchZoom
ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:allow-pan-min-scale="pinchZoomMinScale"
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<img
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
</SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
@ -40,25 +58,36 @@
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button-arrow modal-view-button-arrow--prev"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="arrow-icon"
class="button-icon arrow-icon"
icon="chevron-left"
/>
</button>
<button
v-if="canNavigate"
:title="$t('media_modal.next')"
class="modal-view-button-arrow modal-view-button-arrow--next"
class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
@click.stop.prevent="goNext"
>
<FAIcon
class="arrow-icon"
class="button-icon arrow-icon"
icon="chevron-right"
/>
</button>
<button
class="modal-view-button modal-view-button-hide"
:title="$t('media_modal.hide')"
@click.stop.prevent="hide"
>
<FAIcon
class="button-icon"
icon="times"
/>
</button>
<span
v-if="description"
class="description"
@ -86,11 +115,17 @@
<script src="./media_modal.js"></script>
<style lang="scss">
$modal-view-button-icon-height: 3em;
$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
$modal-view-button-icon-width: 3em;
$modal-view-button-icon-margin: 0.5em;
.modal-view.media-modal-view {
z-index: 1001;
flex-direction: column;
.modal-view-button-arrow {
.modal-view-button-arrow,
.modal-view-button-hide {
opacity: 0.75;
&:focus,
@ -103,6 +138,7 @@
opacity: 1;
}
}
overflow: hidden;
}
.media-modal-view {
@ -115,6 +151,29 @@
}
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
flex-grow: 1;
justify-content: center;
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
@ -134,9 +193,8 @@
}
.modal-image {
max-width: 90%;
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
max-width: 100%;
max-height: 100%;
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
@ -159,13 +217,7 @@
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
.modal-view-button {
border: 0;
padding: 0;
opacity: 0;
@ -175,14 +227,33 @@
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
.button-icon {
position: absolute;
height: $modal-view-button-icon-height;
width: $modal-view-button-icon-width;
font-size: 14px;
line-height: $modal-view-button-icon-height;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: $modal-view-button-icon-half-height;
width: $modal-view-button-icon-width;
height: $modal-view-button-icon-height;
.arrow-icon {
position: absolute;
top: 35px;
height: 30px;
width: 32px;
font-size: 14px;
line-height: 30px;
top: 0;
line-height: $modal-view-button-icon-height;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
@ -191,16 +262,26 @@
&--prev {
left: 0;
.arrow-icon {
left: 6px;
left: $modal-view-button-icon-margin;
}
}
&--next {
right: 0;
.arrow-icon {
right: 6px;
right: $modal-view-button-icon-margin;
}
}
}
.modal-view-button-hide {
position: absolute;
top: 0;
right: 0;
.button-icon {
top: $modal-view-button-icon-margin;
right: $modal-view-button-icon-margin;
}
}
}
</style>

View file

@ -65,7 +65,6 @@
color: var(--link);
opacity: 0.8;
display: inline-block;
height: 50%;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;

View file

@ -0,0 +1,13 @@
import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
export default {
methods: {
setTransform ({ scale, x, y }) {
this.$el.setTransform({ scale, x, y })
}
},
created () {
// Make lint happy
(() => PinchZoom)()
}
}

View file

@ -0,0 +1,11 @@
<template>
<pinch-zoom
class="pinch-zoom-parent"
v-bind="$attrs"
v-on="$listeners"
>
<slot />
</pinch-zoom>
</template>
<script src="./pinch_zoom.js"></script>

View file

@ -51,6 +51,7 @@
bottom: 0;
right: 5px;
height: 100%;
width: 0.875em;
color: $fallback--text;
color: var(--inputText, $fallback--text);
line-height: 28px;

View file

@ -1,14 +1,17 @@
import { get, set } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ModifiedIndicator from './modified_indicator.vue'
import ServerSideIndicator from './server_side_indicator.vue'
export default {
components: {
Checkbox,
ModifiedIndicator
ModifiedIndicator,
ServerSideIndicator
},
props: [
'path',
'disabled'
'disabled',
'expert'
],
computed: {
pathDefault () {
@ -26,8 +29,14 @@ export default {
defaultState () {
return get(this.$parent, this.pathDefault)
},
isServerSide () {
return this.path.startsWith('serverSide_')
},
isChanged () {
return this.state !== this.defaultState
return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
},
matchesExpertLevel () {
return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {

View file

@ -1,5 +1,6 @@
<template>
<label
v-if="matchesExpertLevel"
class="BooleanSetting"
>
<Checkbox
@ -13,8 +14,7 @@
>
<slot />
</span>
<ModifiedIndicator :changed="isChanged" />
</Checkbox>
<ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
</label>
</template>

View file

@ -1,15 +1,18 @@
import { get, set } from 'lodash'
import Select from 'src/components/select/select.vue'
import ModifiedIndicator from './modified_indicator.vue'
import ServerSideIndicator from './server_side_indicator.vue'
export default {
components: {
Select,
ModifiedIndicator
ModifiedIndicator,
ServerSideIndicator
},
props: [
'path',
'disabled',
'options'
'options',
'expert'
],
computed: {
pathDefault () {
@ -27,8 +30,14 @@ export default {
defaultState () {
return get(this.$parent, this.pathDefault)
},
isServerSide () {
return this.path.startsWith('serverSide_')
},
isChanged () {
return this.state !== this.defaultState
return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
},
matchesExpertLevel () {
return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {

View file

@ -1,5 +1,6 @@
<template>
<label
v-if="matchesExpertLevel"
class="ChoiceSetting"
>
<slot />
@ -18,6 +19,7 @@
</option>
</Select>
<ModifiedIndicator :changed="isChanged" />
<ServerSideIndicator :server-side="isServerSide" />
</label>
</template>

View file

@ -0,0 +1,41 @@
import { get, set } from 'lodash'
import ModifiedIndicator from './modified_indicator.vue'
export default {
components: {
ModifiedIndicator
},
props: {
path: String,
disabled: Boolean,
min: Number,
expert: 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
},
matchesExpertLevel () {
return (this.expert || 0) <= this.$parent.expertLevel
}
},
methods: {
update (e) {
set(this.$parent, this.path, parseInt(e.target.value))
}
}
}

View file

@ -0,0 +1,23 @@
<template>
<span
v-if="matchesExpertLevel"
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>

View file

@ -0,0 +1,51 @@
<template>
<span
v-if="serverSide"
class="ServerSideIndicator"
>
<Popover
trigger="hover"
>
<template v-slot:trigger>
&nbsp;
<FAIcon
icon="server"
:aria-label="$t('settings.setting_server_side')"
/>
</template>
<template v-slot:content>
<div class="serverside-tooltip">
{{ $t('settings.setting_server_side') }}
</div>
</template>
</Popover>
</span>
</template>
<script>
import Popover from 'src/components/popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faServer } from '@fortawesome/free-solid-svg-icons'
library.add(
faServer
)
export default {
components: { Popover },
props: ['serverSide']
}
</script>
<style lang="scss">
.ServerSideIndicator {
display: inline-block;
position: relative;
.serverside-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
}
</style>

View file

@ -1,4 +1,5 @@
import { defaultState as configDefaultState } from 'src/modules/config.js'
import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js'
const SharedComputedObject = () => ({
user () {
@ -22,6 +23,14 @@ const SharedComputedObject = () => ({
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...Object.keys(serverSideConfigDefaultState)
.map(key => ['serverSide_' + key, {
get () { return this.$store.state.serverSideConfig[key] },
set (value) {
this.$store.dispatch('setServerSideOption', { name: key, value })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values or perform actions first)
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },

View file

@ -3,6 +3,7 @@ import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
import Popover from '../popover/popover.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { cloneDeep } from 'lodash'
import {
@ -51,6 +52,7 @@ const SettingsModal = {
components: {
Modal,
Popover,
Checkbox,
SettingsModalContent: getResettableAsyncComponent(
() => import('./settings_modal_content.vue'),
{
@ -159,6 +161,15 @@ const SettingsModal = {
},
modalPeeked () {
return this.$store.state.interface.settingsModalState === 'minimized'
},
expertLevel: {
get () {
return this.$store.state.config.expertLevel > 0
},
set (value) {
console.log(value)
this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 })
}
}
}
}

View file

@ -48,4 +48,11 @@
}
}
}
.settings-footer {
display: flex;
>* {
margin-right: 0.5em;
}
}
}

View file

@ -53,7 +53,7 @@
<div class="panel-body">
<SettingsModalContent v-if="modalOpenedOnce" />
</div>
<div class="panel-footer">
<div class="panel-footer settings-footer">
<Popover
class="export"
trigger="click"
@ -108,6 +108,10 @@
</div>
</template>
</Popover>
<Checkbox v-model="expertLevel">
{{ $t("settings.expert_mode") }}
</Checkbox>
</div>
</div>
</Modal>

View file

@ -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(),

View file

@ -21,6 +21,7 @@
</li>
<li>
<BooleanSetting
v-if="user"
:disabled="hideFilteredStatuses"
path="hideMutedThreads"
>
@ -29,6 +30,7 @@
</li>
<li>
<BooleanSetting
v-if="user"
:disabled="hideFilteredStatuses"
path="hideMutedPosts"
>
@ -37,12 +39,23 @@
</li>
</ul>
</li>
<li>
<BooleanSetting path="muteBotStatuses">
{{ $t('settings.mute_bot_posts') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hidePostStats">
{{ $t('settings.hide_post_stats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="hideBotIndication">
{{ $t('settings.hide_bot_indication') }}
</BooleanSetting>
</li>
<ChoiceSetting
v-if="user"
id="replyVisibility"
path="replyVisibility"
:options="replyVisibilityOptions"
@ -59,7 +72,7 @@
<div>{{ $t('settings.filtering_explanation') }}</div>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<li v-if="expertLevel > 0">
<label for="maxThumbnails">
{{ $t('settings.max_thumbnails') }}
</label>
@ -72,6 +85,14 @@
step="1"
>
</li>
<li>
<IntegerSetting
path="maxThumbnails"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">
{{ $t('settings.hide_attachments_in_tl') }}
@ -84,7 +105,10 @@
</li>
</ul>
</div>
<div class="setting-item">
<div
v-if="expertLevel > 0"
class="setting-item"
>
<h2>{{ $t('settings.user_profiles') }}</h2>
<ul class="setting-list">
<li>
@ -94,46 +118,6 @@
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notifications') }}</h2>
<ul class="setting-list">
<li class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<BooleanSetting path="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script src="./filtering_tab.js"></script>

View file

@ -1,8 +1,11 @@
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.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'
import ServerSideIndicator from '../helpers/server_side_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
@ -20,6 +23,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,7 +50,10 @@ const GeneralTab = {
components: {
BooleanSetting,
ChoiceSetting,
InterfaceLanguageSwitcher
IntegerSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
ServerSideIndicator
},
computed: {
postFormats () {
@ -57,6 +73,11 @@ const GeneralTab = {
},
instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
...SharedComputedObject()
},
methods: {
changeDefaultScope (value) {
this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value })
}
}
}

View file

@ -45,26 +45,42 @@
</ul>
</li>
<li>
<BooleanSetting path="useStreamingApi">
<BooleanSetting
path="useStreamingApi"
expert="1"
>
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</BooleanSetting>
</li>
<li>
<BooleanSetting path="virtualScrolling">
<BooleanSetting
path="virtualScrolling"
expert="1"
>
{{ $t('settings.virtual_scrolling') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
<BooleanSetting
path="alwaysShowNewPostButton"
expert="1"
>
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="autohideFloatingPostButton"
expert="1"
>
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li v-if="instanceShoutboxPresent">
<BooleanSetting path="hideShoutbox">
<BooleanSetting
path="hideShoutbox"
expert="1"
>
{{ $t('settings.hide_shoutbox') }}
</BooleanSetting>
</li>
@ -73,19 +89,80 @@
<div class="setting-item">
<h2>{{ $t('settings.post_look_feel') }}</h2>
<ul class="setting-list">
<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"
:expert="1"
>
{{ $t('settings.tree_fade_ancestors') }}
</BooleanSetting>
</li>
<li>
<IntegerSetting
path="maxDepthInThread"
:min="3"
:expert="1"
>
{{ $t('settings.max_depth_in_thread') }}
</IntegerSetting>
</li>
<li>
<ChoiceSetting
id="conversationOtherRepliesButton"
path="conversationOtherRepliesButton"
:options="conversationOtherRepliesButtonOptions"
:expert="1"
>
{{ $t('settings.conversation_other_replies_button') }}
</ChoiceSetting>
</li>
</ul>
<li>
<BooleanSetting path="collapseMessageWithSubject">
{{ $t('settings.collapse_subject') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="emojiReactionsOnTimeline">
<BooleanSetting
path="emojiReactionsOnTimeline"
expert="1"
>
{{ $t('settings.emoji_reactions_on_timeline') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
v-if="user"
path="serverSide_stripRichContent"
expert="1"
>
{{ $t('settings.no_rich_text_description') }}
</BooleanSetting>
</li>
<h3>{{ $t('settings.attachments') }}</h3>
<li>
<BooleanSetting path="useContainFit">
<BooleanSetting
path="useContainFit"
expert="1"
>
{{ $t('settings.use_contain_fit') }}
</BooleanSetting>
</li>
@ -98,6 +175,7 @@
<li>
<BooleanSetting
path="preloadImage"
expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.preload_images') }}
@ -106,6 +184,7 @@
<li>
<BooleanSetting
path="useOneClickNsfw"
expert="1"
:disabled="!hideNsfw"
>
{{ $t('settings.use_one_click_nsfw') }}
@ -113,7 +192,10 @@
</li>
</ul>
<li>
<BooleanSetting path="loopVideo">
<BooleanSetting
path="loopVideo"
expert="1"
>
{{ $t('settings.loop_video') }}
</BooleanSetting>
<ul
@ -123,6 +205,7 @@
<li>
<BooleanSetting
path="loopVideoSilentOnly"
expert="1"
:disabled="!loopVideo || !loopSilentAvailable"
>
{{ $t('settings.loop_video_silent_only') }}
@ -137,21 +220,14 @@
</ul>
</li>
<li>
<BooleanSetting path="playVideosInModal">
<BooleanSetting
path="playVideosInModal"
expert="1"
>
{{ $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>
<h3>{{ $t('settings.mention_links') }}</h3>
<li>
<ChoiceSetting
id="mentionLinkDisplay"
@ -164,47 +240,103 @@
<ul
class="setting-list suboptions"
>
<li
v-if="mentionLinkDisplay === 'short'"
>
<BooleanSetting path="mentionLinkShowTooltip">
<li v-if="mentionLinkDisplay === 'short'">
<BooleanSetting
path="mentionLinkShowTooltip"
expert="1"
>
{{ $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>
<li>
<BooleanSetting
path="useAtIcon"
expert="1"
>
{{ $t('settings.use_at_icon') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="mentionLinkShowAvatar">
{{ $t('settings.mention_link_show_avatar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="mentionLinkFadeDomain"
expert="1"
>
{{ $t('settings.mention_link_fade_domain') }}
</BooleanSetting>
</li>
<li v-if="user">
<BooleanSetting
path="mentionLinkBoldenYou"
expert="1"
>
{{ $t('settings.mention_link_bolden_you') }}
</BooleanSetting>
</li>
<h3 v-if="expertLevel > 0">
{{ $t('settings.fun') }}
</h3>
<li>
<BooleanSetting
path="greentext"
expert="1"
>
{{ $t('settings.greentext') }}
</BooleanSetting>
</li>
<li v-if="user">
<BooleanSetting
path="mentionLinkShowYous"
expert="1"
>
{{ $t('settings.show_yous') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<div
v-if="user"
class="setting-item"
>
<h2>{{ $t('settings.composing') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="scopeCopy">
<label for="default-vis">
{{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" />
<ScopeSelector
class="scope-selector"
:show-all="true"
:user-default="serverSide_defaultScope"
:initial-scope="serverSide_defaultScope"
:on-scope-change="changeDefaultScope"
/>
</label>
</li>
<li>
<!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="scopeCopy"
expert="1"
>
{{ $t('settings.scope_copy') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowSubjectInput">
<BooleanSetting
path="alwaysShowSubjectInput"
expert="1"
>
{{ $t('settings.subject_input_always_show') }}
</BooleanSetting>
</li>
@ -213,6 +345,7 @@
id="subjectLineBehavior"
path="subjectLineBehavior"
:options="subjectLineOptions"
expert="1"
>
{{ $t('settings.subject_line_behavior') }}
</ChoiceSetting>
@ -227,43 +360,39 @@
</ChoiceSetting>
</li>
<li>
<BooleanSetting path="minimalScopesMode">
<BooleanSetting
path="minimalScopesMode"
expert="1"
>
{{ $t('settings.minimal_scopes_mode') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowNewPostButton">
<BooleanSetting
path="alwaysShowNewPostButton"
expert="1"
>
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
<BooleanSetting
path="autohideFloatingPostButton"
expert="1"
>
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="padEmoji">
<BooleanSetting
path="padEmoji"
expert="1"
>
{{ $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>

View file

@ -1,4 +1,5 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const NotificationsTab = {
data () {
@ -9,12 +10,13 @@ const NotificationsTab = {
}
},
components: {
Checkbox
BooleanSetting
},
computed: {
user () {
return this.$store.state.users.currentUser
}
},
...SharedComputedObject()
},
methods: {
updateNotificationSettings () {

View file

@ -2,30 +2,77 @@
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
<p>
<Checkbox v-model="notificationSettings.block_from_strangers">
{{ $t('settings.notification_setting_block_from_strangers') }}
</Checkbox>
</p>
<ul class="setting-list">
<li>
<BooleanSetting path="serverSide_blockNotificationsFromStrangers">
{{ $t('settings.notification_setting_block_from_strangers') }}
</BooleanSetting>
</li>
<li class="select-multiple">
<span class="label">{{ $t('settings.notification_visibility') }}</span>
<ul class="option-list">
<li>
<BooleanSetting path="notificationVisibility.likes">
{{ $t('settings.notification_visibility_likes') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.repeats">
{{ $t('settings.notification_visibility_repeats') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.follows">
{{ $t('settings.notification_visibility_follows') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.mentions">
{{ $t('settings.notification_visibility_mentions') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="notificationVisibility.emojiReactions">
{{ $t('settings.notification_visibility_emoji_reactions') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
<div class="setting-item">
<div
v-if="expertLevel > 0"
class="setting-item"
>
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
<p>
<Checkbox v-model="notificationSettings.hide_notification_contents">
{{ $t('settings.notification_setting_hide_notification_contents') }}
</Checkbox>
</p>
<ul class="setting-list">
<li>
<BooleanSetting
path="webPushNotifications"
expert="1"
>
{{ $t('settings.enable_web_push_notifications') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting
path="serverSide_webPushHideContents"
expert="1"
>
{{ $t('settings.notification_setting_hide_notification_contents') }}
</BooleanSetting>
</li>
</ul>
</div>
<div class="setting-item">
<p>{{ $t('settings.notification_mutes') }}</p>
<p>{{ $t('settings.notification_blocks') }}</p>
<button
class="btn button-default"
@click="updateNotificationSettings"
>
{{ $t('settings.save') }}
</button>
</div>
</div>
</template>

View file

@ -8,6 +8,9 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faTimes,
@ -27,18 +30,10 @@ const ProfileTab = {
newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
@ -54,12 +49,14 @@ const ProfileTab = {
EmojiInput,
Autosuggest,
ProgressButton,
Checkbox
Checkbox,
BooleanSetting
},
computed: {
user () {
return this.$store.state.users.currentUser
},
...SharedComputedObject(),
emojiUserSuggestor () {
return suggestor({
emoji: [
@ -123,15 +120,7 @@ const ProfileTab = {
/* eslint-disable camelcase */
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
bot: this.bot,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {

View file

@ -25,61 +25,6 @@
class="bio resize-height"
/>
</EmojiInput>
<p>
<Checkbox v-model="newLocked">
{{ $t('settings.lock_account_description') }}
</Checkbox>
</p>
<div>
<label for="default-vis">{{ $t('settings.default_vis') }}</label>
<div
id="default-vis"
class="visibility-tray"
>
<scope-selector
:show-all="true"
:user-default="newDefaultScope"
:initial-scope="newDefaultScope"
:on-scope-change="changeVis"
/>
</div>
</div>
<p>
<Checkbox v-model="newNoRichText">
{{ $t('settings.no_rich_text_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollows">
{{ $t('settings.hide_follows_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowsCount"
:disabled="!hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="hideFollowers">
{{ $t('settings.hide_followers_description') }}
</Checkbox>
</p>
<p class="setting-subitem">
<Checkbox
v-model="hideFollowersCount"
:disabled="!hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</Checkbox>
</p>
<p>
<Checkbox v-model="allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</Checkbox>
</p>
<p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole">
<template v-if="role === 'admin'">
@ -90,11 +35,6 @@
</template>
</Checkbox>
</p>
<p>
<Checkbox v-model="discoverable">
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
@ -269,6 +209,67 @@
{{ $t('settings.save') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.account_privacy') }}</h2>
<ul class="setting-list">
<li>
<BooleanSetting path="serverSide_locked">
{{ $t('settings.lock_account_description') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="serverSide_discoverable">
{{ $t('settings.discoverable') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="serverSide_allowFollowingMove">
{{ $t('settings.allow_following_move') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="serverSide_hideFavorites">
{{ $t('settings.hide_favorites_description') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="serverSide_hideFollowers">
{{ $t('settings.hide_followers_description') }}
</BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !serverSide_hideFollowers}]"
>
<li>
<BooleanSetting
path="serverSide_hideFollowersCount"
:disabled="!serverSide_hideFollowers"
>
{{ $t('settings.hide_followers_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
<li>
<BooleanSetting path="serverSide_hideFollows">
{{ $t('settings.hide_follows_description') }}
</BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !serverSide_hideFollows}]"
>
<li>
<BooleanSetting
path="serverSide_hideFollowsCount"
:disabled="!serverSide_hideFollows"
>
{{ $t('settings.hide_follows_count_description') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>

View file

@ -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
},
@ -166,6 +225,12 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
botStatus () {
return this.status.user.bot
},
botIndicator () {
return this.botStatus && !this.hideBotIndication
},
mentionsLine () {
if (!this.headTailLinks) return []
const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
@ -191,7 +256,9 @@ const Status = {
// Thread is muted
status.thread_muted ||
// Wordfiltered
this.muteWordHits.length > 0
this.muteWordHits.length > 0 ||
// bot status
(this.muteBotStatuses && this.botStatus && !this.compact)
return !this.unmuted && !this.shouldNotMute && reasonsToMute
},
userIsMuted () {
@ -293,6 +360,12 @@ const Status = {
hidePostStats () {
return this.mergedConfig.hidePostStats
},
muteBotStatuses () {
return this.mergedConfig.muteBotStatuses
},
hideBotIndication () {
return this.mergedConfig.hideBotIndication
},
currentUser () {
return this.$store.state.users.currentUser
},
@ -304,6 +377,12 @@ const Status = {
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest () {
return !!this.controlledThreadDisplayStatus
},
threadShowing () {
return this.controlledThreadDisplayStatus === 'showing'
}
},
methods: {
@ -326,7 +405,7 @@ const Status = {
this.error = undefined
},
toggleReplying () {
this.replying = !this.replying
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
if (this.inConversation) {
@ -346,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) {
@ -370,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

View file

@ -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 {

View file

@ -77,6 +77,7 @@
<UserAvatar
v-if="retweet"
class="left-side repeater-avatar"
:bot="botIndicator"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
@ -124,6 +125,7 @@
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar
:bot="botIndicator"
:compact="compact"
:better-shadow="betterShadow"
:user="status.user"
@ -219,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
@ -306,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"
@ -315,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"
@ -407,7 +453,10 @@
class="gravestone"
>
<div class="left-side">
<UserAvatar :compact="compact" />
<UserAvatar
:compact="compact"
:bot="botIndicator"
/>
</div>
<div class="right-side">
<div class="deleted-text">

View file

@ -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) {

View file

@ -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>

View file

@ -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)
}
}
}

View file

@ -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;

View file

@ -19,6 +19,7 @@
@load="onLoad"
@error="onError"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,84 @@
import GestureService from '../../services/gesture_service/gesture_service'
/**
* props:
* direction: a vector that indicates the direction of the intended swipe
* threshold: the minimum distance in pixels the swipe has moved on `direction'
* for swipe-finished() to have a non-zero sign
* perpendicularTolerance: see gesture_service
*
* Events:
* preview-requested(offsets)
* Emitted when the pointer has moved.
* offsets: the offsets from the start of the swipe to the current cursor position
*
* swipe-canceled()
* Emitted when the swipe has been canceled due to a pointercancel event.
*
* swipe-finished(sign: 0|-1|1)
* Emitted when the swipe has finished.
* sign: if the swipe does not meet the threshold, 0
* if the swipe meets the threshold in the positive direction, 1
* if the swipe meets the threshold in the negative direction, -1
*
* swipeless-clicked()
* Emitted when there is a click without swipe.
* This and swipe-finished() cannot be emitted for the same pointerup event.
*/
const SwipeClick = {
props: {
direction: {
type: Array
},
threshold: {
type: Function,
default: () => 30
},
perpendicularTolerance: {
type: Number,
default: 1.0
}
},
methods: {
handlePointerDown (event) {
this.$gesture.start(event)
},
handlePointerMove (event) {
this.$gesture.move(event)
},
handlePointerUp (event) {
this.$gesture.end(event)
},
handlePointerCancel (event) {
this.$gesture.cancel(event)
},
handleNativeClick (event) {
this.$gesture.click(event)
},
preview (offsets) {
this.$emit('preview-requested', offsets)
},
end (sign) {
this.$emit('swipe-finished', sign)
},
click () {
this.$emit('swipeless-clicked')
},
cancel () {
this.$emit('swipe-canceled')
}
},
created () {
this.$gesture = new GestureService.SwipeAndClickGesture({
direction: this.direction,
threshold: this.threshold,
perpendicularTolerance: this.perpendicularTolerance,
swipePreviewCallback: this.preview,
swipeEndCallback: this.end,
swipeCancelCallback: this.cancel,
swipelessClickCallback: this.click
})
}
}
export default SwipeClick

View file

@ -0,0 +1,14 @@
<template>
<div
v-bind="$attrs"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerCancel"
@click="handleNativeClick"
>
<slot />
</div>
</template>
<script src="./swipe_click.js"></script>

View 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

View 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>

View file

@ -53,6 +53,13 @@ const TimelineQuickSettings = {
const value = !this.hideMutedPosts
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
}
},
muteBotStatuses: {
get () { return this.mergedConfig.muteBotStatuses },
set () {
const value = !this.muteBotStatuses
this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
}
}
}
}

View file

@ -39,6 +39,15 @@
class="dropdown-divider"
/>
</div>
<button
class="button-default dropdown-item"
@click="muteBotStatuses = !muteBotStatuses"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': muteBotStatuses }"
/>{{ $t('settings.mute_bot_posts') }}
</button>
<button
class="button-default dropdown-item"
@click="hideMedia = !hideMedia"

View file

@ -1,10 +1,21 @@
import StillImage from '../still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faRobot
} from '@fortawesome/free-solid-svg-icons'
library.add(
faRobot
)
const UserAvatar = {
props: [
'user',
'betterShadow',
'compact'
'compact',
'bot'
],
data () {
return {

View file

@ -7,7 +7,13 @@
:src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
/>
>
<FAIcon
v-if="bot"
icon="robot"
class="bot-indicator"
/>
</StillImage>
<div
v-else
class="Avatar -placeholder"
@ -36,6 +42,12 @@
height: 100%;
}
& > .bot-indicator {
position: absolute;
bottom: 0;
right: 0;
}
&.better-shadow {
box-shadow: var(--_avatarShadowInset);
filter: var(--_avatarShadowFilter);

View file

@ -120,7 +120,8 @@
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}"
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
@ -273,11 +274,14 @@
},
"settings": {
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"security": "Security",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"mention_links": "Mention links",
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
@ -365,6 +369,8 @@
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_bot_indication": "Hide bot indication in posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
@ -414,6 +420,7 @@
"name": "Label",
"value": "Content"
},
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
@ -431,6 +438,7 @@
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
@ -444,7 +452,7 @@
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause streaming when tab is not focused",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
@ -479,13 +487,21 @@
"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",
"streaming": "Enable automatic streaming of new posts when scrolled to the top",
"streaming": "Automatically show new posts when scrolled to the top",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)",
"text": "Text",
"theme": "Theme",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
@ -735,6 +751,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",
@ -761,7 +778,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",

View file

@ -11,6 +11,7 @@ import statusesModule from './modules/statuses.js'
import usersModule from './modules/users.js'
import apiModule from './modules/api.js'
import configModule from './modules/config.js'
import serverSideConfigModule from './modules/serverSideConfig.js'
import shoutModule from './modules/shout.js'
import oauthModule from './modules/oauth.js'
import authFlowModule from './modules/auth_flow.js'
@ -45,6 +46,8 @@ Vue.use(VueClickOutside)
Vue.use(PortalVue)
Vue.use(VBodyScrollLock)
Vue.config.ignoredElements = ['pinch-zoom']
Vue.component('FAIcon', FontAwesomeIcon)
Vue.component('FALayers', FontAwesomeLayers)
@ -88,6 +91,7 @@ const persistedStateOptions = {
users: usersModule,
api: apiModule,
config: configModule,
serverSideConfig: serverSideConfigModule,
shout: shoutModule,
oauth: oauthModule,
authFlow: authFlowModule,

View file

@ -12,10 +12,13 @@ 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
]
export const defaultState = {
expertLevel: 0, // used to track which settings to show and hide
colors: {},
theme: undefined,
customTheme: undefined,
@ -27,6 +30,7 @@ export const defaultState = {
hideMutedPosts: undefined, // instance default
hideMutedThreads: undefined, // instance default
hideWordFilteredPosts: undefined, // instance default
muteBotStatuses: undefined, // instance default
collapseMessageWithSubject: undefined, // instance default
padEmoji: true,
hideAttachments: false,
@ -41,7 +45,7 @@ export const defaultState = {
alwaysShowNewPostButton: false,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
stopGifs: false,
stopGifs: true,
replyVisibility: 'all',
notificationVisibility: {
follows: true,
@ -70,7 +74,7 @@ export const defaultState = {
hideFilteredStatuses: undefined, // instance default
playVideosInModal: false,
useOneClickNsfw: false,
useContainFit: false,
useContainFit: true,
greentext: undefined, // instance default
useAtIcon: undefined, // instance default
mentionLinkDisplay: undefined, // instance default
@ -80,9 +84,15 @@ export const defaultState = {
mentionLinkShowYous: undefined, // instance default
mentionLinkBoldenYou: undefined, // instance default
hidePostStats: undefined, // instance default
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

View file

@ -33,8 +33,10 @@ const defaultState = {
hideMutedThreads: true,
hideWordFilteredPosts: false,
hidePostStats: false,
hideBotIndication: false,
hideSitename: false,
hideUserStats: false,
muteBotStatuses: false,
loginMethod: 'password',
logo: '/static/logo.svg',
logoMargin: '.2em',
@ -53,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: [],

View file

@ -0,0 +1,137 @@
import { get, set } from 'lodash'
const defaultApi = ({ rootState, commit }, { path, value }) => {
const params = {}
set(params, path, value)
return rootState
.api
.backendInteractor
.updateProfile({ params })
.then(result => {
commit('addNewUsers', [result])
commit('setCurrentUser', result)
})
}
const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => {
const settings = {}
set(settings, path, value)
return rootState
.api
.backendInteractor
.updateNotificationSettings({ settings })
.then(result => {
if (result.status === 'success') {
commit('confirmServerSideOption', { name, value })
} else {
commit('confirmServerSideOption', { name, value: oldValue })
}
})
}
/**
* Map that stores relation between path for reading (from user profile),
* for writing (into API) an what API to use.
*
* Shorthand - instead of { get, set, api? } object it's possible to use string
* in case default api is used and get = set
*
* If no api is specified, defaultApi is used (see above)
*/
export const settingsMap = {
'defaultScope': 'source.privacy',
'defaultNSFW': 'source.sensitive', // BROKEN: pleroma/pleroma#2837
'stripRichContent': {
get: 'source.pleroma.no_rich_text',
set: 'no_rich_text'
},
// Privacy
'locked': 'locked',
'acceptChatMessages': {
get: 'pleroma.accepts_chat_messages',
set: 'accepts_chat_messages'
},
'allowFollowingMove': {
get: 'pleroma.allow_following_move',
set: 'allow_following_move'
},
'discoverable': 'source.discoverable',
'hideFavorites': {
get: 'pleroma.hide_favorites',
set: 'hide_favorites'
},
'hideFollowers': {
get: 'pleroma.hide_followers',
set: 'hide_followers'
},
'hideFollows': {
get: 'pleroma.hide_follows',
set: 'hide_follows'
},
'hideFollowersCount': {
get: 'pleroma.hide_followers_count',
set: 'hide_followers_count'
},
'hideFollowsCount': {
get: 'pleroma.hide_follows_count',
set: 'hide_follows_count'
},
// NotificationSettingsAPIs
'webPushHideContents': {
get: 'pleroma.notification_settings.hide_notification_contents',
set: 'hide_notification_contents',
api: notificationsApi
},
'blockNotificationsFromStrangers': {
get: 'pleroma.notification_settings.block_from_strangers',
set: 'block_from_strangers',
api: notificationsApi
}
}
export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null]))
const serverSideConfig = {
state: { ...defaultState },
mutations: {
confirmServerSideOption (state, { name, value }) {
set(state, name, value)
},
wipeServerSideOption (state, { name }) {
set(state, name, null)
},
wipeAllServerSideOptions (state) {
Object.keys(settingsMap).forEach(key => {
set(state, key, null)
})
},
// Set the settings based on their path location
setCurrentUser (state, user) {
Object.entries(settingsMap).forEach((map) => {
const [name, value] = map
const { get: path = value } = value
set(state, name, get(user._original, path))
})
}
},
actions: {
setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) {
const oldValue = get(state, name)
const map = settingsMap[name]
if (!map) throw new Error('Invalid server-side setting')
const { set: path = map, api = defaultApi } = map
commit('wipeServerSideOption', { name })
api({ rootState, commit }, { path, value, oldValue })
.catch((e) => {
console.warn('Error setting server-side option:', e)
commit('confirmServerSideOption', { name, value: oldValue })
})
},
logout ({ commit }) {
commit('wipeAllServerSideOptions')
}
}
}
export default serverSideConfig

View file

@ -44,6 +44,7 @@ export const parseUser = (data) => {
const mastoShort = masto && !data.hasOwnProperty('avatar')
output.id = String(data.id)
output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct

View file

@ -4,9 +4,15 @@ const DIRECTION_RIGHT = [1, 0]
const DIRECTION_UP = [0, -1]
const DIRECTION_DOWN = [0, 1]
const BUTTON_LEFT = 0
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
const touchCoord = touch => [touch.screenX, touch.screenY]
const touchEventCoord = e => touchCoord(e.touches[0])
const pointerEventCoord = e => [e.clientX, e.clientY]
const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
@ -61,6 +67,132 @@ const updateSwipe = (event, gesture) => {
gesture._swiping = false
}
class SwipeAndClickGesture {
// swipePreviewCallback(offsets: Array[Number])
// offsets: the offset vector which the underlying component should move, from the starting position
// swipeEndCallback(sign: 0|-1|1)
// sign: if the swipe does not meet the threshold, 0
// if the swipe meets the threshold in the positive direction, 1
// if the swipe meets the threshold in the negative direction, -1
constructor ({
direction,
// swipeStartCallback
swipePreviewCallback,
swipeEndCallback,
swipeCancelCallback,
swipelessClickCallback,
threshold = 30,
perpendicularTolerance = 1.0,
disableClickThreshold = 1
}) {
const nop = () => {}
this.direction = direction
this.swipePreviewCallback = swipePreviewCallback || nop
this.swipeEndCallback = swipeEndCallback || nop
this.swipeCancelCallback = swipeCancelCallback || nop
this.swipelessClickCallback = swipelessClickCallback || nop
this.threshold = typeof threshold === 'function' ? threshold : () => threshold
this.disableClickThreshold = typeof disableClickThreshold === 'function' ? disableClickThreshold : () => disableClickThreshold
this.perpendicularTolerance = perpendicularTolerance
this._reset()
}
_reset () {
this._startPos = [0, 0]
this._pointerId = -1
this._swiping = false
this._swiped = false
this._preventNextClick = false
}
start (event) {
// Only handle left click
if (event.button !== BUTTON_LEFT) {
return
}
this._startPos = pointerEventCoord(event)
this._pointerId = event.pointerId
this._swiping = true
this._swiped = false
}
move (event) {
if (this._swiping && this._pointerId === event.pointerId) {
this._swiped = true
const coord = pointerEventCoord(event)
const delta = deltaCoord(this._startPos, coord)
this.swipePreviewCallback(delta)
}
}
cancel (event) {
if (!this._swiping || this._pointerId !== event.pointerId) {
return
}
this.swipeCancelCallback()
}
end (event) {
if (!this._swiping) {
return
}
if (this._pointerId !== event.pointerId) {
return
}
this._swiping = false
// movement too small
const coord = pointerEventCoord(event)
const delta = deltaCoord(this._startPos, coord)
const sign = (() => {
if (vectorLength(delta) < this.threshold()) {
return 0
}
// movement is opposite from direction
const isPositive = dotProduct(delta, this.direction) > 0
// movement perpendicular to direction is too much
const towardsDir = project(delta, this.direction)
const perpendicularDir = perpendicular(this.direction)
const towardsPerpendicular = project(delta, perpendicularDir)
if (
vectorLength(towardsDir) * this.perpendicularTolerance <
vectorLength(towardsPerpendicular)
) {
return 0
}
return isPositive ? 1 : -1
})()
if (this._swiped) {
this.swipeEndCallback(sign)
}
this._reset()
// Only a mouse will fire click event when
// the end point is far from the starting point
// so for other kinds of pointers do not check
// whether we have swiped
if (vectorLength(delta) >= this.disableClickThreshold() && event.pointerType === 'mouse') {
this._preventNextClick = true
}
}
click (event) {
if (!this._preventNextClick) {
this.swipelessClickCallback()
}
this._reset()
}
}
const GestureService = {
DIRECTION_LEFT,
DIRECTION_RIGHT,
@ -68,7 +200,8 @@ const GestureService = {
DIRECTION_DOWN,
swipeGesture,
beginSwipe,
updateSwipe
updateSwipe,
SwipeAndClickGesture
}
export default GestureService

2373
yarn.lock

File diff suppressed because it is too large Load diff