From 485a061287149fe0ff5c4a188d21d775ff271f6d Mon Sep 17 00:00:00 2001
From: shpuld <shp@cock.li>
Date: Sun, 20 Jan 2019 12:46:11 +0200
Subject: Polish for videos, smaller sizes, remove gif-looping options

---
 src/components/settings/settings.vue | 9 ---------
 1 file changed, 9 deletions(-)

(limited to 'src/components/settings/settings.vue')

diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index 39125009..e84bd3f6 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -131,15 +131,6 @@
             <li>
               <input type="checkbox" id="loopVideo" v-model="loopVideoLocal">
               <label for="loopVideo">{{$t('settings.loop_video')}}</label>
-              <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
-                <li>
-                  <input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal">
-                  <label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label>
-                  <div v-if="!loopSilentAvailable" class="unavailable">
-                    <i class="icon-globe"/>! {{$t('settings.limited_availability')}}
-                  </div>
-                </li>
-              </ul>
             </li>
           </ul>
         </div>
-- 
cgit v1.2.3-70-g09d2


From 3978aaef84cc023908155343af76983f2715cf90 Mon Sep 17 00:00:00 2001
From: shpuld <shp@cock.li>
Date: Sat, 26 Jan 2019 17:45:03 +0200
Subject: Redo everything in the MR

---
 src/App.scss                                       |   8 +-
 src/assets/nsfw.png                                | Bin 39603 -> 35104 bytes
 src/components/attachment/attachment.js            |  47 +++++-
 src/components/attachment/attachment.vue           |  94 ++++++-----
 src/components/gallery/gallery.js                  |  55 +++++++
 src/components/gallery/gallery.vue                 |  60 +++++++
 src/components/media_modal/media_modal.js          |   7 +-
 src/components/media_modal/media_modal.vue         |  13 +-
 src/components/registration/registration.vue       |   2 +-
 src/components/settings/settings.js                |  25 ++-
 src/components/settings/settings.vue               |  21 +++
 src/components/status/status.js                    |  25 ++-
 src/components/status/status.vue                   |  21 ++-
 .../video_attachment/video_attachment.js           |  31 ++++
 .../video_attachment/video_attachment.vue          |  11 ++
 src/i18n/en.json                                   |   3 +
 src/i18n/fi.json                                   | 182 ++++++++++++++++++++-
 src/services/file_type/file_type.service.js        |  31 ++--
 .../specs/services/file_type/file_type.spec.js     |  19 +++
 19 files changed, 563 insertions(+), 92 deletions(-)
 create mode 100644 src/components/gallery/gallery.js
 create mode 100644 src/components/gallery/gallery.vue
 create mode 100644 src/components/video_attachment/video_attachment.js
 create mode 100644 src/components/video_attachment/video_attachment.vue
 create mode 100644 test/unit/specs/services/file_type/file_type.spec.js

(limited to 'src/components/settings/settings.vue')

diff --git a/src/App.scss b/src/App.scss
index ed06bbbc..425c8e01 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -499,7 +499,7 @@ nav {
 }
 
 .main {
-  flex-basis: 60%;
+  flex-basis: 50%;
   flex-grow: 1;
   flex-shrink: 1;
 }
@@ -533,7 +533,7 @@ nav {
   }
 }
 
