commit: 9ccc6174a73199dc3c5fabfc9a9769ab9265060a
parent: b761bcf3334e1f464e63a87de40eb75d0906d545
Author: Shpuld Shpludson <shp@cock.li>
Date:   Mon,  6 Jul 2020 10:17:26 +0000
Merge branch 'feat/rich-text-preview' into 'develop'
Status preview #459
See merge request pleroma/pleroma-fe!1159
Diffstat:
9 files changed, 195 insertions(+), 36 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Autocomplete domains from list of known instances
 - 'Bot' settings option and badge
 - Added profile meta data fields that can be set in profile settings
+- Added status preview option to preview your statuses before posting
 - When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
 
 ### Changed
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
@@ -3,9 +3,10 @@ import MediaUpload from '../media_upload/media_upload.vue'
 import ScopeSelector from '../scope_selector/scope_selector.vue'
 import EmojiInput from '../emoji_input/emoji_input.vue'
 import PollForm from '../poll/poll_form.vue'
+import StatusContent from '../status_content/status_content.vue'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-import { reject, map, uniqBy } from 'lodash'
+import { reject, map, uniqBy, debounce } from 'lodash'
 import suggestor from '../emoji_input/suggestor.js'
 import { mapGetters } from 'vuex'
 import Checkbox from '../checkbox/checkbox.vue'
@@ -38,7 +39,8 @@ const PostStatusForm = {
     EmojiInput,
     PollForm,
     ScopeSelector,
-    Checkbox
+    Checkbox,
+    StatusContent
   },
   mounted () {
     this.resize(this.$refs.textarea)
@@ -84,7 +86,9 @@ const PostStatusForm = {
       caret: 0,
       pollFormVisible: false,
       showDropIcon: 'hide',
-      dropStopTimeout: null
+      dropStopTimeout: null,
+      preview: null,
+      previewLoading: false
     }
   },
   computed: {
@@ -163,18 +167,29 @@ const PostStatusForm = {
         this.newStatus.poll &&
         this.newStatus.poll.error
     },
+    showPreview () {
+      return !!this.preview || this.previewLoading
+    },
+    emptyStatus () {
+      return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
+    },
     ...mapGetters(['mergedConfig'])
   },
+  watch: {
+    'newStatus.contentType': function () {
+      this.autoPreview()
+    },
+    'newStatus.spoilerText': function () {
+      this.autoPreview()
+    }
+  },
   methods: {
     postStatus (newStatus) {
       if (this.posting) { return }
       if (this.submitDisabled) { return }
-
-      if (this.newStatus.status === '') {
-        if (this.newStatus.files.length === 0) {
-          this.error = 'Cannot post an empty status with no files'
-          return
-        }
+      if (this.emptyStatus) {
+        this.error = this.$t('post_status.empty_status_error')
+        return
       }
 
       const poll = this.pollFormVisible ? this.newStatus.poll : {}
@@ -212,12 +227,64 @@ const PostStatusForm = {
           el.style.height = 'auto'
           el.style.height = undefined
           this.error = null
+          this.previewStatus()
         } else {
           this.error = data.error
         }
         this.posting = false
       })
     },
+    previewStatus () {
+      if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
+        this.preview = { error: this.$t('post_status.preview_empty') }
+        this.previewLoading = false
+        return
+      }
+      const newStatus = this.newStatus
+      this.previewLoading = true
+      statusPoster.postStatus({
+        status: newStatus.status,
+        spoilerText: newStatus.spoilerText || null,
+        visibility: newStatus.visibility,
+        sensitive: newStatus.nsfw,
+        media: [],
+        store: this.$store,
+        inReplyToStatusId: this.replyTo,
+        contentType: newStatus.contentType,
+        poll: {},
+        preview: true
+      }).then((data) => {
+        // Don't apply preview if not loading, because it means
+        // user has closed the preview manually.
+        if (!this.previewLoading) return
+        if (!data.error) {
+          this.preview = data
+        } else {
+          this.preview = { error: data.error }
+        }
+      }).catch((error) => {
+        this.preview = { error }
+      }).finally(() => {
+        this.previewLoading = false
+      })
+    },
+    debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
+    autoPreview () {
+      if (!this.preview) return
+      this.previewLoading = true
+      this.debouncePreviewStatus()
+    },
+    closePreview () {
+      this.preview = null
+      this.previewLoading = false
+    },
+    togglePreview () {
+      if (this.showPreview) {
+        this.closePreview()
+      } else {
+        this.previewStatus()
+      }
+    },
     addMediaFile (fileInfo) {
       this.newStatus.files.push(fileInfo)
     },
