logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git
commit: a97db1efd65d6ba598df6d5e9cff5f3d74c666e9
parent f16f35a4d43fa1f5a13b7e8c1cde85737f5d2f32
Author: Henry Jameson <me@hjkos.com>
Date:   Mon, 14 Mar 2022 09:31:24 +0200

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

Diffstat:

MCHANGELOG.md10++++++----
Mpackage.json1+
Msrc/_variables.scss2++
Msrc/components/conversation/conversation.js374++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/components/conversation/conversation.vue247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/components/media_modal/media_modal.js68+++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/components/media_modal/media_modal.vue147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Asrc/components/pinch_zoom/pinch_zoom.js13+++++++++++++
Asrc/components/pinch_zoom/pinch_zoom.vue11+++++++++++
Asrc/components/settings_modal/helpers/integer_setting.js41+++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/helpers/integer_setting.vue23+++++++++++++++++++++++
Msrc/components/settings_modal/tabs/filtering_tab.js4+++-
Msrc/components/settings_modal/tabs/filtering_tab.vue14++++++++------
Msrc/components/settings_modal/tabs/general_tab.js12++++++++++++
Msrc/components/settings_modal/tabs/general_tab.vue46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/status/status.js96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/components/status/status.scss27+++++++++------------------
Msrc/components/status/status.vue46+++++++++++++++++++++++++++++++++++++++++++++-
Msrc/components/status_body/status_body.js16+++++++++-------
Msrc/components/status_body/status_body.vue4++--
Msrc/components/status_content/status_content.js54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/components/status_content/status_content.vue10++++++----
Asrc/components/swipe_click/swipe_click.js84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/swipe_click/swipe_click.vue14++++++++++++++
Asrc/components/thread_tree/thread_tree.js90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/thread_tree/thread_tree.vue127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/i18n/en.json26++++++++++++++++++++++++--
Msrc/main.js2++
Msrc/modules/config.js9++++++++-
Msrc/modules/instance.js5+++++
Msrc/services/gesture_service/gesture_service.js137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Myarn.lock12++++++++++++
32 files changed, 1618 insertions(+), 154 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json @@ -22,6 +22,7 @@ "@fortawesome/free-regular-svg-icons": "5.15.1", "@fortawesome/free-solid-svg-icons": "5.15.1", "@fortawesome/vue-fontawesome": "2.0.0", + "@kazvmoe-infra/pinch-zoom-element": "^1.2.0", "body-scroll-lock": "2.6.4", "chromatism": "3.0.0", "cropperjs": "1.4.3", diff --git a/src/_variables.scss b/src/_variables.scss @@ -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; diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js @@ -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,13 +70,51 @@ const conversation = { } }, computed: { - hideStatus () { + 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.virtualHidden && this.$refs.statusComponent[0].suspendable + return this.$refs.statusComponent.every(s => s.suspendable) } else { - return this.virtualHidden + 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 = {} } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue @@ -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,19 +193,74 @@ @import '../../_variables.scss'; .Conversation { - .conversation-status { + .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); } - &.-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.-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; + border-bottom-color: var(--border, $fallback--border); + border-radius: 0; + } + + .thread-ancestor-has-other-replies .conversation-status, + .thread-ancestor:last-child .conversation-status, + .thread-ancestor:last-child .thread-ancestor-dive-box, + &.-expanded .thread-tree .conversation-status { + border-bottom: none; + } + + .thread-ancestors + .thread-tree > .conversation-status { + border-top-width: 1px; + border-top-style: solid; + border-top-color: var(--border, $fallback--border); + } + + /* expanded conversation in timeline */ + &.status-fadein.-expanded .thread-body { + border-left-width: 4px; + border-left-style: solid; + border-left-color: $fallback--cRed; + border-left-color: var(--cRed, $fallback--cRed); + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: 1px solid var(--border, $fallback--border); + } } </style> diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js @@ -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() diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue @@ -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; - .arrow-icon { + .button-icon { position: absolute; - top: 35px; - height: 30px; - width: 32px; + height: $modal-view-button-icon-height; + width: $modal-view-button-icon-width; font-size: 14px; - line-height: 30px; + 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: 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> diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js @@ -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)() + } +} diff --git a/src/components/pinch_zoom/pinch_zoom.vue b/src/components/pinch_zoom/pinch_zoom.vue @@ -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> diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js @@ -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)) + } + } +} diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue @@ -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> diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js @@ -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(), diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue @@ -40,12 +40,6 @@ </ul> </li> <li> - <BooleanSetting - path="hidePostStats" - expert="1" - > - </li> - <li> <BooleanSetting path="muteBotStatuses"> {{ $t('settings.mute_bot_posts') }} </BooleanSetting> @@ -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"> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js @@ -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 diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue @@ -90,6 +90,52 @@ <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> diff --git a/src/components/status/status.js b/src/components/status/status.js @@ -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 diff --git a/src/components/status/status.scss b/src/components/status/status.scss @@ -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 { diff --git a/src/components/status/status.vue b/src/components/status/status.vue @@ -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" diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js @@ -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) { diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue @@ -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> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js @@ -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) + } } } diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue @@ -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; diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js @@ -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 diff --git a/src/components/swipe_click/swipe_click.vue b/src/components/swipe_click/swipe_click.vue @@ -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> diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js @@ -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 diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue @@ -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> diff --git a/src/i18n/en.json b/src/i18n/en.json @@ -119,7 +119,8 @@ "media_modal": { "previous": "Previous", "next": "Next", - "counter": "{current} / {total}" + "counter": "{current} / {total}", + "hide": "Close media viewer" }, "nav": { "about": "About", @@ -472,6 +473,15 @@ "subject_line_email": "Like email: \"re: subject\"", "subject_line_mastodon": "Like mastodon: copy as is", "subject_line_noop": "Do not copy", + "conversation_display": "Conversation display style", + "conversation_display_tree": "Tree-style", + "tree_advanced": "Allow more flexible navigation in tree view", + "tree_fade_ancestors": "Display ancestors of the current status in faint text", + "conversation_display_linear": "Linear-style", + "conversation_other_replies_button": "Show the \"other replies\" button", + "conversation_other_replies_button_below": "Below statuses", + "conversation_other_replies_button_inside": "Inside statuses", + "max_depth_in_thread": "Maximum number of levels in thread to display by default", "post_status_content_type": "Post status content type", "sensitive_by_default": "Mark posts as sensitive by default", "stop_gifs": "Pause animated images until you hover on them", @@ -727,6 +737,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", @@ -753,7 +764,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", diff --git a/src/main.js b/src/main.js @@ -46,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) diff --git a/src/modules/config.js b/src/modules/config.js @@ -12,6 +12,8 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0] export const multiChoiceProperties = [ 'postContentType', 'subjectLineBehavior', + 'conversationDisplay', // tree | linear + 'conversationOtherRepliesButton', // below | inside 'mentionLinkDisplay' // short | full_for_remote | full ] @@ -84,7 +86,12 @@ export const defaultState = { hideBotIndication: undefined, // instance default hideUserStats: undefined, // instance default virtualScrolling: undefined, // instance default - sensitiveByDefault: undefined // instance default + sensitiveByDefault: undefined, // instance default + conversationDisplay: undefined, // instance default + conversationTreeAdvanced: undefined, // instance default + conversationOtherRepliesButton: undefined, // instance default + conversationTreeFadeAncestors: undefined, // instance default + maxDepthInThread: undefined // instance default } // caching the instance default properties diff --git a/src/modules/instance.js b/src/modules/instance.js @@ -55,6 +55,11 @@ const defaultState = { theme: 'pleroma-dark', virtualScrolling: true, sensitiveByDefault: false, + conversationDisplay: 'linear', + conversationTreeAdvanced: false, + conversationOtherRepliesButton: 'below', + conversationTreeFadeAncestors: false, + maxDepthInThread: 6, // Nasty stuff customEmoji: [], diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js @@ -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 diff --git a/yarn.lock b/yarn.lock @@ -916,6 +916,13 @@ resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.0.tgz#63da3e459147cebb0a8d58eed81d6071db9f5973" integrity sha512-N3VKw7KzRfOm8hShUVldpinlm13HpvLBQgT63QS+aCrIRLwjoEUXY5Rcmttbfb6HkzZaeqjLqd/aZCQ53UjQpg== +"@kazvmoe-infra/pinch-zoom-element@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@kazvmoe-infra/pinch-zoom-element/-/pinch-zoom-element-1.2.0.tgz#eb3ca34c53b4410c689d60aca02f4a497ce84aba" + integrity sha512-HBrhH5O/Fsp2bB7EGTXzCsBAVcMjknSagKC5pBdGpKsF8meHISR0kjDIdw4YoE0S+0oNMwJ6ZUZyIBrdywxPPw== + dependencies: + pointer-tracker "^2.0.3" + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" @@ -6995,6 +7002,11 @@ pngjs@^5.0.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +pointer-tracker@^2.0.3: + version "2.4.0" + resolved "https://registry.yarnpkg.com/pointer-tracker/-/pointer-tracker-2.4.0.tgz#78721c2d2201486db11ec1094377f03023b621b3" + integrity sha512-pWI2tpaM/XNtc9mUTv42Rmjf6mkHvE8LT5DDEq0G7baPNhxNM9E3CepubPplSoSLk9E5bwQrAMyDcPVmJyTW4g== + portal-vue@2.1.7: version "2.1.7" resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.7.tgz#ea08069b25b640ca08a5b86f67c612f15f4e4ad4"