-@media all and (min-width: 960px) {
+@media all and (min-width: 800px) {
   body {
     overflow-y: scroll;
   }
@@ -617,7 +617,7 @@ nav {
   color: $fallback--faint;
   color: var(--faint, $fallback--faint);
 }
-@media all and (min-width: 959px) {
+@media all and (min-width: 800px) {
   .logo {
     opacity: 1 !important;
   }
@@ -654,7 +654,7 @@ nav {
   border-radius: var(--inputRadius, $fallback--inputRadius);
 }
 
-@media all and (max-width: 959px) {
+@media all and (max-width: 800px) {
   .mobile-hidden {
     display: none;
   }
diff --git a/src/assets/nsfw.png b/src/assets/nsfw.png
index 972bcb4c..d2513776 100644
Binary files a/src/assets/nsfw.png and b/src/assets/nsfw.png differ
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index d16e5086..0ae8f71a 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,4 +1,5 @@
 import StillImage from '../still-image/still-image.vue'
+import VideoAttachment from '../video_attachment/video_attachment.vue'
 import nsfwImage from '../../assets/nsfw.png'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 
@@ -8,6 +9,7 @@ const Attachment = {
     'nsfw',
     'statusId',
     'size',
+    'allowPlay',
     'setMedia'
   ],
   data () {
@@ -16,11 +18,14 @@ const Attachment = {
       hideNsfwLocal: this.$store.state.config.hideNsfw,
       preloadImage: this.$store.state.config.preloadImage,
       loading: false,
-      modalOpen: false
+      img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
+      modalOpen: false,
+      showHidden: false
     }
   },
   components: {
-    StillImage
+    StillImage,
+    VideoAttachment
   },
   computed: {
     usePlaceHolder () {
@@ -33,7 +38,7 @@ const Attachment = {
       return fileTypeService.fileType(this.attachment.mimetype)
     },
     hidden () {
-      return this.nsfw && this.hideNsfwLocal
+      return this.nsfw && this.hideNsfwLocal && !this.showHidden
     },
     isEmpty () {
       return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
@@ -51,14 +56,38 @@ const Attachment = {
         window.open(target.href, '_blank')
       }
     },
-    toggleModal (event) {
-      if (this.type !== 'image' && this.type !== 'video') {
+    openModal (event) {
+      const modalTypes = this.$store.state.config.playVideosInline
+        ? ['image']
+        : ['image', 'video']
+      if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
+        this.usePlaceHolder
+      ) {
+        event.stopPropagation()
+        event.preventDefault()
+        this.setMedia()
+        this.$store.dispatch('setCurrent', this.attachment)
+      }
+    },
+    toggleHidden (event) {
+      if (this.$store.state.config.useOneClickNsfw && !this.showHidden) {
+        this.openModal(event)
         return
       }
-      event.stopPropagation()
-      event.preventDefault()
-      this.setMedia()
-      this.$store.dispatch('setCurrent', this.attachment)
+      if (this.img && !this.preloadImage) {
+        if (this.img.onload) {
+          this.img.onload()
+        } else {
+          this.loading = true
+          this.img.src = this.attachment.url
+          this.img.onload = () => {
+            this.loading = false
+            this.showHidden = !this.showHidden
+          }
+        }
+      } else {
+        this.showHidden = !this.showHidden
+      }
     }
   }
 }
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index ad5120c0..56a49d2d 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,34 +1,43 @@
 <template>
-  <div v-if="usePlaceHolder" @click="toggleModal">
-    <a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a>
+  <div v-if="usePlaceHolder" @click="openModal">
+    <a class="placeholder"
+      v-if="type !== 'html'"
+      target="_blank" :href="attachment.url"
+    >
+      [{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]
+    </a>
   </div>
   <div
     v-else class="attachment"
     :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
     v-show="!isEmpty"
-    @click="toggleModal"
   >
-    <a class="image-attachment" v-if="hidden" :href="attachment.url">
-      <img :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
+    <a class="image-attachment" v-if="hidden" :href="attachment.url" @click.prevent="toggleHidden">
+      <img class="nsfw" :key="nsfwImage" :src="nsfwImage" :class="{'small': isSmall}"/>
       <i v-if="type === 'video'" class="play-icon icon-play-circled"></i>
     </a>
+    <div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
+      <a href="#" @click.prevent="toggleHidden">Hide</a>
+    </div>
 
     <a v-if="type === 'image' && (!hidden || preloadImage)"
+      @click="openModal"
       class="image-attachment"
-      :class="{'hidden': hidden && preloadImage}"
+      :class="{'hidden': hidden && preloadImage }"
       :href="attachment.url" target="_blank"
       :title="attachment.description"
     >
-      <StillImage :class="{'small': isSmall}" referrerpolicy="referrerPolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
+      <StillImage referrerpolicy="referrerPolicy" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
     </a>
 
     <a class="video-container"
+      @click="openModal"
       v-if="type === 'video' && !hidden"
       :class="{'small': isSmall}"
       :href="attachment.url"
     >
-      <video class="video" :src="attachment.url"></video>
-      <i class="play-icon icon-play-circled"></i>
+      <VideoAttachment class="video" :attachment="attachment" :controls="allowPlay" />
+      <i v-if="!allowPlay" class="play-icon icon-play-circled"></i>
     </a>
 
     <audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
@@ -50,18 +59,18 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-$normalheight: 140px;
-$normalwidth: 180px;
-$smallheight: 80px;
-
 .attachments {
   display: flex;
   flex-wrap: wrap;
 
   .attachment.media-upload-container {
     flex: 0 0 auto;
-    max-height: $normalheight;
+    max-height: 200px;
     max-width: 100%;
+    display: flex;
+    video {
+      max-width: 100%;
+    }
   }
 
   .placeholder {
@@ -82,7 +91,6 @@ $smallheight: 80px;
     margin: 0.5em 0.5em 0em 0em;
     align-self: flex-start;
     line-height: 0;
-    flex-grow: 0;
 
     border-style: solid;
     border-width: 1px;
@@ -92,6 +100,28 @@ $smallheight: 80px;
     border-color: var(--border, $fallback--border);
     overflow: hidden;
   }
+
+  .non-gallery.attachment {
+    &.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%;
   }
@@ -101,14 +131,12 @@ $smallheight: 80px;
   }
 
   .video-container {
-    width: auto;
-    height: $normalheight;
-    max-width: $normalwidth;
+    display: flex;
+    max-height: 100%;
   }
 
   .video {
-    width: auto;
-    object-fit: cover;
+    width: 100%;
   }
 
   .play-icon {
@@ -132,6 +160,7 @@ $smallheight: 80px;
 
   .hider {
     position: absolute;
+    white-space: nowrap;
     margin: 10px;
     padding: 5px;
     background: rgba(230,230,230,0.6);
@@ -142,12 +171,7 @@ $smallheight: 80px;
     border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
   }
 
-  .small {
-    max-height: $smallheight;
-  }
-
   video {
-    height: 100%;
     z-index: 0;
   }
 
@@ -157,7 +181,7 @@ $smallheight: 80px;
 
   img.media-upload {
     line-height: 0;
-    max-height: $normalheight;
+    max-height: 200px;
     max-width: 100%;
   }
 
@@ -194,26 +218,20 @@ $smallheight: 80px;
   }
 
   .image-attachment {
-    flex-grow: 0;
+    width: 100%;
+    height: 100%;
 
     &.hidden {
       display: none;
     }
 
-    .still-image {
-    }
-
-    .small {
-      img {
-        height: $smallheight;
-      }
+    .nsfw {
+      object-fit: cover;
+      width: 100%;
+      height: 100%;
     }
 
     img {
-      object-fit: cover;
-      height: $normalheight;
-      width: auto;
-      max-width: $normalwidth;
       image-orientation: from-image;
     }
   }
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
new file mode 100644
index 00000000..7f33a81b
--- /dev/null
+++ b/src/components/gallery/gallery.js
@@ -0,0 +1,55 @@
+import Attachment from '../attachment/attachment.vue'
+import { chunk, last, dropRight } from 'lodash'
+
+const Gallery = {
+  data: () => ({
+    width: 500
+  }),
+  props: [
+    'attachments',
+    'nsfw',
+    'setMedia'
+  ],
+  components: { Attachment },
+  mounted () {
+    this.resize()
+    window.addEventListener('resize', this.resize)
+  },
+  destroyed () {
+    window.removeEventListener('resize', this.resize)
+  },
+  computed: {
+    rows () {
+      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
+      }
+      return rows
+    },
+    rowHeight () {
+      return itemsPerRow => ({ 'height': `${(this.width / (itemsPerRow + 0.6))}px` })
+    },
+    useContainFit () {
+      return this.$store.state.config.useContainFit
+    }
+  },
+  methods: {
+    resize () {
+      // Quick optimization to make resizing not always trigger state change,
+      // only update attachment size in 10px steps
+      const width = Math.floor(this.$el.getBoundingClientRect().width / 10) * 10
+      if (this.width !== width) {
+        this.width = width
+      }
+    }
+  }
+}
+
+export default Gallery
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
new file mode 100644
index 00000000..20e8ab2f
--- /dev/null
+++ b/src/components/gallery/gallery.vue
@@ -0,0 +1,60 @@
+<template>
+  <div ref="galleryContainer" style="width: 100%;">
+    <div class="gallery-row" v-for="row in rows" :style="rowHeight(row.length)" :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }">
+      <attachment
+        v-for="attachment in row"
+        :setMedia="setMedia"
+        :nsfw="nsfw"
+        :attachment="attachment"
+        :allowPlay="false"
+        :key="attachment.id"
+      />
+    </div>
+  </div>
+</template>
+
+<script src='./gallery.js'></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.gallery-row {
+  height: 200px;
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+  align-content: stretch;
+  flex-grow: 1;
+  margin-top: 0.5em;
+
+  .attachments, .attachment {
+    margin: 0 0.5em 0 0;
+    flex-grow: 1;
+    height: 100%;
+    box-sizing: border-box;
+  }
+
+  .image-attachment {
+    width: 100%;
+    height: 100%;
+  }
+
+  .video-container {
+    height: 100%;
+  }
+
+  &.contain-fit {
+    img, video {
+      object-fit: contain;
+    }
+  }
+
+  &.cover-fit {
+    img, video {
+      object-fit: cover;
+    }
+  }
+}
+
+</style>
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 7f10589c..14ae19d4 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,9 +1,11 @@
 import StillImage from '../still-image/still-image.vue'
+import VideoAttachment from '../video_attachment/video_attachment.vue'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 
 const MediaModal = {
   components: {
-    StillImage
+    StillImage,
+    VideoAttachment
   },
   computed: {
     showing () {
@@ -17,9 +19,6 @@ const MediaModal = {
     },
     type () {
       return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
-    },
-    loopVideo () {
-      return this.$store.state.config.loopVideo
     }
   },
   created () {
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index eb8fca53..796d4e40 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -1,14 +1,13 @@
 <template>
   <div class="modal-view" v-if="showing" @click.prevent="hide">
     <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
-    <video
+    <VideoAttachment
       class="modal-image"
       v-if="type === 'video'"
-      :src="currentMedia.url"
-      @click.stop=""
-      controls autoplay
-      :loop="loopVideo">
-    </video>
+      :attachment="currentMedia"
+      :controls="true"
+      @click.stop.native="">
+    </VideoAttachment>
   </div>
 </template>
 
@@ -18,7 +17,7 @@
 @import '../../_variables.scss';
 
 .modal-view {
-  z-index: 1005;
+  z-index: 1000;
   position: fixed;
   width: 100vw;
   height: 100vh;
diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue
index 25c1a9d0..a3ba14d1 100644
--- a/src/components/registration/registration.vue
+++ b/src/components/registration/registration.vue
@@ -215,7 +215,7 @@ $validations-cRed: #f04124;
   }
 }
 
-@media all and (max-width: 959px) {
+@media all and (max-width: 800px) {
   .registration-form .container {
     flex-direction: column-reverse;
   }
diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js
index 76b42bab..06011e7c 100644
--- a/src/components/settings/settings.js
+++ b/src/components/settings/settings.js
@@ -13,6 +13,7 @@ const settings = {
       hideAttachmentsLocal: user.hideAttachments,
       hideAttachmentsInConvLocal: user.hideAttachmentsInConv,
       hideNsfwLocal: user.hideNsfw,
+      useOneClickNsfw: user.useOneClickNsfw,
       hideISPLocal: user.hideISP,
       preloadImage: user.preloadImage,
 
@@ -56,7 +57,17 @@ const settings = {
       scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
 
       stopGifs: user.stopGifs,
-      webPushNotificationsLocal: user.webPushNotifications
+      webPushNotificationsLocal: user.webPushNotifications,
+      loopVideoSilentOnlyLocal: user.loopVideosSilentOnly,
+      loopSilentAvailable:
+        // Firefox
+        Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
+        // Chrome-likes
+        Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') ||
+        // Future spec, still not supported in Nightly 63 as of 08/2018
+        Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'),
+      playVideosInline: user.playVideosInline,
+      useContainFit: user.useContainFit
     }
   },
   components: {
@@ -88,6 +99,9 @@ const settings = {
     hideNsfwLocal (value) {
       this.$store.dispatch('setOption', { name: 'hideNsfw', value })
     },
+    useOneClickNsfw (value) {
+      this.$store.dispatch('setOption', { name: 'useOneClickNsfw', value })
+    },
     preloadImage (value) {
       this.$store.dispatch('setOption', { name: 'preloadImage', value })
     },
@@ -112,6 +126,9 @@ const settings = {
     loopVideoLocal (value) {
       this.$store.dispatch('setOption', { name: 'loopVideo', value })
     },
+    loopVideoSilentOnlyLocal (value) {
+      this.$store.dispatch('setOption', { name: 'loopVideoSilentOnly', value })
+    },
     autoLoadLocal (value) {
       this.$store.dispatch('setOption', { name: 'autoLoad', value })
     },
@@ -146,6 +163,12 @@ const settings = {
     webPushNotificationsLocal (value) {
       this.$store.dispatch('setOption', { name: 'webPushNotifications', value })
       if (value) this.$store.dispatch('registerPushNotifications')
+    },
+    playVideosInline (value) {
+      this.$store.dispatch('setOption', { name: 'playVideosInline', value })
+    },
+    useContainFit (value) {
+      this.$store.dispatch('setOption', { name: 'useContainFit', value })
     }
   }
 }
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index e84bd3f6..08d659d6 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -123,6 +123,10 @@
                 <input :disabled="!hideNsfwLocal" type="checkbox" id="preloadImage" v-model="preloadImage">
                 <label for="preloadImage">{{$t('settings.preload_images')}}</label>
               </li>
+              <li>
+                <input type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw">
+                <label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label>
+              </li>
             </ul>
             <li>
               <input type="checkbox" id="stopGifs" v-model="stopGifs">
@@ -131,6 +135,23 @@
             <li>
               <input type="checkbox" id="loopVideo" v-model="loopVideoLocal">
               <label for="loopVideo">{{$t('settings.loop_video')}}</label>
+              <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]">
+                <li>
+                  <input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal">
+                  <label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label>
+                  <div v-if="!loopSilentAvailable" class="unavailable">
+                    <i class="icon-globe"/>! {{$t('settings.limited_availability')}}
+                  </div>
+                </li>
+              </ul>
+            </li>
+            <li>
+              <input type="checkbox" id="playVideosInline" v-model="playVideosInline">
+              <label for="playVideosInline">{{$t('settings.play_videos_inline')}}</label>
+            </li>
+            <li>
+              <input type="checkbox" id="useContainFit" v-model="useContainFit">
+              <label for="useContainFit">{{$t('settings.use_contain_fit')}}</label>
             </li>
           </ul>
         </div>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 6e82307a..1db074e2 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -5,9 +5,11 @@ import DeleteButton from '../delete_button/delete_button.vue'
 import PostStatusForm from '../post_status_form/post_status_form.vue'
 import UserCardContent from '../user_card_content/user_card_content.vue'
 import StillImage from '../still-image/still-image.vue'
+import Gallery from '../gallery/gallery.vue'
 import { filter, find } from 'lodash'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import fileType from 'src/services/file_type/file_type.service'
 
 const Status = {
   name: 'Status',
@@ -197,6 +199,24 @@ const Status = {
         return 'small'
       }
       return 'normal'
+    },
+    galleryTypes () {
+      if (this.attachmentSize === 'hide') {
+        return []
+      }
+      return this.$store.state.config.playVideosInline
+        ? ['image']
+        : ['image', 'video']
+    },
+    galleryAttachments () {
+      return this.status.attachments.filter(
+        file => fileType.fileMatchesSomeType(this.galleryTypes, file)
+      )
+    },
+    nonGalleryAttachments () {
+      return this.status.attachments.filter(
+        file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
+      )
     }
   },
   components: {
@@ -206,7 +226,8 @@ const Status = {
     DeleteButton,
     PostStatusForm,
     UserCardContent,
-    StillImage
+    StillImage,
+    Gallery
   },
   methods: {
     visibilityIcon (visibility) {
@@ -283,7 +304,7 @@ const Status = {
       return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
     },
     setMedia () {
-      const attachments = this.status.attachments
+      const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
       return () => this.$store.dispatch('setMedia', attachments)
     }
   },
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d7cab15b..ba3b9a4a 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -93,16 +93,23 @@
             <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a>
           </div>
 
-          <div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'>
+          <div v-if="status.attachments && !hideSubjectStatus" class="attachments media-body">
             <attachment
+              class="non-gallery"
+              v-for="attachment in nonGalleryAttachments"
               :size="attachmentSize"
-              :status-id="status.id"
               :nsfw="nsfwClickthrough"
               :attachment="attachment"
-              :set-media="setMedia()"
-              v-for="attachment in status.attachments"
-              :key="attachment.id">
-            </attachment>
+              :allowPlay="true"
+              :setMedia="setMedia()"
+              :key="attachment.id"
+            />
+            <gallery
+              v-if="galleryAttachments.length > 0"
+              :nsfw="nsfwClickthrough"
+              :attachments="galleryAttachments"
+              :setMedia="setMedia()"
+            />
           </div>
 
           <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'>
@@ -568,7 +575,7 @@ a.unmute {
   }
 }
 
-@media all and (max-width: 960px) {
+@media all and (max-width: 800px) {
   .status-el {
     .retweet-info {
       .avatar {
diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js
new file mode 100644
index 00000000..76b19a02
--- /dev/null
+++ b/src/components/video_attachment/video_attachment.js
@@ -0,0 +1,31 @@
+
+const VideoAttachment = {
+  props: ['attachment', 'controls'],
+  data () {
+    return {
+      loopVideo: this.$store.state.config.loopVideo
+    }
+  },
+  methods: {
+    onVideoDataLoad (e) {
+      const target = e.srcElement || e.target
+      if (typeof target.webkitAudioDecodedByteCount !== 'undefined') {
+        // non-zero if video has audio track
+        if (target.webkitAudioDecodedByteCount > 0) {
+          this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+        }
+      } else if (typeof target.mozHasAudio !== 'undefined') {
+        // true if video has audio track
+        if (target.mozHasAudio) {
+          this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+        }
+      } else if (typeof target.audioTracks !== 'undefined') {
+        if (target.audioTracks.length > 0) {
+          this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+        }
+      }
+    }
+  }
+}
+
+export default VideoAttachment
diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue
new file mode 100644
index 00000000..68de201e
--- /dev/null
+++ b/src/components/video_attachment/video_attachment.vue
@@ -0,0 +1,11 @@
+<template>
+  <video class="video"
+    @loadeddata="onVideoDataLoad"
+    :src="attachment.url"
+    :loop="loopVideo"
+    :controls="controls"
+    playsinline
+  />
+</template>
+
+<script src="./video_attachment.js"></script>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 1dd3462b..6008287f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -132,6 +132,7 @@
     "hide_attachments_in_tl": "Hide attachments in timeline",
     "hide_isp": "Hide instance-specific panel",
     "preload_images": "Preload images",
+    "use_one_click_nsfw": "Open NSFW attachments with just one click",
     "hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
     "hide_user_stats": "Hide user statistics (e.g. the number of followers)",
     "import_followers_from_a_csv_file": "Import follows from a csv file",
@@ -148,6 +149,8 @@
     "lock_account_description": "Restrict your account to approved followers only",
     "loop_video": "Loop videos",
     "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
+    "play_videos_inline": "Play videos directly on timeline",
+    "use_contain_fit": "Don't crop the attachment in thumbnails",
     "name": "Name",
     "name_bio": "Name & Bio",
     "new_password": "New password",
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index 08cfb617..969d5a9e 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -1,4 +1,16 @@
 {
+  "chat": {
+    "title": "Chat"
+  },
+  "features_panel": {
+    "chat": "Chat",
+    "gopher": "Gopher",
+    "media_proxy": "Media-välityspalvelin",
+    "scope_options": "Näkyvyyden rajaus",
+    "text_limit": "Tekstin pituusraja",
+    "title": "Ominaisuudet",
+    "who_to_follow": "Seurausehdotukset"
+  },
   "finder": {
     "error_fetching_user": "Virhe hakiessa käyttäjää",
     "find_user": "Hae käyttäjä"
@@ -9,85 +21,243 @@
   },
   "login": {
     "login": "Kirjaudu sisään",
+    "description": "Kirjaudu sisään OAuthilla",
     "logout": "Kirjaudu ulos",
     "password": "Salasana",
-    "placeholder": "esim. lain",
+    "placeholder": "esim. Seppo",
     "register": "Rekisteröidy",
     "username": "Käyttäjänimi"
   },
   "nav": {
+    "about": "Tietoja",
+    "back": "Takaisin",
+    "chat": "Paikallinen Chat",
+    "friend_requests": "Seurauspyynnöt",
     "mentions": "Maininnat",
+    "dms": "Yksityisviestit",
     "public_tl": "Julkinen Aikajana",
     "timeline": "Aikajana",
-    "twkn": "Koko Tunnettu Verkosto"
+    "twkn": "Koko Tunnettu Verkosto",
+    "user_search": "Käyttäjähaku",
+    "who_to_follow": "Seurausehdotukset",
+    "preferences": "Asetukset"
   },
   "notifications": {
+    "broken_favorite": "Viestiä ei löydetty...",
     "favorited_you": "tykkäsi viestistäsi",
     "followed_you": "seuraa sinua",
+    "load_older": "Lataa vanhempia ilmoituksia",
     "notifications": "Ilmoitukset",
     "read": "Lue!",
     "repeated_you": "toisti viestisi"
   },
   "post_status": {
+    "new_status": "Uusi viesti",
+    "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi",
+    "account_not_locked_warning_link": "lukittu",
+    "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
+    "content_type": {
+      "plain_text": "Tavallinen teksti"
+    },
+    "content_warning": "Aihe (valinnainen)",
     "default": "Tulin juuri saunasta.",
-    "posting": "Lähetetään"
+    "direct_warning": "Tämä viesti näkyy vain mainituille käyttäjille.",
+    "posting": "Lähetetään",
+    "scope": {
+      "direct": "Yksityisviesti - Näkyy vain mainituille käyttäjille",
+      "private": "Vain-seuraajille - Näkyy vain seuraajillesi",
+      "public": "Julkinen - Näkyy julkisilla aikajanoilla",
+      "unlisted": "Listaamaton - Ei näy julkisilla aikajanoilla"
+    }
   },
   "registration": {
     "bio": "Kuvaus",
     "email": "Sähköposti",
     "fullname": "Koko nimi",
     "password_confirm": "Salasanan vahvistaminen",
-    "registration": "Rekisteröityminen"
+    "registration": "Rekisteröityminen",
+    "token": "Kutsuvaltuus",
+    "captcha": "Varmenne",
+    "new_captcha": "Paina kuvaa saadaksesi uuden varmenteen",
+    "validations": {
+      "username_required": "ei voi olla tyhjä",
+      "fullname_required": "ei voi olla tyhjä",
+      "email_required": "ei voi olla tyhjä",
+      "password_required": "ei voi olla tyhjä",
+      "password_confirmation_required": "ei voi olla tyhjä",
+      "password_confirmation_match": "pitää vastata salasanaa"
+    }
   },
   "settings": {
+    "attachmentRadius": "Liitteet",
     "attachments": "Liitteet",
     "autoload": "Lataa vanhempia viestejä automaattisesti ruudun pohjalla",
     "avatar": "Profiilikuva",
+    "avatarAltRadius": "Profiilikuvat (ilmoitukset)",
+    "avatarRadius": "Profiilikuvat",
     "background": "Tausta",
     "bio": "Kuvaus",
+    "btnRadius": "Napit",
+    "cBlue": "Sininen (Vastaukset, seuraukset)",
+    "cGreen": "Vihreä (Toistot)",
+    "cOrange": "Oranssi (Tykkäykset)",
+    "cRed": "Punainen (Peruminen)",
+    "change_password": "Vaihda salasana",
+    "change_password_error": "Virhe vaihtaessa salasanaa.",
+    "changed_password": "Salasana vaihdettu!",
+    "collapse_subject": "Minimoi viestit, joille on asetettu aihe",
+    "composing": "Viestien laatiminen",
+    "confirm_new_password": "Vahvista uusi salasana",
     "current_avatar": "Nykyinen profiilikuvasi",
+    "current_password": "Nykyinen salasana",
     "current_profile_banner": "Nykyinen julisteesi",
+    "data_import_export_tab": "Tietojen tuonti / vienti",
+    "default_vis": "Oletusnäkyvyysrajaus",
+    "delete_account": "Poista tili",
+    "delete_account_description": "Poista tilisi ja viestisi pysyvästi.",
+    "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.",
+    "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
+    "export_theme": "Tallenna teema",
     "filtering": "Suodatus",
     "filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.",
+    "follow_export": "Seurausten vienti",
+    "follow_export_button": "Vie seurauksesi CSV-tiedostoon",
+    "follow_export_processing": "Käsitellään, sinua pyydetään lataamaan tiedosto hetken päästä",
+    "follow_import": "Seurausten tuonti",
+    "follow_import_error": "Virhe tuodessa seuraksia",
+    "follows_imported": "Seuraukset tuotu! Niiden käsittely vie hetken.",
     "foreground": "Korostus",
+    "general": "Yleinen",
     "hide_attachments_in_convo": "Piilota liitteet keskusteluissa",
     "hide_attachments_in_tl": "Piilota liitteet aikajanalla",
+    "hide_isp": "Piilota palvelimenkohtainen ruutu",
+    "preload_images": "Esilataa kuvat",
+    "use_one_click_nsfw": "Avaa NSFW-liitteet yhdellä painalluksella",
+    "hide_post_stats": "Piilota viestien statistiikka (esim. tykkäysten määrä)",
+    "hide_user_stats": "Piilota käyttäjien statistiikka (esim. seuraajien määrä)",
+    "import_followers_from_a_csv_file": "Tuo seuraukset CSV-tiedostosta",
+    "import_theme": "Tuo tallennettu teema",
+    "inputRadius": "Syöttökentät",
+    "checkboxRadius": "Valintalaatikot",
+    "instance_default": "(oletus: {value})",
+    "instance_default_simple": "(oletus)",
+    "interface": "Käyttöliittymä",
+    "interfaceLanguage": "Käyttöliittymän kieli",
+    "invalid_theme_imported": "Tuotu tallennettu teema on epäkelpo, muutoksia ei tehty nykyiseen teemaasi.",
+    "limited_availability": "Ei saatavilla selaimessasi",
     "links": "Linkit",
+    "lock_account_description": "Vain erikseen hyväksytyt käyttäjät voivat seurata tiliäsi",
+    "loop_video": "Uudelleentoista videot",
+    "loop_video_silent_only": "Uudelleentoista ainoastaan äänettömät videot (Video-\"giffit\")",
+    "play_videos_inline": "Toista videot suoraan aikajanalla",
+    "use_contain_fit": "Älä rajaa liitteitä esikatselussa",
     "name": "Nimi",
     "name_bio": "Nimi ja kuvaus",
-    "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse.",
+    "new_password": "Uusi salasana",
+    "notification_visibility": "Ilmoitusten näkyvyys",
+    "notification_visibility_follows": "Seuraukset",
+    "notification_visibility_likes": "Tykkäykset",
+    "notification_visibility_mentions": "Maininnat",
+    "notification_visibility_repeats": "Toistot",
+    "no_rich_text_description": "Älä näytä tekstin muotoilua.",
+    "hide_network_description": "Älä näytä seurauksiani tai seuraajiani",
+    "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse",
+    "panelRadius": "Ruudut",
+    "pause_on_unfocused": "Pysäytä automaattinen viestien näyttö välilehden ollessa pois fokuksesta",
     "presets": "Valmiit teemat",
     "profile_background": "Taustakuva",
     "profile_banner": "Juliste",
+    "profile_tab": "Profiili",
+    "radii_help": "Aseta reunojen pyöristys (pikseleinä)",
+    "replies_in_timeline": "Keskustelut aikajanalla",
     "reply_link_preview": "Keskusteluiden vastauslinkkien esikatselu",
+    "reply_visibility_all": "Näytä kaikki vastaukset",
+    "reply_visibility_following": "Näytä vain vastaukset minulle tai seuraamilleni käyttäjille",
+    "reply_visibility_self": "Näytä vain vastaukset minulle",
+    "saving_err": "Virhe tallentaessa asetuksia",
+    "saving_ok": "Asetukset tallennettu",
+    "security_tab": "Tietoturva",
+    "scope_copy": "Kopioi näkyvyysrajaus vastatessa (Yksityisviestit aina kopioivat)",
     "set_new_avatar": "Aseta uusi profiilikuva",
     "set_new_profile_background": "Aseta uusi taustakuva",
     "set_new_profile_banner": "Aseta uusi juliste",
     "settings": "Asetukset",
+    "subject_input_always_show": "Näytä aihe-kenttä",
+    "subject_line_behavior": "Aihe-kentän kopiointi",
+    "subject_line_email": "Kuten sähköposti: \"re: aihe\"",
+    "subject_line_mastodon": "Kopioi sellaisenaan",
+    "subject_line_noop": "Älä kopioi",
+    "stop_gifs": "Toista giffit vain kohdistaessa",
     "streaming": "Näytä uudet viestit automaattisesti ollessasi ruudun huipulla",
     "text": "Teksti",
     "theme": "Teema",
     "theme_help": "Käytä heksadesimaalivärejä muokataksesi väriteemaasi.",
-    "user_settings": "Käyttäjän asetukset"
+    "theme_help_v2_1": "Voit asettaa tiettyjen osien värin tai läpinäkyvyyden täyttämällä valintalaatikon, käytä \"Tyhjennä kaikki\"-nappia tyhjentääksesi kaiken.",
+    "theme_help_v2_2": "Ikonit kenttien alla ovat kontrasti-indikaattoreita, lisätietoa kohdistamalla. Käyttäessä läpinäkyvyyttä ne näyttävät pahimman skenaarion.",
+    "tooltipRadius": "Ohje- tai huomioviestit",
+    "user_settings": "Käyttäjän asetukset",
+    "values": {
+      "false": "pois päältä",
+      "true": "päällä"
+    }
   },
   "timeline": {
     "collapse": "Sulje",
     "conversation": "Keskustelu",
     "error_fetching": "Virhe ladatessa viestejä",
     "load_older": "Lataa vanhempia viestejä",
+    "no_retweet_hint": "Viesti ei ole julkinen, eikä sitä voi toistaa",
     "repeated": "toisti",
     "show_new": "Näytä uudet",
     "up_to_date": "Ajantasalla"
   },
   "user_card": {
+    "approve": "Hyväksy",
+    "block": "Estä",
+    "blocked": "Estetty!",
+    "deny": "Älä hyväksy",
     "follow": "Seuraa",
+    "follow_sent": "Pyyntö lähetetty!",
+    "follow_progress": "Pyydetään...",
+    "follow_again": "Lähetä pyyntö uudestaan",
+    "follow_unfollow": "Älä seuraa",
     "followees": "Seuraa",
     "followers": "Seuraajat",
     "following": "Seuraat!",
     "follows_you": "Seuraa sinua!",
+    "its_you": "Sinun tili!",
     "mute": "Hiljennä",
     "muted": "Hiljennetty",
     "per_day": "päivässä",
+    "remote_follow": "Seuraa muualta",
     "statuses": "Viestit"
+  },
+  "user_profile": {
+    "timeline_title": "Käyttäjän aikajana"
+  },
+  "who_to_follow": {
+    "more": "Lisää",
+    "who_to_follow": "Seurausehdotukset"
+  },
+  "tool_tip": {
+    "media_upload": "Lataa tiedosto",
+    "repeat": "Toista",
+    "reply": "Vastaa",
+    "favorite": "Tykkää",
+    "user_settings": "Käyttäjäasetukset"
+  },
+  "upload":{
+    "error": {
+    "base": "Lataus epäonnistui.",
+    "file_too_big": "Tiedosto liian suuri [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+    "default": "Yritä uudestaan myöhemmin"
+    },
+    "file_size_units": {
+      "B": "tavua",
+      "KiB": "kt",
+      "MiB": "Mt",
+      "GiB": "Gt",
+      "TiB": "Tt"
+    }
   }
 }
diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js
index f543ec79..2a046bec 100644
--- a/src/services/file_type/file_type.service.js
+++ b/src/services/file_type/file_type.service.js
@@ -1,27 +1,32 @@
-const fileType = (typeString) => {
-  let type = 'unknown'
-
-  if (typeString.match(/text\/html/)) {
-    type = 'html'
+// TODO this func might as well take the entire file and use its mimetype
+// or the entire service could be just mimetype service that only operates
+// on mimetypes and not files. Currently the naming is confusing.
+const fileType = mimetype => {
+  if (mimetype.match(/text\/html/)) {
+    return 'html'
   }
 
-  if (typeString.match(/image/)) {
-    type = 'image'
+  if (mimetype.match(/image/)) {
+    return 'image'
   }
 
-  if (typeString.match(/video/)) {
-    type = 'video'
+  if (mimetype.match(/video/)) {
+    return 'video'
   }
 
-  if (typeString.match(/audio/)) {
-    type = 'audio'
+  if (mimetype.match(/audio/)) {
+    return 'audio'
   }
 
-  return type
+  return 'unknown'
 }
 
+const fileMatchesSomeType = (types, file) =>
+  types.some(type => fileType(file.mimetype) === type)
+
 const fileTypeService = {
-  fileType
+  fileType,
+  fileMatchesSomeType
 }
 
 export default fileTypeService
diff --git a/test/unit/specs/services/file_type/file_type.spec.js b/test/unit/specs/services/file_type/file_type.spec.js
new file mode 100644
index 00000000..eb8a087d
--- /dev/null
+++ b/test/unit/specs/services/file_type/file_type.spec.js
@@ -0,0 +1,19 @@
+import fileType from 'src/services/file_type/file_type.service.js'
+
+describe('fileType service', () => {
+  describe('fileMatchesSomeType', () => {
+    it('should be true when file type is one of the listed', () => {
+      const file = { mimetype: 'audio/mpeg' }
+      const types = ['video', 'audio']
+
+      expect(fileType.fileMatchesSomeType(types, file)).to.eql(true)
+    })
+
+    it('should be false when files type is not included in type list', () => {
+      const file = { mimetype: 'audio/mpeg' }
+      const types = ['image', 'video']
+
+      expect(fileType.fileMatchesSomeType(types, file)).to.eql(false)
+    })
+  })
+})
-- 
cgit v1.2.3-70-g09d2