@@ -239,6 +306,7 @@ const PostStatusForm = {
       return fileTypeService.fileType(fileInfo.mimetype)
     },
     paste (e) {
+      this.autoPreview()
       this.resize(e)
       if (e.clipboardData.files.length > 0) {
         // prevent pasting of file as text
@@ -273,6 +341,7 @@ const PostStatusForm = {
       }
     },
     onEmojiInputInput (e) {
+      this.autoPreview()
       this.$nextTick(() => {
         this.resize(this.$refs['textarea'])
       })
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
@@ -69,6 +69,44 @@
           <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
           <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
         </p>
+        <div class="preview-heading faint">
+          <a
+            class="preview-toggle faint"
+            @click.stop.prevent="togglePreview"
+          >
+            {{ $t('post_status.preview') }}
+            <i
+              class="icon-down-open"
+              :style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
+            />
+          </a>
+          <i
+            v-show="previewLoading"
+            class="icon-spin3 animate-spin"
+          />
+        </div>
+        <div
+          v-if="showPreview"
+          class="preview-container"
+        >
+          <div
+            v-if="!preview"
+            class="preview-status"
+          >
+            {{ $t('general.loading') }}
+          </div>
+          <div
+            v-else-if="preview.error"
+            class="preview-status preview-error"
+          >
+            {{ preview.error }}
+          </div>
+          <StatusContent
+            v-else
+            :status="preview"
+            class="preview-status"
+          />
+        </div>
         <EmojiInput
           v-if="newStatus.spoilerText || alwaysShowSubject"
           v-model="newStatus.spoilerText"
@@ -77,7 +115,6 @@
           class="form-control"
         >
           <input
-
             v-model="newStatus.spoilerText"
             type="text"
             :placeholder="$t('post_status.content_warning')"
@@ -303,14 +340,6 @@
 }
 
 .post-status-form {
-  .visibility-tray {
-    display: flex;
-    justify-content: space-between;
-    padding-top: 5px;
-  }
-}
-
-.post-status-form {
   .form-bottom {
     display: flex;
     justify-content: space-between;
@@ -336,6 +365,48 @@
     max-width: 10em;
   }
 
+  .preview-heading {
+    display: flex;
+    width: 100%;
+
+    .icon-spin3 {
+      margin-left: auto;
+    }
+  }
+
+  .preview-toggle {
+    display: flex;
+    cursor: pointer;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  .icon-down-open {
+    transition: transform 0.1s;
+  }
+
+  .preview-container {
+    margin-bottom: 1em;
+  }
+
+  .preview-error {
+    font-style: italic;
+    color: $fallback--faint;
+    color: var(--faint, $fallback--faint);
+  }
+
+  .preview-status {
+    border: 1px solid $fallback--border;
+    border: 1px solid var(--border, $fallback--border);
+    border-radius: $fallback--tooltipRadius;
+    border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+    padding: 0.5em;
+    margin: 0;
+    line-height: 1.4em;
+  }
+
   .text-format {
     .only-format {
       color: $fallback--faint;
@@ -343,6 +414,12 @@
     }
   }
 
+  .visibility-tray {
+    display: flex;
+    justify-content: space-between;
+    padding-top: 5px;
+  }
+
   .media-upload-icon, .poll-icon, .emoji-icon {
     font-size: 26px;
     flex: 1;
@@ -408,7 +485,7 @@
     flex-direction: column;
   }
 
-  .attachments {
+  .media-upload-wrapper .attachments {
     padding: 0 0.5em;
 
     .attachment {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
@@ -377,9 +377,6 @@ $status-margin: 0.75em;
 }
 
 .status-el {
-  overflow-wrap: break-word;
-  word-wrap: break-word;
-  word-break: break-word;
   border-left-width: 0px;
   min-width: 0;
   border-color: $fallback--border;
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
@@ -217,6 +217,9 @@ $status-margin: 0.75em;
     font-family: var(--postFont, sans-serif);
     line-height: 1.4em;
     white-space: pre-wrap;
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-word;
 
     blockquote {
       margin: 0.2em 0 0.2em 2em;
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
@@ -10,9 +10,7 @@
         :hide-bio="true"
         rounded="top"
       />
-      <div class="panel-footer">
-        <PostStatusForm />
-      </div>
+      <PostStatusForm />
     </div>
     <auth-form
       v-else
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -189,6 +189,9 @@
     "direct_warning_to_all": "This post will be visible to all the mentioned users.",
     "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
     "posting": "Posting",
+    "preview": "Preview",
+    "preview_empty": "Empty",
+    "empty_status_error": "Can't post an empty status with no files",
     "scope_notice": {
       "public": "This post will be visible to everyone",
       "private": "This post will be visible to your followers only",
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
@@ -645,7 +645,8 @@ const postStatus = ({
   poll,
   mediaIds = [],
   inReplyToStatusId,
-  contentType
+  contentType,
+  preview
 }) => {
   const form = new FormData()
   const pollOptions = poll.options || []
@@ -675,6 +676,9 @@ const postStatus = ({
   if (inReplyToStatusId) {
     form.append('in_reply_to_id', inReplyToStatusId)
   }
+  if (preview) {
+    form.append('preview', 'true')
+  }
 
   return fetch(MASTODON_POST_STATUS_URL, {
     body: form,
@@ -682,13 +686,7 @@ const postStatus = ({
     headers: authHeaders(credentials)
   })
     .then((response) => {
-      if (response.ok) {
-        return response.json()
-      } else {
-        return {
-          error: response
-        }
-      }
+      return response.json()
     })
     .then((data) => data.error ? data : parseStatus(data))
 }
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
@@ -1,7 +1,18 @@
 import { map } from 'lodash'
 import apiService from '../api/api.service.js'
 
-const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
+const postStatus = ({
+  store,
+  status,
+  spoilerText,
+  visibility,
+  sensitive,
+  poll,
+  media = [],
+  inReplyToStatusId = undefined,
+  contentType = 'text/plain',
+  preview = false
+}) => {
   const mediaIds = map(media, 'id')
 
   return apiService.postStatus({
@@ -13,9 +24,11 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
     mediaIds,
     inReplyToStatusId,
     contentType,
-    poll })
+    poll,
+    preview
+  })
     .then((data) => {
-      if (!data.error) {
+      if (!data.error && !preview) {
         store.dispatch('addNewStatuses', {
           statuses: [data],
           timeline: 'friends',