Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85630426
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
24 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 684fbe450a..b4c0cfb4b0 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,181 +1,159 @@
import StillImage from '../still-image/still-image.vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Modal from '../modal/modal.vue'
import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+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 Vuex from 'vuex'
+import fileTypeService from '../../services/file_type/file_type.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronLeft,
faChevronRight,
faCircleNotch
} from '@fortawesome/free-solid-svg-icons'
library.add(
faChevronLeft,
faChevronRight,
faCircleNotch
)
const onlyXAxis = ([x, y]) => [x, 0]
const SCALING_RESET_MIN = 1.1
const SCALING_ENABLE_MOVE_THRESHOLD = 1
const MediaModal = {
components: {
StillImage,
VideoAttachment,
PinchZoom,
+ SwipeClick,
Modal,
Flash
},
data () {
return {
loading: false,
- pinchZoomOptions: {}
+ swipeDirection: GestureService.DIRECTION_LEFT,
+ swipeThreshold: 50
}
},
computed: {
showing () {
return this.$store.state.mediaViewer.activated
},
media () {
return this.$store.state.mediaViewer.media
},
description () {
return this.currentMedia.description
},
currentIndex () {
return this.$store.state.mediaViewer.currentIndex
},
currentMedia () {
return this.media[this.currentIndex]
},
canNavigate () {
return this.media.length > 1
},
type () {
return this.currentMedia ? this.getType(this.currentMedia) : null
},
scaling () {
return this.$store.state.mediaViewer.swipeScaler.scaling
},
offsets () {
return this.$store.state.mediaViewer.swipeScaler.offsets
},
transform () {
return `translate(${this.offsets[0]}px, ${this.offsets[1]}px) scale(${this.scaling}, ${this.scaling})`
}
},
created () {
- this.mediaGesture = new GestureService.SwipeAndScaleGesture({
- direction: GestureService.DIRECTION_LEFT,
- callbackPositive: this.goNext,
- callbackNegative: this.goPrev,
- swipePreviewCallback: this.handleSwipePreview,
- swipeEndCallback: this.handleSwipeEnd,
- pinchPreviewCallback: this.handlePinchPreview,
- pinchEndCallback: this.handlePinchEnd,
- threshold: 50
- })
+ // this.mediaGesture = new GestureService.SwipeAndScaleGesture({
+ // callbackPositive: this.goNext,
+ // callbackNegative: this.goPrev,
+ // swipePreviewCallback: this.handleSwipePreview,
+ // swipeEndCallback: this.handleSwipeEnd,
+ // pinchPreviewCallback: this.handlePinchPreview,
+ // pinchEndCallback: this.handlePinchEnd
+ // })
},
methods: {
getType (media) {
return fileTypeService.fileType(media.mimetype)
},
- mediaTouchStart (e) {
- this.mediaGesture.start(e)
- },
- mediaTouchMove (e) {
- this.mediaGesture.move(e)
- },
- mediaTouchEnd (e) {
- this.mediaGesture.end(e)
- },
hide () {
this.$store.dispatch('closeMediaViewer')
},
goPrev () {
if (this.canNavigate) {
const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
const newMedia = this.media[prevIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
}
},
goNext () {
if (this.canNavigate) {
const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
const newMedia = this.media[nextIndex]
if (this.getType(newMedia) === 'image') {
this.loading = true
}
this.$store.dispatch('setCurrentMedia', newMedia)
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
}
},
onImageLoaded () {
this.loading = false
},
handleSwipePreview (offsets) {
- this.$store.dispatch('swipeScaler/apply', {
- offsets: this.scaling > SCALING_ENABLE_MOVE_THRESHOLD ? offsets : onlyXAxis(offsets)
- })
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
},
handleSwipeEnd (sign) {
- if (this.scaling > SCALING_ENABLE_MOVE_THRESHOLD) {
- this.$store.dispatch('swipeScaler/finish')
- return
- }
+ console.log('handleSwipeEnd:', sign)
if (sign === 0) {
- this.$store.dispatch('swipeScaler/reset')
+ this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
} else if (sign > 0) {
this.goNext()
} else {
this.goPrev()
}
},
- handlePinchPreview (offsets, scaling) {
- console.log('handle pinch preview:', offsets, scaling)
- this.$store.dispatch('swipeScaler/apply', { offsets, scaling })
- },
- handlePinchEnd () {
- if (this.scaling > SCALING_RESET_MIN) {
- this.$store.dispatch('swipeScaler/finish')
- } else {
- this.$store.dispatch('swipeScaler/reset')
- }
- },
handleKeyupEvent (e) {
if (this.showing && e.keyCode === 27) { // escape
this.hide()
}
},
handleKeydownEvent (e) {
if (!this.showing) {
return
}
if (e.keyCode === 39) { // arrow right
this.goNext()
} else if (e.keyCode === 37) { // arrow left
this.goPrev()
}
}
},
mounted () {
window.addEventListener('popstate', this.hide)
document.addEventListener('keyup', this.handleKeyupEvent)
document.addEventListener('keydown', this.handleKeydownEvent)
},
destroyed () {
window.removeEventListener('popstate', this.hide)
document.removeEventListener('keyup', this.handleKeyupEvent)
document.removeEventListener('keydown', this.handleKeydownEvent)
}
}
export default MediaModal
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index a3fad4c5a4..e385024ec8 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,252 +1,259 @@
<template>
<Modal
v-if="showing"
class="media-modal-view"
@backdropClicked="hide"
>
- <div class="modal-image-container">
+ <SwipeClick
+ class="modal-image-container"
+ :direction="swipeDirection"
+ :threshold="swipeThreshold"
+ @preview-requested="handleSwipePreview"
+ @swipe-finished="handleSwipeEnd"
+ @swipeless-clicked="hide"
+ >
<PinchZoom
- options="pinchZoomOptions"
+ ref="pinchZoom"
class="modal-image-container-inner"
selector=".modal-image"
allow-pan-min-scale="1"
min-scale="1"
reset-to-min-scale-limit="1.2"
reach-min-scale-strategy="reset"
stop-propagate-handled="stop-propgate-handled"
:inner-class="'modal-image-container-inner'"
>
<img
v-if="type === 'image'"
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@load="onImageLoaded"
>
</PinchZoom>
- </div>
+ </SwipeClick>
<VideoAttachment
v-if="type === 'video'"
class="modal-image"
:attachment="currentMedia"
:controls="true"
/>
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<Flash
v-if="type === 'flash'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
/>
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
class="modal-view-button-arrow modal-view-button-arrow--prev"
@click.stop.prevent="goPrev"
>
<FAIcon
class="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"
@click.stop.prevent="goNext"
>
<FAIcon
class="arrow-icon"
icon="chevron-right"
/>
</button>
<span
v-if="description"
class="description"
>
{{ description }}
</span>
<span
class="counter"
>
{{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }}
</span>
<span
v-if="loading"
class="loading-spinner"
>
<FAIcon
spin
icon="circle-notch"
size="5x"
/>
</span>
</Modal>
</template>
<script src="./media_modal.js"></script>
<style lang="scss">
.modal-view.media-modal-view {
z-index: 1001;
flex-direction: column;
.modal-view-button-arrow {
opacity: 0.75;
&:focus,
&:hover {
outline: none;
box-shadow: none;
}
&:hover {
opacity: 1;
}
}
overflow: hidden;
}
.media-modal-view {
@keyframes media-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-image-container {
display: flex;
overflow: hidden;
align-items: center;
flex-direction: column;
max-width: 90%;
max-height: 95%;
flex-grow: 1;
&-inner {
width: 100%;
height: 100%;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
}
}
.modal-image {
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
}
//.modal-image {
// height: 90vh;
// width: 100%;
//}
.description,
.counter {
/* Hardcoded since background is also hardcoded */
color: white;
margin-top: 1em;
text-shadow: 0 0 10px black, 0 0 10px black;
padding: 0.2em 2em;
}
.description {
flex: 0 0 auto;
overflow-y: auto;
min-height: 1em;
max-width: 500px;
max-height: 9.5em;
word-break: break-all;
}
.modal-image {
max-width: 90%;
max-height: 90%;
box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
image-orientation: from-image; // NOTE: only FF supports this
animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
&.loading {
opacity: 0.5;
}
}
.loading-spinner {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
svg {
color: white;
}
}
.modal-view-button-arrow {
position: absolute;
display: block;
top: 50%;
margin-top: -50px;
width: 70px;
height: 100px;
border: 0;
padding: 0;
opacity: 0;
box-shadow: none;
background: none;
appearance: none;
overflow: visible;
cursor: pointer;
transition: opacity 333ms cubic-bezier(.4,0,.22,1);
.arrow-icon {
position: absolute;
top: 35px;
height: 30px;
width: 32px;
font-size: 14px;
line-height: 30px;
color: #FFF;
text-align: center;
background-color: rgba(0,0,0,.3);
}
&--prev {
left: 0;
.arrow-icon {
left: 6px;
}
}
&--next {
right: 0;
.arrow-icon {
right: 6px;
}
}
}
}
</style>
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
index 75bb608276..36bebbce5b 100644
--- a/src/components/pinch_zoom/pinch_zoom.js
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -1,6 +1,11 @@
import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
export default {
props: {
+ },
+ methods: {
+ setTransform ({ scale, x, y }) {
+ this.$el.setTransform({ scale, x, y })
+ }
}
}
diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js
index ddcccb7948..0299b04ee1 100644
--- a/src/modules/media_viewer.js
+++ b/src/modules/media_viewer.js
@@ -1,94 +1,40 @@
import fileTypeService from '../services/file_type/file_type.service.js'
const supportedTypes = new Set(['image', 'video', 'audio', 'flash'])
const mediaViewer = {
state: {
media: [],
currentIndex: 0,
activated: false
},
mutations: {
setMedia (state, media) {
state.media = media
},
setCurrentMedia (state, index) {
state.activated = true
state.currentIndex = index
},
close (state) {
state.activated = false
}
},
actions: {
setMedia ({ commit, dispatch }, attachments) {
const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype)
return supportedTypes.has(type)
})
commit('setMedia', media)
- dispatch('swipeScaler/reset')
},
- setCurrentMedia ({ commit, state, dispatch }, current) {
+ setCurrentMedia ({ commit, state }, current) {
const index = state.media.indexOf(current)
commit('setCurrentMedia', index || 0)
- dispatch('swipeScaler/reset')
},
closeMediaViewer ({ commit, dispatch }) {
commit('close')
- dispatch('swipeScaler/reset')
- }
- },
- modules: {
- swipeScaler: {
- namespaced: true,
-
- state: {
- origOffsets: [0, 0],
- offsets: [0, 0],
- origScaling: 1,
- scaling: 1
- },
-
- mutations: {
- reset (state) {
- state.origOffsets = [0, 0]
- state.offsets = [0, 0]
- state.origScaling = 1
- state.scaling = 1
- },
- applyOffsets (state, { offsets }) {
- state.offsets = state.origOffsets.map((k, n) => k + offsets[n])
- },
- applyScaling (state, { scaling }) {
- state.scaling = state.origScaling * scaling
- },
- finish (state) {
- state.origOffsets = [...state.offsets]
- state.origScaling = state.scaling
- },
- revert (state) {
- state.offsets = [...state.origOffsets]
- state.scaling = state.origScaling
- }
- },
-
- actions: {
- reset ({ commit }) {
- commit('reset')
- },
- apply ({ commit }, { offsets, scaling = 1 }) {
- commit('applyOffsets', { offsets })
- commit('applyScaling', { scaling })
- },
- finish ({ commit }) {
- commit('finish')
- },
- revert ({ commit }) {
- commit('revert')
- }
- }
}
}
}
export default mediaViewer
diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js
index 82337bc6b0..f10dec3a79 100644
--- a/src/services/gesture_service/gesture_service.js
+++ b/src/services/gesture_service/gesture_service.js
@@ -1,216 +1,206 @@
const DIRECTION_LEFT = [-1, 0]
const DIRECTION_RIGHT = [1, 0]
const DIRECTION_UP = [0, -1]
const DIRECTION_DOWN = [0, 1]
-const DISTANCE_MIN = 1
+// const DISTANCE_MIN = 1
-const isSwipeEvent = e => (e.touches.length === 1)
-const isSwipeEventEnd = e => (e.changedTouches.length === 1)
+// const isSwipeEvent = e => (e.touches.length === 1)
+// const isSwipeEventEnd = e => (e.changedTouches.length === 1)
-const isScaleEvent = e => (e.targetTouches.length === 2)
-const isScaleEventEnd = e => (e.targetTouches.length === 1)
+// const isScaleEvent = e => (e.targetTouches.length === 2)
+// const isScaleEventEnd = e => (e.targetTouches.length === 1)
const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
-const vectorMinus = (a, b) => a.map((k, n) => k - b[n])
-const vectorAdd = (a, b) => a.map((k, n) => k + b[n])
+// const vectorMinus = (a, b) => a.map((k, n) => k - b[n])
+// const vectorAdd = (a, b) => a.map((k, n) => k + b[n])
-const avgCoord = (coords) => [...coords].reduce(vectorAdd, [0, 0]).map(d => d / coords.length)
+// const avgCoord = (coords) => [...coords].reduce(vectorAdd, [0, 0]).map(d => d / coords.length)
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])
const perpendicular = v => [v[1], -v[0]]
const dotProduct = (v1, v2) => v1[0] * v2[0] + v1[1] * v2[1]
// const numProduct = (num, v) => v.map(k => num * k)
const project = (v1, v2) => {
const scalar = (dotProduct(v1, v2) / dotProduct(v2, v2))
return [scalar * v2[0], scalar * v2[1]]
}
// direction: either use the constants above or an arbitrary 2d vector.
// threshold: how many Px to move from touch origin before checking if the
// callback should be called.
// divergentTolerance: a scalar for much of divergent direction we tolerate when
// above threshold. for example, with 1.0 we only call the callback if
// divergent component of delta is < 1.0 * direction component of delta.
const swipeGesture = (direction, onSwipe, threshold = 30, perpendicularTolerance = 1.0) => {
return {
direction,
onSwipe,
threshold,
perpendicularTolerance,
_startPos: [0, 0],
_swiping: false
}
}
const beginSwipe = (event, gesture) => {
gesture._startPos = touchEventCoord(event)
gesture._swiping = true
}
const updateSwipe = (event, gesture) => {
if (!gesture._swiping) return
// movement too small
const delta = deltaCoord(gesture._startPos, touchEventCoord(event))
if (vectorLength(delta) < gesture.threshold) return
// movement is opposite from direction
if (dotProduct(delta, gesture.direction) < 0) return
// movement perpendicular to direction is too much
const towardsDir = project(delta, gesture.direction)
const perpendicularDir = perpendicular(gesture.direction)
const towardsPerpendicular = project(delta, perpendicularDir)
if (
vectorLength(towardsDir) * gesture.perpendicularTolerance <
vectorLength(towardsPerpendicular)
) return
gesture.onSwipe()
gesture._swiping = false
}
-class SwipeAndScaleGesture {
+class SwipeAndClickGesture {
// swipePreviewCallback(offsets: Array[Number])
// offsets: the offset vector which the underlying component should move, from the starting position
- // pinchPreviewCallback(offsets: Array[Number], scaling: Number)
- // offsets: the offset vector which the underlying component should move, from the starting position
- // scaling: the scaling factor we should apply to the underlying component, from the starting position
- // swipeEndcallback(sign: 0|-1|1)
+ // 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, pinchStartCallback,
- swipePreviewCallback, pinchPreviewCallback,
- swipeEndCallback, pinchEndCallback,
+ // swipeStartCallback
+ swipePreviewCallback,
+ swipeEndCallback,
+ swipeCancelCallback,
+ swipelessClickCallback,
threshold = 30, perpendicularTolerance = 1.0
}) {
const nop = () => { console.log('Warning: Not implemented') }
this.direction = direction
this.swipePreviewCallback = swipePreviewCallback || nop
- this.pinchPreviewCallback = pinchPreviewCallback || nop
this.swipeEndCallback = swipeEndCallback || nop
- this.pinchEndCallback = pinchEndCallback || nop
+ this.swipeCancelCallback = swipeCancelCallback || nop
+ this.swipelessClickCallback = swipelessClickCallback || nop
this.threshold = threshold
this.perpendicularTolerance = perpendicularTolerance
+ this._reset()
+ }
+
+ _reset () {
this._startPos = [0, 0]
- this._startDistance = DISTANCE_MIN
+ this._pointerId = -1
this._swiping = false
+ this._swiped = false
}
start (event) {
console.log('start() called', event)
- if (isSwipeEvent(event)) {
- this._startPos = touchEventCoord(event)
- console.log('start pos:', this._startPos)
- this._swiping = true
- } else if (isScaleEvent(event)) {
- const coords = [...event.targetTouches].map(touchCoord)
- this._startPos = avgCoord(coords)
- this._startDistance = vectorLength(deltaCoord(coords[0], coords[1]))
- if (this._startDistance < DISTANCE_MIN) {
- this._startDistance = DISTANCE_MIN
- }
- this._scalePoints = [...event.targetTouches]
- this._swiping = false
- console.log(
- 'is scale event, start =', this._startPos,
- 'dist =', this._startDistance)
- }
+
+ this._startPos = pointerEventCoord(event)
+ this._pointerId = event.pointerId
+ console.log('start pos:', this._startPos)
+ this._swiping = true
+ this._swiped = false
}
move (event) {
- // console.log('move called', event)
- if (isSwipeEvent(event)) {
- const touch = event.changedTouches[0]
- const delta = deltaCoord(this._startPos, touchCoord(touch))
+ if (this._swiping && this._pointerId === event.pointerId) {
+ this._swiped = true
+
+ const coord = pointerEventCoord(event)
+ const delta = deltaCoord(this._startPos, coord)
this.swipePreviewCallback(delta)
- } else if (isScaleEvent(event)) {
- console.log('is scale event')
- const coords = [...event.targetTouches].map(touchCoord)
- const curPos = avgCoord(coords)
- const curDistance = vectorLength(deltaCoord(coords[0], coords[1]))
- const scaling = curDistance / this._startDistance
- const posDiff = vectorMinus(curPos, this._startPos)
- // const delta = vectorAdd(numProduct((1 - scaling), this._startPos), posDiff)
- const delta = posDiff
- // console.log(
- // 'is scale event, cur =', curPos,
- // 'dist =', curDistance,
- // 'scale =', scaling,
- // 'delta =', delta)
- this.pinchPreviewCallback(delta, scaling)
}
}
- end (event) {
- console.log('end() called', event)
- if (isScaleEventEnd(event)) {
- this.pinchEndCallback()
- }
-
- if (!isSwipeEventEnd(event)) {
- console.log('not swipe event')
+ cancel (event) {
+ if (!this._swiping || this._pointerId !== event.pointerId) {
return
}
+
+ this.swipeCancelCallback()
+ }
+
+ end (event) {
if (!this._swiping) {
console.log('not swiping')
return
}
- this.swiping = false
- console.log('is swipe event')
+ if (this._pointerId !== event.pointerId) {
+ console.log('pointer id does not match')
+ return
+ }
+
+ this._swiping = false
+
+ console.log('end: is swipe event')
// movement too small
- const touch = event.changedTouches[0]
- const delta = deltaCoord(this._startPos, touchCoord(touch))
- this.swipePreviewCallback(delta)
+ 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
})()
- this.swipeEndCallback(sign)
+ if (this._swiped) {
+ this.swipeEndCallback(sign)
+ } else {
+ this.swipelessClickCallback()
+ }
+ this._reset()
}
}
const GestureService = {
DIRECTION_LEFT,
DIRECTION_RIGHT,
DIRECTION_UP,
DIRECTION_DOWN,
swipeGesture,
beginSwipe,
updateSwipe,
- SwipeAndScaleGesture
+ SwipeAndClickGesture
}
export default GestureService
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Aug 9, 1:19 PM (1 d, 8 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724609
Default Alt Text
(24 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment