Quellcode durchsuchen

Merge branch 'proper-attachments' into 'develop'

Attachment improvements

See merge request pleroma/pleroma-fe!1399
HJ vor 2 Jahren
Ursprung
Commit
ddee8bb686

+ 11 - 0
CHANGELOG.md

@@ -6,12 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 ## Unreleased
 ### Fixed
 - Completely hidden posts still had 1px border
+- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
+- Attachment description is prefilled with backend-provided default when uploading
+- Proper visual feedback that next image is loading when browsing
 
 ### Changed
 - Settings window has been throughly rearranged to make make more sense and make navication settings easier.
+- Uploaded attachments are uniform with displayed attachments
+- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
+- Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post.
 
 ### Added
 - Option to completely hide muted threads
+- Ability to open videos in modal even if you disabled that feature, via an icon button
+- New button on attachment that indicates that attachment has a description and shows a bar filled with description
+- 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
 
 ## [2.4.2] - 2022-01-09
 ### Added 

+ 89 - 23
src/components/attachment/attachment.js

@@ -11,7 +11,12 @@ import {
   faImage,
   faVideo,
   faPlayCircle,
-  faTimes
+  faTimes,
+  faStop,
+  faSearchPlus,
+  faTrashAlt,
+  faPencilAlt,
+  faAlignRight
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -20,27 +25,39 @@ library.add(
   faImage,
   faVideo,
   faPlayCircle,
-  faTimes
+  faTimes,
+  faStop,
+  faSearchPlus,
+  faTrashAlt,
+  faPencilAlt,
+  faAlignRight
 )
 
 const Attachment = {
   props: [
     'attachment',
+    'description',
+    'hideDescription',
     'nsfw',
     'size',
-    'allowPlay',
     'setMedia',
-    'naturalSizeLoad'
+    'remove',
+    'shiftUp',
+    'shiftDn',
+    'edit'
   ],
   data () {
     return {
+      localDescription: this.description || this.attachment.description,
       nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
       hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
       preloadImage: this.$store.getters.mergedConfig.preloadImage,
       loading: false,
       img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
       modalOpen: false,
-      showHidden: false
+      showHidden: false,
+      flashLoaded: false,
+      showDescription: false
     }
   },
   components: {
@@ -49,8 +66,23 @@ const Attachment = {
     VideoAttachment
   },
   computed: {
+    classNames () {
+      return [
+        {
+          '-loading': this.loading,
+          '-nsfw-placeholder': this.hidden,
+          '-editable': this.edit !== undefined
+        },
+        '-type-' + this.type,
+        this.size && '-size-' + this.size,
+        `-${this.useContainFit ? 'contain' : 'cover'}-fit`
+      ]
+    },
     usePlaceholder () {
-      return this.size === 'hide' || this.type === 'unknown'
+      return this.size === 'hide'
+    },
+    useContainFit () {
+      return this.$store.getters.mergedConfig.useContainFit
     },
     placeholderName () {
       if (this.attachment.description === '' || !this.attachment.description) {
@@ -74,24 +106,33 @@ const Attachment = {
       return this.nsfw && this.hideNsfwLocal && !this.showHidden
     },
     isEmpty () {
-      return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
-    },
-    isSmall () {
-      return this.size === 'small'
-    },
-    fullwidth () {
-      if (this.size === 'hide') return false
-      return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
+      return (this.type === 'html' && !this.attachment.oembed)
     },
     useModal () {
-      const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
-        : this.mergedConfig.playVideosInModal
-          ? ['image', 'video']
-          : ['image']
+      let modalTypes = []
+      switch (this.size) {
+        case 'hide':
+        case 'small':
+          modalTypes = ['image', 'video', 'audio', 'flash']
+          break
+        default:
+          modalTypes = this.mergedConfig.playVideosInModal
+            ? ['image', 'video', 'flash']
+            : ['image']
+          break
+      }
       return modalTypes.includes(this.type)
     },
+    videoTag () {
+      return this.useModal ? 'button' : 'span'
+    },
     ...mapGetters(['mergedConfig'])
   },
+  watch: {
+    localDescription (newVal) {
+      this.onEdit(newVal)
+    }
+  },
   methods: {
     linkClicked ({ target }) {
       if (target.tagName === 'A') {
@@ -100,12 +141,37 @@ const Attachment = {
     },
     openModal (event) {
       if (this.useModal) {
-        event.stopPropagation()
-        event.preventDefault()
-        this.setMedia()
-        this.$store.dispatch('setCurrent', this.attachment)
+        this.$emit('setMedia')
+        this.$store.dispatch('setCurrentMedia', this.attachment)
+      } else if (this.type === 'unknown') {
+        window.open(this.attachment.url)
       }
     },
+    openModalForce (event) {
+      this.$emit('setMedia')
+      this.$store.dispatch('setCurrentMedia', this.attachment)
+    },
+    onEdit (event) {
+      this.edit && this.edit(this.attachment, event)
+    },
+    onRemove () {
+      this.remove && this.remove(this.attachment)
+    },
+    onShiftUp () {
+      this.shiftUp && this.shiftUp(this.attachment)
+    },
+    onShiftDn () {
+      this.shiftDn && this.shiftDn(this.attachment)
+    },
+    stopFlash () {
+      this.$refs.flash.closePlayer()
+    },
+    setFlashLoaded (event) {
+      this.flashLoaded = event
+    },
+    toggleDescription () {
+      this.showDescription = !this.showDescription
+    },
     toggleHidden (event) {
       if (
         (this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
@@ -132,7 +198,7 @@ const Attachment = {
     onImageLoad (image) {
       const width = image.naturalWidth
       const height = image.naturalHeight
-      this.naturalSizeLoad && this.naturalSizeLoad({ width, height })
+      this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
     }
   }
 }

+ 268 - 0
src/components/attachment/attachment.scss

@@ -0,0 +1,268 @@
+@import '../../_variables.scss';
+
+.Attachment {
+  display: inline-flex;
+  flex-direction: column;
+  position: relative;
+  align-self: flex-start;
+  line-height: 0;
+  height: 100%;
+  border-style: solid;
+  border-width: 1px;
+  border-radius: $fallback--attachmentRadius;
+  border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
+  border-color: $fallback--border;
+  border-color: var(--border, $fallback--border);
+
+  .attachment-wrapper {
+    flex: 1 1 auto;
+    height: 100%;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .description-container {
+    flex: 0 1 0;
+    display: flex;
+    padding-top: 0.5em;
+    z-index: 1;
+
+    p {
+      flex: 1;
+      text-align: center;
+      line-height: 1.5;
+      padding: 0.5em;
+      margin: 0;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    }
+
+    &.-static {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      padding-top: 0;
+      background: var(--popover);
+      box-shadow: var(--popupShadow);
+    }
+  }
+
+  .description-field {
+    flex: 1;
+    min-width: 0;
+  }
+
+  & .placeholder-container,
+  & .image-container,
+  & .audio-container,
+  & .video-container,
+  & .flash-container,
+  & .oembed-container {
+    display: flex;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+  }
+
+  .image-container {
+    .image {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  & .flash-container,
+  & .video-container {
+    & .flash,
+    & video {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+      align-self: center;
+    }
+  }
+
+  .audio-container {
+    display: flex;
+    align-items: flex-end;
+
+    audio {
+      width: 100%;
+      height: 100%;
+    }
+  }
+
+  .placeholder-container {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding-top: 0.5em;
+  }
+
+
+  .play-icon {
+    position: absolute;
+    font-size: 64px;
+    top: calc(50% - 32px);
+    left: calc(50% - 32px);
+    color: rgba(255, 255, 255, 0.75);
+    text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
+
+    &::before {
+      margin: 0;
+    }
+  }
+
+  .attachment-buttons {
+    display: flex;
+    position: absolute;
+    right: 0;
+    top: 0;
+    margin-top: 0.5em;
+    margin-right: 0.5em;
+    z-index: 1;
+
+    .attachment-button {
+      padding: 0;
+      border-radius: $fallback--tooltipRadius;
+      border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+      text-align: center;
+      width: 2em;
+      height: 2em;
+      margin-left: 0.5em;
+      font-size: 1.25em;
+      // TODO: theming? hard to theme with unknown background image color
+      background: rgba(230, 230, 230, 0.7);
+
+      .svg-inline--fa {
+        color: rgba(0, 0, 0, 0.6);
+      }
+
+      &:hover .svg-inline--fa {
+        color: rgba(0, 0, 0, 0.9);
+      }
+    }
+  }
+
+  .oembed-container {
+    line-height: 1.2em;
+    flex: 1 0 100%;
+    width: 100%;
+    margin-right: 15px;
+    display: flex;
+
+    img {
+      width: 100%;
+    }
+
+    .image {
+      flex: 1;
+      img {
+        border: 0px;
+        border-radius: 5px;
+        height: 100%;
+        object-fit: cover;
+      }
+    }
+
+    .text {
+      flex: 2;
+      margin: 8px;
+      word-break: break-all;
+      h1 {
+        font-size: 14px;
+        margin: 0px;
+      }
+    }
+  }
+
+  &.-size-small {
+    .play-icon {
+      zoom: 0.5;
+      opacity: 0.7;
+    }
+
+    .attachment-buttons {
+      zoom: 0.7;
+      opacity: 0.5;
+    }
+  }
+
+  &.-editable {
+    padding: 0.5em;
+
+    & .description-container,
+    & .attachment-buttons {
+      margin: 0;
+    }
+  }
+
+  &.-placeholder {
+    display: inline-block;
+    color: $fallback--link;
+    color: var(--postLink, $fallback--link);
+    overflow: hidden;
+    white-space: nowrap;
+    height: auto;
+    line-height: 1.5;
+
+    &:not(.-editable) {
+      border: none;
+    }
+
+    &.-editable {
+      display: flex;
+      flex-direction: row;
+      align-items: baseline;
+
+      & .description-container,
+      & .attachment-buttons {
+        margin: 0;
+        padding: 0;
+        position: relative;
+      }
+
+      .description-container {
+        flex: 1;
+        padding-left: 0.5em;
+      }
+
+      .attachment-buttons {
+        order: 99;
+        align-self: center;
+      }
+    }
+
+    a {
+      display: inline-block;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    svg {
+      color: inherit;
+    }
+  }
+
+  &.-loading {
+    cursor: progress;
+  }
+
+  &.-contain-fit {
+    img,
+    canvas {
+      object-fit: contain;
+    }
+  }
+
+  &.-cover-fit {
+    img,
+    canvas {
+      object-fit: cover;
+    }
+  }
+}

+ 233 - 297
src/components/attachment/attachment.vue

@@ -1,7 +1,8 @@
 <template>
-  <div
+  <button
     v-if="usePlaceholder"
-    :class="{ 'fullwidth': fullwidth }"
+    class="Attachment -placeholder button-unstyled"
+    :class="classNames"
     @click="openModal"
   >
     <a
@@ -13,316 +14,251 @@
       :title="attachment.description"
     >
       <FAIcon :icon="placeholderIconClass" />
-      <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
+      <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
     </a>
-  </div>
-  <div
-    v-else
-    v-show="!isEmpty"
-    class="attachment"
-    :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
-  >
-    <a
-      v-if="hidden"
-      class="image-attachment"
-      :href="attachment.url"
-      :alt="attachment.description"
-      :title="attachment.description"
-      @click.prevent.stop="toggleHidden"
+    <div
+      v-if="edit || remove"
+      class="attachment-buttons"
     >
-      <img
-        :key="nsfwImage"
-        class="nsfw"
-        :src="nsfwImage"
-        :class="{'small': isSmall}"
+      <button
+        v-if="remove"
+        class="button-unstyled attachment-button"
+        @click.prevent="onRemove"
       >
-      <FAIcon
-        v-if="type === 'video'"
-        class="play-icon"
-        icon="play-circle"
-      />
-    </a>
-    <button
-      v-if="nsfw && hideNsfwLocal && !hidden"
-      class="button-unstyled hider"
-      @click.prevent="toggleHidden"
+        <FAIcon icon="trash-alt" />
+      </button>
+    </div>
+    <div
+      v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
+      class="description-container"
+      :class="{ '-static': !edit }"
     >
-      <FAIcon icon="times" />
-    </button>
-
-    <a
-      v-if="type === 'image' && (!hidden || preloadImage)"
-      class="image-attachment"
-      :class="{'hidden': hidden && preloadImage }"
-      :href="attachment.url"
-      target="_blank"
-      @click="openModal"
+      <input
+        v-if="edit"
+        v-model="localDescription"
+        type="text"
+        class="description-field"
+        :placeholder="$t('post_status.media_description')"
+        @keydown.enter.prevent=""
+      >
+      <p v-else>
+        {{ localDescription }}
+      </p>
+    </div>
+  </button>
+  <div
+    v-else
+    class="Attachment"
+    :class="classNames"
+  >
+    <div
+      v-show="!isEmpty"
+      class="attachment-wrapper"
     >
-      <StillImage
-        class="image"
-        :referrerpolicy="referrerpolicy"
-        :mimetype="attachment.mimetype"
-        :src="attachment.large_thumb_url || attachment.url"
-        :image-load-handler="onImageLoad"
+      <a
+        v-if="hidden"
+        class="image-container"
+        :href="attachment.url"
         :alt="attachment.description"
-      />
-    </a>
+        :title="attachment.description"
+        @click.prevent.stop="toggleHidden"
+      >
+        <img
+          :key="nsfwImage"
+          class="nsfw"
+          :src="nsfwImage"
+        >
+        <FAIcon
+          v-if="type === 'video'"
+          class="play-icon"
+          icon="play-circle"
+        />
+      </a>
+      <div
+        v-if="!hidden"
+        class="attachment-buttons"
+      >
+        <button
+          v-if="type === 'flash' && flashLoaded"
+          class="button-unstyled attachment-button"
+          @click.prevent="stopFlash"
+          :title="$t('status.attachment_stop_flash')"
+        >
+          <FAIcon icon="stop" />
+        </button>
+        <button
+          v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
+          class="button-unstyled attachment-button"
+          @click.prevent="toggleDescription"
+          :title="$t('status.show_attachment_description')"
+        >
+          <FAIcon icon="align-right" />
+        </button>
+        <button
+          v-if="!useModal && type !== 'unknown'"
+          class="button-unstyled attachment-button"
+          @click.prevent="openModalForce"
+          :title="$t('status.show_attachment_in_modal')"
+        >
+          <FAIcon icon="search-plus" />
+        </button>
+        <button
+          v-if="nsfw && hideNsfwLocal"
+          class="button-unstyled attachment-button"
+          @click.prevent="toggleHidden"
+          :title="$t('status.hide_attachment')"
+        >
+          <FAIcon icon="times" />
+        </button>
+        <button
+          v-if="shiftUp"
+          class="button-unstyled attachment-button"
+          @click.prevent="onShiftUp"
+          :title="$t('status.move_up')"
+        >
+          <FAIcon icon="chevron-left" />
+        </button>
+        <button
+          v-if="shiftDn"
+          class="button-unstyled attachment-button"
+          @click.prevent="onShiftDn"
+          :title="$t('status.move_down')"
+        >
+          <FAIcon icon="chevron-right" />
+        </button>
+        <button
+          v-if="remove"
+          class="button-unstyled attachment-button"
+          @click.prevent="onRemove"
+          :title="$t('status.remove_attachment')"
+        >
+          <FAIcon icon="trash-alt" />
+        </button>
+      </div>
 
-    <a
-      v-if="type === 'video' && !hidden"
-      class="video-container"
-      :class="{'small': isSmall}"
-      :href="allowPlay ? undefined : attachment.url"
-      @click="openModal"
-    >
-      <VideoAttachment
-        class="video"
-        :attachment="attachment"
-        :controls="allowPlay"
-        @play="$emit('play')"
-        @pause="$emit('pause')"
-      />
-      <FAIcon
-        v-if="!allowPlay"
-        class="play-icon"
-        icon="play-circle"
-      />
-    </a>
+      <a
+        v-if="type === 'image' && (!hidden || preloadImage)"
+        class="image-container"
+        :class="{'-hidden': hidden && preloadImage }"
+        :href="attachment.url"
+        target="_blank"
+        @click.stop.prevent="openModal"
+      >
+        <StillImage
+          class="image"
+          :referrerpolicy="referrerpolicy"
+          :mimetype="attachment.mimetype"
+          :src="attachment.large_thumb_url || attachment.url"
+          :image-load-handler="onImageLoad"
+          :alt="attachment.description"
+        />
+      </a>
+
+      <a
+        v-if="type === 'unknown' && !hidden"
+        class="placeholder-container"
+        :href="attachment.url"
+        target="_blank"
+      >
+        <FAIcon size="5x" :icon="placeholderIconClass" />
+        <p>
+          {{ localDescription }}
+        </p>
+      </a>
+
+      <component
+        :is="videoTag"
+        v-if="type === 'video' && !hidden"
+        class="video-container"
+        :class="{ 'button-unstyled': 'isModal' }"
+        :href="attachment.url"
+        @click.stop.prevent="openModal"
+      >
+        <VideoAttachment
+          class="video"
+          :attachment="attachment"
+          :controls="!useModal"
+          @play="$emit('play')"
+          @pause="$emit('pause')"
+        />
+        <FAIcon
+          v-if="useModal"
+          class="play-icon"
+          icon="play-circle"
+        />
+      </component>
+
+      <span
+        v-if="type === 'audio' && !hidden"
+        class="audio-container"
+        :href="attachment.url"
+        @click.stop.prevent="openModal"
+      >
+        <audio
+          v-if="type === 'audio'"
+          :src="attachment.url"
+          :alt="attachment.description"
+          :title="attachment.description"
+          controls
+          @play="$emit('play')"
+          @pause="$emit('pause')"
+        />
+      </span>
 
-    <audio
-      v-if="type === 'audio'"
-      :src="attachment.url"
-      :alt="attachment.description"
-      :title="attachment.description"
-      controls
-      @play="$emit('play')"
-      @pause="$emit('pause')"
-    />
+      <div
+        v-if="type === 'html' && attachment.oembed"
+        class="oembed-container"
+        @click.prevent="linkClicked"
+      >
+        <div
+          v-if="attachment.thumb_url"
+          class="image"
+        >
+          <img :src="attachment.thumb_url">
+        </div>
+        <div class="text">
+          <!-- eslint-disable vue/no-v-html -->
+          <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
+          <div v-html="attachment.oembed.oembedHTML" />
+          <!-- eslint-enable vue/no-v-html -->
+        </div>
+      </div>
 
+      <span
+        v-if="type === 'flash' && !hidden"
+        class="flash-container"
+        :href="attachment.url"
+        @click.stop.prevent="openModal"
+      >
+        <Flash
+          ref="flash"
+          class="flash"
+          :src="attachment.large_thumb_url || attachment.url"
+          @playerOpened="setFlashLoaded(true)"
+          @playerClosed="setFlashLoaded(false)"
+        />
+      </span>
+    </div>
     <div
-      v-if="type === 'html' && attachment.oembed"
-      class="oembed"
-      @click.prevent="linkClicked"
+      v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
+      class="description-container"
+      :class="{ '-static': !edit }"
     >
-      <div
-        v-if="attachment.thumb_url"
-        class="image"
+      <input
+        v-if="edit"
+        v-model="localDescription"
+        type="text"
+        class="description-field"
+        :placeholder="$t('post_status.media_description')"
+        @keydown.enter.prevent=""
       >
-        <img :src="attachment.thumb_url">
-      </div>
-      <div class="text">
-        <!-- eslint-disable vue/no-v-html -->
-        <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
-        <div v-html="attachment.oembed.oembedHTML" />
-        <!-- eslint-enable vue/no-v-html -->
-      </div>
+      <p v-else>
+        {{ localDescription }}
+      </p>
     </div>
-
-    <Flash
-      v-if="type === 'flash'"
-      :src="attachment.large_thumb_url || attachment.url"
-    />
   </div>
 </template>
 
 <script src="./attachment.js"></script>
 
-<style lang="scss">
-@import '../../_variables.scss';
-
-.attachments {
-  display: flex;
-  flex-wrap: wrap;
-
-  .non-gallery {
-    max-width: 100%;
-  }
-
-  .placeholder {
-    display: inline-block;
-    padding: 0.3em 1em 0.3em 0;
-    color: $fallback--link;
-    color: var(--postLink, $fallback--link);
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    max-width: 100%;
-
-    svg {
-      color: inherit;
-    }
-  }
-
-  .nsfw-placeholder {
-    cursor: pointer;
-
-    &.loading {
-      cursor: progress;
-    }
-  }
-
-  .attachment {
-    position: relative;
-    margin-top: 0.5em;
-    align-self: flex-start;
-    line-height: 0;
-
-    border-style: solid;
-    border-width: 1px;
-    border-radius: $fallback--attachmentRadius;
-    border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
-    border-color: $fallback--border;
-    border-color: var(--border, $fallback--border);
-    overflow: hidden;
-  }
-
-  .non-gallery.attachment {
-    &.flash,
-    &.video {
-      flex: 1 0 40%;
-    }
-    .nsfw {
-      height: 260px;
-    }
-    .small {
-      height: 120px;
-      flex-grow: 0;
-    }
-    .video {
-      height: 260px;
-      display: flex;
-    }
-    video {
-      max-height: 100%;
-      object-fit: contain;
-    }
-  }
-
-  .fullwidth {
-    flex-basis: 100%;
-  }
-  // fixes small gap below video
-  &.video {
-    line-height: 0;
-  }
-
-  .video-container {
-    display: flex;
-    max-height: 100%;
-  }
-
-  .video {
-    width: 100%;
-    height: 100%;
-  }
-
-  .play-icon {
-    position: absolute;
-    font-size: 64px;
-    top: calc(50% - 32px);
-    left: calc(50% - 32px);
-    color: rgba(255, 255, 255, 0.75);
-    text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
-  }
-
-  .play-icon::before {
-    margin: 0;
-  }
-
-  &.html {
-    flex-basis: 90%;
-    width: 100%;
-    display: flex;
-  }
-
-  .hider {
-    position: absolute;
-    right: 0;
-    margin: 10px;
-    padding: 0;
-    z-index: 4;
-    border-radius: $fallback--tooltipRadius;
-    border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-    text-align: center;
-    width: 2em;
-    height: 2em;
-    font-size: 1.25em;
-    // TODO: theming? hard to theme with unknown background image color
-    background: rgba(230, 230, 230, 0.7);
-    .svg-inline--fa {
-      color: rgba(0, 0, 0, 0.6);
-    }
-    &:hover .svg-inline--fa {
-      color: rgba(0, 0, 0, 0.9);
-    }
-  }
-
-  video {
-    z-index: 0;
-  }
-
-  audio {
-    width: 100%;
-  }
-
-  img.media-upload {
-    line-height: 0;
-    max-height: 200px;
-    max-width: 100%;
-  }
-
-  .oembed {
-    line-height: 1.2em;
-    flex: 1 0 100%;
-    width: 100%;
-    margin-right: 15px;
-    display: flex;
-
-    img {
-      width: 100%;
-    }
-
-    .image {
-      flex: 1;
-      img {
-        border: 0px;
-        border-radius: 5px;
-        height: 100%;
-        object-fit: cover;
-      }
-    }
-
-    .text {
-      flex: 2;
-      margin: 8px;
-      word-break: break-all;
-      h1 {
-        font-size: 14px;
-        margin: 0px;
-      }
-    }
-  }
-
-  .image-attachment {
-    &,
-    & .image {
-      width: 100%;
-      height: 100%;
-    }
-
-    &.hidden {
-      display: none;
-    }
-
-    .nsfw {
-      object-fit: cover;
-      width: 100%;
-      height: 100%;
-    }
-
-    img {
-      image-orientation: from-image; // NOTE: only FF supports this
-    }
-  }
-}
-</style>
+<style src="./attachment.scss" lang="scss"></style>

+ 0 - 4
src/components/chat_message/chat_message.scss

@@ -62,10 +62,6 @@
     &.with-media {
       width: 100%;
 
-      .gallery-row {
-        overflow: hidden;
-      }
-
       .status {
         width: 100%;
       }

+ 3 - 2
src/components/flash/flash.js

@@ -39,12 +39,13 @@ const Flash = {
           this.player = 'error'
         })
         this.ruffleInstance = player
+        this.$emit('playerOpened')
       })
     },
     closePlayer () {
-      console.log(this.ruffleInstance)
-      this.ruffleInstance.remove()
+      this.ruffleInstance && this.ruffleInstance.remove()
       this.player = false
+      this.$emit('playerClosed')
     }
   }
 }

+ 12 - 16
src/components/flash/flash.vue

@@ -36,13 +36,6 @@
         </p>
       </span>
     </button>
-    <button
-      v-if="player"
-      class="button-unstyled hider"
-      @click="closePlayer"
-    >
-      <FAIcon icon="stop" />
-    </button>
   </div>
 </template>
 
@@ -51,8 +44,9 @@
 <style lang="scss">
 @import '../../_variables.scss';
 .Flash {
+  display: inline-block;
   width: 100%;
-  height: 260px;
+  height: 100%;
   position: relative;
 
   .player {
@@ -60,6 +54,16 @@
     width: 100%;
   }
 
+  .placeholder {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--bg);
+    color: var(--link);
+  }
+
   .hider {
     top: 0;
   }
@@ -76,13 +80,5 @@
     display: none;
     visibility: 'hidden';
   }
-
-  .placeholder {
-    height: 100%;
-    flex: 1;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
 }
 </style>

+ 81 - 16
src/components/gallery/gallery.js

@@ -1,15 +1,26 @@
 import Attachment from '../attachment/attachment.vue'
-import { chunk, last, dropRight, sumBy } from 'lodash'
+import { sumBy } from 'lodash'
 
 const Gallery = {
   props: [
     'attachments',
+    'limitRows',
+    'descriptions',
+    'limit',
     'nsfw',
-    'setMedia'
+    'setMedia',
+    'size',
+    'editable',
+    'removeAttachment',
+    'shiftUpAttachment',
+    'shiftDnAttachment',
+    'editAttachment',
+    'grid'
   ],
   data () {
     return {
-      sizes: {}
+      sizes: {},
+      hidingLong: true
     }
   },
   components: { Attachment },
@@ -18,26 +29,70 @@ const Gallery = {
       if (!this.attachments) {
         return []
       }
-      const rows = chunk(this.attachments, 3)
-      if (last(rows).length === 1 && rows.length > 1) {
-        // if 1 attachment on last row -> add it to the previous row instead
-        const lastAttachment = last(rows)[0]
-        const allButLastRow = dropRight(rows)
-        last(allButLastRow).push(lastAttachment)
-        return allButLastRow
+      const attachments = this.limit > 0
+        ? this.attachments.slice(0, this.limit)
+        : this.attachments
+      if (this.size === 'hide') {
+        return attachments.map(item => ({ minimal: true, items: [item] }))
       }
+      const rows = this.grid
+        ? [{ grid: true, items: attachments }]
+        : attachments.reduce((acc, attachment, i) => {
+          if (attachment.mimetype.includes('audio')) {
+            return [...acc, { audio: true, items: [attachment] }, { items: [] }]
+          }
+          if (!(
+            attachment.mimetype.includes('image') ||
+              attachment.mimetype.includes('video') ||
+              attachment.mimetype.includes('flash')
+          )) {
+            return [...acc, { minimal: true, items: [attachment] }, { items: [] }]
+          }
+          const maxPerRow = 3
+          const attachmentsRemaining = this.attachments.length - i + 1
+          const currentRow = acc[acc.length - 1].items
+          currentRow.push(attachment)
+          if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) {
+            return [...acc, { items: [] }]
+          } else {
+            return acc
+          }
+        }, [{ items: [] }]).filter(_ => _.items.length > 0)
       return rows
     },
-    useContainFit () {
-      return this.$store.getters.mergedConfig.useContainFit
+    attachmentsDimensionalScore () {
+      return this.rows.reduce((acc, row) => {
+        let size = 0
+        if (row.minimal) {
+          size += 1 / 8
+        } else if (row.audio) {
+          size += 1 / 4
+        } else {
+          size += 1 / (row.items.length + 0.6)
+        }
+        return acc + size
+      }, 0)
+    },
+    tooManyAttachments () {
+      if (this.editable || this.size === 'small') {
+        return false
+      } else if (this.size === 'hide') {
+        return this.attachments.length > 8
+      } else {
+        return this.attachmentsDimensionalScore > 1
+      }
     }
   },
   methods: {
-    onNaturalSizeLoad (id, size) {
-      this.$set(this.sizes, id, size)
+    onNaturalSizeLoad ({ id, width, height }) {
+      this.$set(this.sizes, id, { width, height })
     },
-    rowStyle (itemsPerRow) {
-      return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` }
+    rowStyle (row) {
+      if (row.audio) {
+        return { 'padding-bottom': '25%' } // fixed reduced height for audio
+      } else if (!row.minimal && !row.grid) {
+        return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
+      }
     },
     itemStyle (id, row) {
       const total = sumBy(row, item => this.getAspectRatio(item.id))
@@ -46,6 +101,16 @@ const Gallery = {
     getAspectRatio (id) {
       const size = this.sizes[id]
       return size ? size.width / size.height : 1
+    },
+    toggleHidingLong (event) {
+      this.hidingLong = event
+    },
+    openGallery () {
+      this.$store.dispatch('setMedia', this.attachments)
+      this.$store.dispatch('setCurrentMedia', this.attachments[0])
+    },
+    onMedia () {
+      this.$store.dispatch('setMedia', this.attachments)
     }
   }
 }

+ 151 - 51
src/components/gallery/gallery.vue

@@ -1,26 +1,84 @@
 <template>
   <div
     ref="galleryContainer"
-    style="width: 100%;"
+    class="Gallery"
+    :class="{ '-long': tooManyAttachments && hidingLong }"
   >
+    <div class="gallery-rows">
+      <div
+        v-for="(row, rowIndex) in rows"
+        :key="rowIndex"
+        class="gallery-row"
+        :style="rowStyle(row)"
+        :class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }"
+      >
+        <div
+          class="gallery-row-inner"
+          :class="{ '-grid': grid }"
+        >
+          <Attachment
+            v-for="(attachment, attachmentIndex) in row.items"
+            :key="attachment.id"
+            class="gallery-item"
+            :nsfw="nsfw"
+            :attachment="attachment"
+            :allow-play="false"
+            :size="size"
+            :editable="editable"
+            :remove="removeAttachment"
+            :shiftUp="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment"
+            :shiftDn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment"
+            :edit="editAttachment"
+            :description="descriptions && descriptions[attachment.id]"
+            :hide-description="size === 'small' || tooManyAttachments && hidingLong"
+            :style="itemStyle(attachment.id, row.items)"
+            @setMedia="onMedia"
+            @naturalSizeLoad="onNaturalSizeLoad"
+          />
+        </div>
+      </div>
+    </div>
     <div
-      v-for="(row, index) in rows"
-      :key="index"
-      class="gallery-row"
-      :style="rowStyle(row.length)"
-      :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }"
+      v-if="tooManyAttachments"
+      class="many-attachments"
     >
-      <div class="gallery-row-inner">
-        <attachment
-          v-for="attachment in row"
-          :key="attachment.id"
-          :set-media="setMedia"
-          :nsfw="nsfw"
-          :attachment="attachment"
-          :allow-play="false"
-          :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)"
-          :style="itemStyle(attachment.id, row)"
-        />
+      <div class="many-attachments-text">
+        {{ $t("status.many_attachments", { number: attachments.length }) }}
+      </div>
+      <div class="many-attachments-buttons">
+        <span
+          v-if="!hidingLong"
+          class="many-attachments-button"
+        >
+          <button
+            class="button-unstyled -link"
+            @click="toggleHidingLong(true)"
+          >
+            {{ $t("status.collapse_attachments") }}
+          </button>
+        </span>
+        <span
+          v-if="hidingLong"
+          class="many-attachments-button"
+        >
+          <button
+            class="button-unstyled -link"
+            @click="toggleHidingLong(false)"
+          >
+            {{ $t("status.show_all_attachments") }}
+          </button>
+        </span>
+        <span
+          v-if="hidingLong"
+          class="many-attachments-button"
+        >
+          <button
+            class="button-unstyled -link"
+            @click="openGallery"
+          >
+            {{ $t("status.open_gallery") }}
+          </button>
+        </span>
       </div>
     </div>
   </div>
@@ -31,12 +89,66 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-.gallery-row {
-  position: relative;
-  height: 0;
-  width: 100%;
-  flex-grow: 1;
-  margin-top: 0.5em;
+.Gallery {
+  .gallery-rows {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .gallery-row {
+    position: relative;
+    height: 0;
+    width: 100%;
+    flex-grow: 1;
+
+    &:not(:first-child) {
+      margin-top: 0.5em;
+    }
+  }
+
+  &.-long {
+    .gallery-rows {
+      max-height: 25em;
+      overflow: hidden;
+      mask:
+        linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+        linear-gradient(to top, white, white);
+
+      /* Autoprefixed seem to ignore this one, and also syntax is different */
+      -webkit-mask-composite: xor;
+      mask-composite: exclude;
+    }
+  }
+
+  .many-attachments-text {
+    text-align: center;
+    line-height: 2;
+  }
+
+  .many-attachments-buttons {
+    display: flex;
+  }
+
+  .many-attachments-button {
+    display: flex;
+    flex: 1;
+    justify-content: center;
+    line-height: 2;
+
+    button {
+      padding: 0 2em;
+    }
+  }
+
+  .gallery-row {
+    &.-grid,
+    &.-minimal {
+      height: auto;
+      .gallery-row-inner {
+        position: relative;
+      }
+    }
+  }
 
   .gallery-row-inner {
     position: absolute;
@@ -48,9 +160,24 @@
     flex-direction: row;
     flex-wrap: nowrap;
     align-content: stretch;
+
+    &.-grid {
+      width: 100%;
+      height: auto;
+      position: relative;
+      display: grid;
+      grid-column-gap: 0.5em;
+      grid-row-gap: 0.5em;
+      grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
+
+      .gallery-item {
+        margin: 0;
+        height: 200px;
+      }
+    }
   }
 
-  .gallery-row-inner .attachment {
+  .gallery-item {
     margin: 0 0.5em 0 0;
     flex-grow: 1;
     height: 100%;
@@ -61,32 +188,5 @@
       margin: 0;
     }
   }
-
-  .image-attachment {
-    width: 100%;
-    height: 100%;
-  }
-
-  .video-container {
-    height: 100%;
-  }
-
-  &.contain-fit {
-    img,
-    video,
-    canvas {
-      object-fit: contain;
-      height: 100%;
-    }
-  }
-
-  &.cover-fit {
-    img,
-    video,
-    canvas {
-      object-fit: cover;
-    }
-  }
 }
-
 </style>

+ 32 - 6
src/components/media_modal/media_modal.js

@@ -3,22 +3,31 @@ 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 GestureService from '../../services/gesture_service/gesture_service'
+import Flash from 'src/components/flash/flash.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faChevronLeft,
-  faChevronRight
+  faChevronRight,
+  faCircleNotch
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
   faChevronLeft,
-  faChevronRight
+  faChevronRight,
+  faCircleNotch
 )
 
 const MediaModal = {
   components: {
     StillImage,
     VideoAttachment,
-    Modal
+    Modal,
+    Flash
+  },
+  data () {
+    return {
+      loading: false
+    }
   },
   computed: {
     showing () {
@@ -27,6 +36,9 @@ const MediaModal = {
     media () {
       return this.$store.state.mediaViewer.media
     },
+    description () {
+      return this.currentMedia.description
+    },
     currentIndex () {
       return this.$store.state.mediaViewer.currentIndex
     },
@@ -37,7 +49,7 @@ const MediaModal = {
       return this.media.length > 1
     },
     type () {
-      return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
+      return this.currentMedia ? this.getType(this.currentMedia) : null
     }
   },
   created () {
@@ -53,6 +65,9 @@ const MediaModal = {
     )
   },
   methods: {
+    getType (media) {
+      return fileTypeService.fileType(media.mimetype)
+    },
     mediaTouchStart (e) {
       GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
       GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
@@ -67,15 +82,26 @@ const MediaModal = {
     goPrev () {
       if (this.canNavigate) {
         const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1)
-        this.$store.dispatch('setCurrent', this.media[prevIndex])
+        const newMedia = this.media[prevIndex]
+        if (this.getType(newMedia) === 'image') {
+          this.loading = true
+        }
+        this.$store.dispatch('setCurrentMedia', newMedia)
       }
     },
     goNext () {
       if (this.canNavigate) {
         const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1)
-        this.$store.dispatch('setCurrent', this.media[nextIndex])
+        const newMedia = this.media[nextIndex]
+        if (this.getType(newMedia) === 'image') {
+          this.loading = true
+        }
+        this.$store.dispatch('setCurrentMedia', newMedia)
       }
     },
+    onImageLoaded () {
+      this.loading = false
+    },
     handleKeyupEvent (e) {
       if (this.showing && e.keyCode === 27) { // escape
         this.hide()

+ 117 - 47
src/components/media_modal/media_modal.vue

@@ -6,6 +6,7 @@
   >
     <img
       v-if="type === 'image'"
+      :class="{ loading }"
       class="modal-image"
       :src="currentMedia.url"
       :alt="currentMedia.description"
@@ -13,6 +14,7 @@
       @touchstart.stop="mediaTouchStart"
       @touchmove.stop="mediaTouchMove"
       @click="hide"
+      @load="onImageLoaded"
     >
     <VideoAttachment
       v-if="type === 'video'"
@@ -28,6 +30,13 @@
       :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')"
@@ -50,6 +59,27 @@
         icon="chevron-right"
       />
     </button>
+    <span
+      v-if="description"
+      class="description"
+    >
+      {{ description }}
+    </span>
+    <span
+      class="counter"
+    >
+      {{ currentIndex + 1 }} / {{ media.length }}
+    </span>
+    <span
+      v-if="loading"
+      class="loading-spinner"
+    >
+      <FAIcon
+        spin
+        icon="circle-notch"
+        size="5x"
+      />
+    </span>
   </Modal>
 </template>
 
@@ -58,6 +88,7 @@
 <style lang="scss">
 .modal-view.media-modal-view {
   z-index: 1001;
+  flex-direction: column;
 
   .modal-view-button-arrow {
     opacity: 0.75;
@@ -67,69 +98,108 @@
       outline: none;
       box-shadow: none;
     }
+
     &:hover {
       opacity: 1;
     }
   }
 }
 
-@keyframes media-fadein {
-  from {
-    opacity: 0;
+.media-modal-view {
+  @keyframes media-fadein {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
   }
-  to {
-    opacity: 1;
+
+  .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;
   }
-}
 
-.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;
-}
+  .description {
+    flex: 0 0 auto;
+    overflow-y: auto;
+    min-height: 1em;
+    max-width: 500px;
+    max-height: 9.5em;
+    word-break: break-all;
+  }
 
-.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);
+  .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;
+    }
   }
 
-  &--prev {
-    left: 0;
-    .arrow-icon {
-      left: 6px;
+  .loading-spinner {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    pointer-events: none;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    svg {
+      color: white;
     }
   }
 
-  &--next {
-    right: 0;
+  .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 {
-      right: 6px;
+      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;
+      }
     }
   }
 }

+ 6 - 0
src/components/notification/notification.scss

@@ -9,6 +9,12 @@
    word-break: break-word;
    --emoji-size: 14px;
 
+  &:hover {
+    --_still-image-img-visibility: visible;
+    --_still-image-canvas-visibility: hidden;
+    --_still-image-label-visibility: hidden;
+  }
+
   &.-muted {
     padding: 0.25em 0.6em;
     height: 1.2em;

+ 2 - 1
src/components/notification/notification.vue

@@ -184,8 +184,9 @@
           </router-link>
         </div>
         <template v-else>
-          <status-content
+          <StatusContent
             class="faint"
+            :compact="true"
             :status="notification.action"
           />
         </template>

+ 18 - 1
src/components/post_status_form/post_status_form.js

@@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue'
 import EmojiInput from '../emoji_input/emoji_input.vue'
 import PollForm from '../poll/poll_form.vue'
 import Attachment from '../attachment/attachment.vue'
+import Gallery from 'src/components/gallery/gallery.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'
@@ -85,7 +86,8 @@ const PostStatusForm = {
     Checkbox,
     Select,
     Attachment,
-    StatusContent
+    StatusContent,
+    Gallery
   },
   mounted () {
     this.updateIdempotencyKey()
@@ -388,6 +390,21 @@ const PostStatusForm = {
       this.newStatus.files.splice(index, 1)
       this.$emit('resize')
     },
+    editAttachment (fileInfo, newText) {
+      this.newStatus.mediaDescriptions[fileInfo.id] = newText
+    },
+    shiftUpMediaFile (fileInfo) {
+      const { files } = this.newStatus
+      const index = this.newStatus.files.indexOf(fileInfo)
+      files.splice(index, 1)
+      files.splice(index - 1, 0, fileInfo)
+    },
+    shiftDnMediaFile (fileInfo) {
+      const { files } = this.newStatus
+      const index = this.newStatus.files.indexOf(fileInfo)
+      files.splice(index, 1)
+      files.splice(index + 1, 0, fileInfo)
+    },
     uploadFailed (errString, templateArgs) {
       templateArgs = templateArgs || {}
       this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)

+ 20 - 59
src/components/post_status_form/post_status_form.vue

@@ -287,32 +287,22 @@
           @click="clearError"
         />
       </div>
-      <div class="attachments">
-        <div
-          v-for="file in newStatus.files"
-          :key="file.url"
-          class="media-upload-wrapper"
-        >
-          <button
-            class="button-unstyled hider"
-            @click="removeMediaFile(file)"
-          >
-            <FAIcon icon="times" />
-          </button>
-          <attachment
-            :attachment="file"
-            :set-media="() => $store.dispatch('setMedia', newStatus.files)"
-            size="small"
-            allow-play="false"
-          />
-          <input
-            v-model="newStatus.mediaDescriptions[file.id]"
-            type="text"
-            :placeholder="$t('post_status.media_description')"
-            @keydown.enter.prevent=""
-          >
-        </div>
-      </div>
+      <gallery
+        v-if="newStatus.files && newStatus.files.length > 0"
+        class="attachments"
+        :grid="true"
+        :nsfw="false"
+        :attachments="newStatus.files"
+        :descriptions="newStatus.mediaDescriptions"
+        :set-media="() => $store.dispatch('setMedia', newStatus.files)"
+        :editable="true"
+        :edit-attachment="editAttachment"
+        :remove-attachment="removeMediaFile"
+        :shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
+        :shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
+        @play="$emit('mediaplay', attachment.id)"
+        @pause="$emit('mediapause', attachment.id)"
+      />
       <div
         v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
         class="upload_settings"
@@ -330,26 +320,13 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-.tribute-container {
-  ul {
-    padding: 0px;
-    li {
-      display: flex;
-      align-items: center;
-    }
-  }
-  img {
-    padding: 3px;
-    width: 16px;
-    height: 16px;
-    border-radius: $fallback--avatarAltRadius;
-    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
-  }
-}
-
 .post-status-form {
   position: relative;
 
+  .attachments {
+    margin-bottom: 0.5em;
+  }
+
   .form-bottom {
     display: flex;
     justify-content: space-between;
@@ -507,15 +484,6 @@
     flex-direction: column;
   }
 
-   .attachments .media-upload-wrapper {
-    position: relative;
-
-    .attachment {
-      margin: 0;
-      padding: 0;
-    }
-  }
-
   .btn {
     cursor: pointer;
   }
@@ -616,11 +584,4 @@
     border: 2px dashed var(--text, $fallback--text);
   }
 }
-
-// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
-img.media-upload, .media-upload-container > video {
-  line-height: 0;
-  max-height: 200px;
-  max-width: 100%;
-}
 </style>

+ 2 - 0
src/components/status_body/status_body.js

@@ -21,6 +21,7 @@ library.add(
 const StatusContent = {
   name: 'StatusContent',
   props: [
+    'compact',
     'status',
     'focused',
     'noHeading',
@@ -49,6 +50,7 @@ const StatusContent = {
     // Using max-height + overflow: auto for status components resulted in false positives
     // very often with japanese characters, and it was very annoying.
     tallStatus () {
+      if (this.singleLine || this.compact) return false
       const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
       return lengthScore > 20
     },

+ 56 - 0
src/components/status_body/status_body.scss

@@ -1,11 +1,17 @@
 @import '../../_variables.scss';
 
 .StatusBody {
+  display: flex;
+  flex-direction: column;
 
   .emoji {
     --_still_image-label-scale: 0.5;
   }
 
+  .attachments {
+    margin-top: 0.5em;
+  }
+
   & .text,
   & .summary {
     font-family: var(--postFont, sans-serif);
@@ -115,4 +121,54 @@
   .cyantext {
     color: var(--postCyantext, $fallback--cBlue);
   }
+
+  &.-compact {
+    align-items: top;
+    flex-direction: row;
+
+    --emoji-size: 16px;
+
+    & .body,
+    & .attachments {
+      max-height: 3.25em;
+    }
+
+    .body {
+      overflow: hidden;
+      white-space: normal;
+      min-width: 5em;
+      flex: 5 1 auto;
+      mask-size: auto 3.5em, auto auto;
+      mask-position: 0 0, 0 0;
+      mask-repeat: repeat-x, repeat;
+      mask-image: linear-gradient(to bottom, white 2em, transparent 3em);
+
+      /* Autoprefixed seem to ignore this one, and also syntax is different */
+      -webkit-mask-composite: xor;
+      mask-composite: exclude;
+    }
+
+    .attachments {
+      margin-top: 0;
+      flex: 1 1 0;
+      min-width: 5em;
+      height: 100%;
+      margin-left: 0.5em;
+    }
+
+    .summary-wrapper {
+      .summary::after {
+        content: ': ';
+      }
+
+      line-height: inherit;
+      margin: 0;
+      border: none;
+      display: inline-block;
+    }
+
+    .text-wrapper {
+      display: inline-block;
+    }
+  }
 }

+ 4 - 1
src/components/status_body/status_body.vue

@@ -1,5 +1,8 @@
 <template>
-  <div class="StatusBody">
+  <div
+    class="StatusBody"
+    :class="{ '-compact': compact }"
+  >
     <div class="body">
       <div
         v-if="status.summary_raw_html"

+ 4 - 28
src/components/status_content/status_content.js

@@ -3,7 +3,6 @@ import Poll from '../poll/poll.vue'
 import Gallery from '../gallery/gallery.vue'
 import StatusBody from 'src/components/status_body/status_body.vue'
 import LinkPreview from '../link-preview/link-preview.vue'
-import fileType from 'src/services/file_type/file_type.service'
 import { mapGetters, mapState } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -28,6 +27,7 @@ const StatusContent = {
   name: 'StatusContent',
   props: [
     'status',
+    'compact',
     'focused',
     'noHeading',
     'fullContent',
@@ -48,33 +48,15 @@ const StatusContent = {
       return true
     },
     attachmentSize () {
-      if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
+      if (this.compact) {
+        return 'small'
+      } else if ((this.mergedConfig.hideAttachments && !this.inConversation) ||
         (this.mergedConfig.hideAttachmentsInConv && this.inConversation) ||
         (this.status.attachments.length > this.maxThumbnails)) {
         return 'hide'
-      } else if (this.compact) {
-        return 'small'
       }
       return 'normal'
     },
-    galleryTypes () {
-      if (this.attachmentSize === 'hide') {
-        return []
-      }
-      return this.mergedConfig.playVideosInModal
-        ? ['image', 'video']
-        : ['image']
-    },
-    galleryAttachments () {
-      return this.status.attachments.filter(
-        file => fileType.fileMatchesSomeType(this.galleryTypes, file)
-      )
-    },
-    nonGalleryAttachments () {
-      return this.status.attachments.filter(
-        file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
-      )
-    },
     maxThumbnails () {
       return this.mergedConfig.maxThumbnails
     },
@@ -89,12 +71,6 @@ const StatusContent = {
     Gallery,
     LinkPreview,
     StatusBody
-  },
-  methods: {
-    setMedia () {
-      const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
-      return () => this.$store.dispatch('setMedia', attachments)
-    }
   }
 }
 

+ 22 - 24
src/components/status_content/status_content.vue

@@ -1,44 +1,42 @@
 <template>
-  <div class="StatusContent">
+  <div
+    class="StatusContent"
+    :class="{ '-compact': compact }"
+  >
     <slot name="header" />
     <StatusBody
       :status="status"
+      :compact="compact"
       :single-line="singleLine"
       @parseReady="$emit('parseReady', $event)"
     >
-      <div v-if="status.poll && status.poll.options">
+      <div v-if="status.poll && status.poll.options && !compact">
         <Poll
           :base-poll="status.poll"
           :emoji="status.emojis"
         />
       </div>
 
-      <div
-        v-if="status.attachments.length !== 0"
-        class="attachments media-body"
-      >
-        <attachment
-          v-for="attachment in nonGalleryAttachments"
-          :key="attachment.id"
-          class="non-gallery"
-          :size="attachmentSize"
-          :nsfw="nsfwClickthrough"
-          :attachment="attachment"
-          :allow-play="true"
-          :set-media="setMedia()"
-          @play="$emit('mediaplay', attachment.id)"
-          @pause="$emit('mediapause', attachment.id)"
-        />
-        <gallery
-          v-if="galleryAttachments.length > 0"
-          :nsfw="nsfwClickthrough"
-          :attachments="galleryAttachments"
-          :set-media="setMedia()"
+      <div v-else-if="status.poll && status.poll.options && compact">
+        <FAIcon
+          icon="poll-h"
+          size="2x"
         />
       </div>
 
+      <gallery
+        v-if="status.attachments.length !== 0"
+        class="attachments media-body"
+        :nsfw="nsfwClickthrough"
+        :attachments="status.attachments"
+        :limit="compact ? 1 : 0"
+        :size="attachmentSize"
+        @play="$emit('mediaplay', attachment.id)"
+        @pause="$emit('mediapause', attachment.id)"
+      />
+
       <div
-        v-if="status.card && !noHeading"
+        v-if="status.card && !noHeading && !compact"
         class="link-preview media-body"
       >
         <link-preview

+ 1 - 1
src/components/user_card/user_card.js

@@ -166,7 +166,7 @@ export default {
         mimetype: 'image'
       }
       this.$store.dispatch('setMedia', [attachment])
-      this.$store.dispatch('setCurrent', attachment)
+      this.$store.dispatch('setCurrentMedia', attachment)
     },
     mentionUser () {
       this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })

+ 12 - 1
src/i18n/en.json

@@ -733,7 +733,18 @@
     "nsfw": "NSFW",
     "expand": "Expand",
     "you": "(You)",
-    "plus_more": "+{number} more"
+    "plus_more": "+{number} more",
+    "many_attachments": "Post has {number} attachment(s)",
+    "collapse_attachments": "Collapse attachments",
+    "show_all_attachments": "Show all attachments",
+    "show_attachment_in_modal": "Show in media modal",
+    "show_attachment_description": "Preview description (open attachment for full description)",
+    "hide_attachment": "Hide attachment",
+    "remove_attachment": "Remove attachment",
+    "attachment_stop_flash": "Stop Flash player",
+    "move_up": "Shift attachment left",
+    "move_down": "Shift attachment right",
+    "open_gallery": "Open gallery"
   },
   "user_card": {
     "approve": "Approve",

+ 5 - 4
src/modules/media_viewer.js

@@ -1,4 +1,5 @@
 import fileTypeService from '../services/file_type/file_type.service.js'
+const supportedTypes = new Set(['image', 'video', 'audio', 'flash'])
 
 const mediaViewer = {
   state: {
@@ -10,7 +11,7 @@ const mediaViewer = {
     setMedia (state, media) {
       state.media = media
     },
-    setCurrent (state, index) {
+    setCurrentMedia (state, index) {
       state.activated = true
       state.currentIndex = index
     },
@@ -22,13 +23,13 @@ const mediaViewer = {
     setMedia ({ commit }, attachments) {
       const media = attachments.filter(attachment => {
         const type = fileTypeService.fileType(attachment.mimetype)
-        return type === 'image' || type === 'video' || type === 'audio'
+        return supportedTypes.has(type)
       })
       commit('setMedia', media)
     },
-    setCurrent ({ commit, state }, current) {
+    setCurrentMedia ({ commit, state }, current) {
       const index = state.media.indexOf(current)
-      commit('setCurrent', index || 0)
+      commit('setCurrentMedia', index || 0)
     },
     closeMediaViewer ({ commit }) {
       commit('close')