Merge remote-tracking branch 'origin/develop' into expert-settings-and-serverside

* origin/develop: (83 commits)
  Make media modal buttons larger
  Add English translation for hide tooltip
  Add hide button to media modal
  Lint
  Prevent hiding media viewer if swiped over SwipeClick
  Fix webkit image blurs
  Fix video in media modal not displaying properly
  Add changelog for https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1403
  Remove image box-shadow in media modal
  Clean up debug code for image pinch zoom
  Bump @kazvmoe-infra/pinch-zoom-element to 1.2.0 on npm
  Bump pinch-zoom-element version
  Clean up
  Check whether we swiped only for mouse pointer
  Scale swipe threshold with viewport width
  Update pinch-zoom-element
  Allow pinch-zoom to fill the whole screen
  Use native click for hiding overlay
  Reset position on swipe end even if we cannot navigate
  Make lint happy
  ...
This commit is contained in:
Henry Jameson 2022-03-14 09:31:24 +02:00
commit a97db1efd6
32 changed files with 1620 additions and 156 deletions

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

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

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

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

@ -39,12 +39,6 @@
</li>
</ul>
</li>
<li>
<BooleanSetting
path="hidePostStats"
expert="1"
>
</li>
<li>
<BooleanSetting path="muteBotStatuses">
{{ $t('settings.mute_bot_posts') }}
@ -89,7 +83,15 @@
type="number"
min="0"
step="1"
/>
</li>
<li>
<IntegerSetting
path="maxThumbnails"
:min="0"
>
{{ $t('settings.max_thumbnails') }}
</IntegerSetting>
</li>
<li>
<BooleanSetting path="hideAttachments">

View file

@ -1,6 +1,7 @@
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'
@ -22,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,
@ -39,6 +50,7 @@ const GeneralTab = {
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
InterfaceLanguageSwitcher,
ScopeSelector,
ServerSideIndicator

View file

@ -89,6 +89,52 @@
<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') }}

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
},
@ -318,6 +377,12 @@ const Status = {
},
isSuspendable () {
return !this.replying && this.mediaPlaying.length === 0
},
inThreadForest () {
return !!this.controlledThreadDisplayStatus
},
threadShowing () {
return this.controlledThreadDisplayStatus === 'showing'
}
},
methods: {
@ -340,7 +405,7 @@ const Status = {
this.error = undefined
},
toggleReplying () {
this.replying = !this.replying
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
if (this.inConversation) {
@ -360,17 +425,19 @@ const Status = {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
addMediaPlaying (id) {
this.mediaPlaying.push(id)
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
}
},
watch: {
'highlight': function (id) {
},
toggleThreadDisplay () {
this.controlledToggleThreadDisplay()
},
scrollIfHighlighted (highlightId) {
const id = highlightId
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
@ -384,6 +451,11 @@ const Status = {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
},
watch: {
'highlight': function (id) {
this.scrollIfHighlighted(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way

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

@ -221,6 +221,31 @@
class="fa-scale-110"
/>
</button>
<button
v-if="inThreadForest && replies && replies.length && !simpleTree"
class="button-unstyled"
:title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
:aria-expanded="threadShowing ? 'true' : 'false'"
@click.prevent="toggleThreadDisplay"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="threadShowing ? 'chevron-up' : 'chevron-down'"
/>
</button>
<button
v-if="dive && !simpleTree"
class="button-unstyled"
:title="$t('status.show_only_conversation_under_this')"
@click.prevent="dive"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="'angle-double-right'"
/>
</button>
</span>
</div>
<div
@ -308,6 +333,12 @@
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
:controlled-showing-tall="controlledShowingTall"
:controlled-expanding-subject="controlledExpandingSubject"
:controlled-showing-long-subject="controlledShowingLongSubject"
:controlled-toggle-showing-tall="controlledToggleShowingTall"
:controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
@ -317,7 +348,20 @@
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<button
v-if="showOtherRepliesAsButton && replies.length > 1"
class="button-unstyled -link faint"
:title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
@click.prevent="dive"
>
{{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
</button>
<span
v-else
class="faint"
>
{{ $t('status.replies_list') }}
</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"

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

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