From b97a03383990d54573bd5e68393a1ad11e33608b Mon Sep 17 00:00:00 2001
From: jared <jaredrmain@gmail.com>
Date: Mon, 25 Mar 2019 22:38:15 -0400
Subject: [PATCH 1/4] #255 - add emoji input component

---
 src/components/emoji-input/emoji-input.js     | 106 ++++++++++++++++++
 src/components/emoji-input/emoji-input.vue    |  99 ++++++++++++++++
 .../post_status_form/post_status_form.js      |   4 +-
 .../post_status_form/post_status_form.vue     |   5 +-
 4 files changed, 211 insertions(+), 3 deletions(-)
 create mode 100644 src/components/emoji-input/emoji-input.js
 create mode 100644 src/components/emoji-input/emoji-input.vue

diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
new file mode 100644
index 00000000..56414358
--- /dev/null
+++ b/src/components/emoji-input/emoji-input.js
@@ -0,0 +1,106 @@
+import Completion from '../../services/completion/completion.js'
+import { take, filter, map } from 'lodash'
+
+const EmojiInput = {
+  props: [
+    'value',
+    'placeholder',
+    'type'
+  ],
+  data () {
+    return {
+      highlighted: 0,
+      caret: 0
+    }
+  },
+  computed: {
+    suggestions () {
+      const firstchar = this.textAtCaret.charAt(0)
+      if (firstchar === ':') {
+        if (this.textAtCaret === ':') { return }
+        const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
+        if (matchedEmoji.length <= 0) {
+          return false
+        }
+        return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
+          shortcode: `:${shortcode}:`,
+          utf: utf || '',
+          // eslint-disable-next-line camelcase
+          img: utf ? '' : this.$store.state.instance.server + image_url,
+          highlighted: index === this.highlighted
+        }))
+      } else {
+        return false
+      }
+    },
+    textAtCaret () {
+      return (this.wordAtCaret || {}).word || ''
+    },
+    wordAtCaret () {
+      const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
+      return word
+    },
+    emoji () {
+      return this.$store.state.instance.emoji || []
+    },
+    customEmoji () {
+      return this.$store.state.instance.customEmoji || []
+    }
+  },
+  methods: {
+    replace (replacement) {
+      const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
+      this.$emit('input', newValue)
+      this.caret = 0
+    },
+    replaceEmoji (e) {
+      const len = this.suggestions.length || 0
+      if (this.textAtCaret === ':' || e.ctrlKey) { return }
+      if (len > 0) {
+        e.preventDefault()
+        const emoji = this.suggestions[this.highlighted]
+        const replacement = emoji.utf || (emoji.shortcode + ' ')
+        const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
+        this.$emit('input', newValue)
+        this.caret = 0
+        this.highlighted = 0
+      }
+    },
+    cycleBackward (e) {
+      const len = this.suggestions.length || 0
+      if (len > 0) {
+        e.preventDefault()
+        this.highlighted -= 1
+        if (this.highlighted < 0) {
+          this.highlighted = this.suggestions.length - 1
+        }
+      } else {
+        this.highlighted = 0
+      }
+    },
+    cycleForward (e) {
+      const len = this.suggestions.length || 0
+      if (len > 0) {
+        if (e.shiftKey) { return }
+        e.preventDefault()
+        this.highlighted += 1
+        if (this.highlighted >= len) {
+          this.highlighted = 0
+        }
+      } else {
+        this.highlighted = 0
+      }
+    },
+    onKeydown (e) {
+      e.stopPropagation()
+    },
+    onInput (e) {
+      this.$emit('input', e.target.value)
+    },
+    setCaret ({target: {selectionStart}}) {
+      this.caret = selectionStart
+    }
+  }
+}
+
+export default EmojiInput
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
new file mode 100644
index 00000000..95606305
--- /dev/null
+++ b/src/components/emoji-input/emoji-input.vue
@@ -0,0 +1,99 @@
+<template>
+  <div class="emoji-input">
+    <input
+      class="form-control"
+      :type="type"
+      :value="value"
+      :placeholder="placeholder"
+      @input="onInput"
+      @click="setCaret"
+      @keyup="setCaret"
+      @keydown="onKeydown"
+      @keydown.down="cycleForward"
+      @keydown.up="cycleBackward"
+      @keydown.shift.tab="cycleBackward"
+      @keydown.tab="cycleForward"
+      @keydown.enter="replaceEmoji"
+    />
+    <div class="autocomplete-panel" v-if="suggestions">
+      <div class="autocomplete-panel-body">
+        <div
+          v-for="(emoji, index) in suggestions"
+          :key="index"
+          @click="replace(emoji.utf || (emoji.shortcode + ' '))"
+          class="autocomplete-item"
+          :class="{ highlighted: emoji.highlighted }"
+        >
+          <span v-if="emoji.img">
+            <img :src="emoji.img" />
+          </span>
+          <span v-else>{{emoji.utf}}</span>
+          <span>{{emoji.shortcode}}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./emoji-input.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.emoji-input {
+  .form-control {
+    width: 100%;
+  }
+}
+
+.autocomplete {
+  &-panel {
+    position: relative;
+
+    &-body {
+      margin: 0 0.5em 0 0.5em;
+      border-radius: $fallback--tooltipRadius;
+      border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+      position: absolute;
+      z-index: 1;
+      box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+      // this doesn't match original but i don't care, making it uniform.
+      box-shadow: var(--popupShadow);
+      min-width: 75%;
+      background: $fallback--bg;
+      background: var(--bg, $fallback--bg);
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
+  }
+
+  &-item {
+    cursor: pointer;
+    padding: 0.2em 0.4em 0.2em 0.4em;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+    display: flex;
+
+    img {
+      width: 24px;
+      height: 24px;
+      object-fit: contain;
+    }
+
+    span {
+      line-height: 24px;
+      margin: 0 0.1em 0 0.2em;
+    }
+
+    small {
+      margin-left: .5em;
+      color: $fallback--faint;
+      color: var(--faint, $fallback--faint);
+    }
+
+    &.highlighted {
+      background-color: $fallback--fg;
+      background-color: var(--lightBg, $fallback--fg);
+    }
+  }
+}
+</style>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c5f30ca6..229aefb7 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,5 +1,6 @@
 import statusPoster from '../../services/status_poster/status_poster.service.js'
 import MediaUpload from '../media_upload/media_upload.vue'
+import EmojiInput from '../emoji-input/emoji-input.vue'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 import Completion from '../../services/completion/completion.js'
 import { take, filter, reject, map, uniqBy } from 'lodash'
@@ -28,7 +29,8 @@ const PostStatusForm = {
     'subject'
   ],
   components: {
-    MediaUpload
+    MediaUpload,
+    EmojiInput
   },
   mounted () {
     this.resize(this.$refs.textarea)
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 612f87c1..9d449b74 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -10,12 +10,13 @@
         <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
       </i18n>
       <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
-      <input
+      <EmojiInput
         v-if="newStatus.spoilerText || alwaysShowSubject"
         type="text"
         :placeholder="$t('post_status.content_warning')"
         v-model="newStatus.spoilerText"
-        class="form-cw">
+        class="form-cw"
+      />
       <textarea
         ref="textarea"
         @click="setCaret"

From 29274542336b82b5a8c5c19f7e5ce476f489ae37 Mon Sep 17 00:00:00 2001
From: jared <jaredrmain@gmail.com>
Date: Tue, 26 Mar 2019 13:40:37 -0400
Subject: [PATCH 2/4] #255 - support textarea and update user settings page

---
 src/components/emoji-input/emoji-input.js      |  3 ++-
 src/components/emoji-input/emoji-input.vue     | 18 +++++++++++++++++-
 .../post_status_form/post_status_form.vue      |  2 +-
 src/components/user_settings/user_settings.js  |  4 +++-
 src/components/user_settings/user_settings.vue | 13 +++++++++++--
 5 files changed, 34 insertions(+), 6 deletions(-)

diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
index 56414358..a5bb6eaf 100644
--- a/src/components/emoji-input/emoji-input.js
+++ b/src/components/emoji-input/emoji-input.js
@@ -5,7 +5,8 @@ const EmojiInput = {
   props: [
     'value',
     'placeholder',
-    'type'
+    'type',
+    'classname'
   ],
   data () {
     return {
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
index 95606305..568bd080 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji-input/emoji-input.vue
@@ -1,7 +1,8 @@
 <template>
   <div class="emoji-input">
     <input
-      class="form-control"
+      v-if="type !== 'textarea'"
+      :class="classname"
       :type="type"
       :value="value"
       :placeholder="placeholder"
@@ -15,6 +16,21 @@
       @keydown.tab="cycleForward"
       @keydown.enter="replaceEmoji"
     />
+    <textarea
+      v-else
+      :class="classname"
+      :value="value"
+      :placeholder="placeholder"
+      @input="onInput"
+      @click="setCaret"
+      @keyup="setCaret"
+      @keydown="onKeydown"
+      @keydown.down="cycleForward"
+      @keydown.up="cycleBackward"
+      @keydown.shift.tab="cycleBackward"
+      @keydown.tab="cycleForward"
+      @keydown.enter="replaceEmoji"
+    ></textarea>
     <div class="autocomplete-panel" v-if="suggestions">
       <div class="autocomplete-panel-body">
         <div
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 9d449b74..b2a1dc58 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -15,7 +15,7 @@
         type="text"
         :placeholder="$t('post_status.content_warning')"
         v-model="newStatus.spoilerText"
-        class="form-cw"
+        classname="form-control"
       />
       <textarea
         ref="textarea"
diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
index 72e7bb53..5cb23b97 100644
--- a/src/components/user_settings/user_settings.js
+++ b/src/components/user_settings/user_settings.js
@@ -7,6 +7,7 @@ import StyleSwitcher from '../style_switcher/style_switcher.vue'
 import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
 import BlockCard from '../block_card/block_card.vue'
 import MuteCard from '../mute_card/mute_card.vue'
+import EmojiInput from '../emoji-input/emoji-input.vue'
 import withSubscription from '../../hocs/with_subscription/with_subscription'
 import withList from '../../hocs/with_list/with_list'
 
@@ -69,7 +70,8 @@ const UserSettings = {
     TabSwitcher,
     ImageCropper,
     BlockList,
-    MuteList
+    MuteList,
+    EmojiInput
   },
   computed: {
     user () {
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index c9e68808..27c2f47d 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -22,9 +22,18 @@
           <div class="setting-item" >
             <h2>{{$t('settings.name_bio')}}</h2>
             <p>{{$t('settings.name')}}</p>
-            <input class='name-changer' id='username' v-model="newName"></input>
+            <EmojiInput 
+              type="text"
+              v-model="newName"
+              id="username"
+              classname="name-changer"
+            />
             <p>{{$t('settings.bio')}}</p>
-            <textarea class="bio" v-model="newBio"></textarea>
+            <EmojiInput
+              type="textarea"
+              v-model="newBio"
+              classname="bio"
+            />
             <p>
               <input type="checkbox" v-model="newLocked" id="account-locked">
               <label for="account-locked">{{$t('settings.lock_account_description')}}</label>

From 6dc2cedab07d14ef796640644c8b7ce6ec480665 Mon Sep 17 00:00:00 2001
From: jared <jaredrmain@gmail.com>
Date: Tue, 26 Mar 2019 13:42:27 -0400
Subject: [PATCH 3/4] #255 - clean up user settings page with self-closing html
 tags

---
 src/components/user_settings/user_settings.vue | 16 +++++++---------
 1 file changed, 7 insertions(+), 9 deletions(-)

diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
index 27c2f47d..52df143c 100644
--- a/src/components/user_settings/user_settings.vue
+++ b/src/components/user_settings/user_settings.vue
@@ -70,7 +70,7 @@
             <h2>{{$t('settings.avatar')}}</h2>
             <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p>
             <p>{{$t('settings.current_avatar')}}</p>
-            <img :src="user.profile_image_url_original" class="current-avatar"></img>
+            <img :src="user.profile_image_url_original" class="current-avatar" />
             <p>{{$t('settings.set_new_avatar')}}</p>
             <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button>
             <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" />
@@ -78,12 +78,11 @@
           <div class="setting-item">
             <h2>{{$t('settings.profile_banner')}}</h2>
             <p>{{$t('settings.current_profile_banner')}}</p>
-            <img :src="user.cover_photo" class="banner"></img>
+            <img :src="user.cover_photo" class="banner" />
             <p>{{$t('settings.set_new_profile_banner')}}</p>
-            <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview">
-            </img>
+            <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" />
             <div>
-              <input type="file" @change="uploadFile('banner', $event)" ></input>
+              <input type="file" @change="uploadFile('banner', $event)" />
             </div>
             <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
             <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
@@ -95,10 +94,9 @@
           <div class="setting-item">
             <h2>{{$t('settings.profile_background')}}</h2>
             <p>{{$t('settings.set_new_profile_background')}}</p>
-            <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview">
-            </img>
+            <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" />
             <div>
-              <input type="file" @change="uploadFile('background', $event)" ></input>
+              <input type="file" @change="uploadFile('background', $event)" />
             </div>
             <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
             <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
@@ -174,7 +172,7 @@
             <h2>{{$t('settings.follow_import')}}</h2>
             <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
             <form>
-              <input type="file" ref="followlist" v-on:change="followListChange"></input>
+              <input type="file" ref="followlist" v-on:change="followListChange" />
             </form>
             <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
             <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>

From 8fe9101f0b134978212bf05ed6e73894f47c617e Mon Sep 17 00:00:00 2001
From: jared <jaredrmain@gmail.com>
Date: Tue, 26 Mar 2019 14:53:27 -0400
Subject: [PATCH 4/4] #255 - clean up autocomplete form

---
 src/App.scss                                  | 51 +++++++++++++++
 src/components/emoji-input/emoji-input.vue    | 51 ---------------
 .../post_status_form/post_status_form.vue     | 65 ++++---------------
 3 files changed, 63 insertions(+), 104 deletions(-)

diff --git a/src/App.scss b/src/App.scss
index 244b3474..ae068e4f 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -767,3 +767,54 @@ nav {
 .btn.btn-default {
   min-height: 28px;
 }
+
+.autocomplete {
+  &-panel {
+    position: relative;
+
+    &-body {
+      margin: 0 0.5em 0 0.5em;
+      border-radius: $fallback--tooltipRadius;
+      border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+      position: absolute;
+      z-index: 1;
+      box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+      // this doesn't match original but i don't care, making it uniform.
+      box-shadow: var(--popupShadow);
+      min-width: 75%;
+      background: $fallback--bg;
+      background: var(--bg, $fallback--bg);
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
+  }
+
+  &-item {
+    cursor: pointer;
+    padding: 0.2em 0.4em 0.2em 0.4em;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+    display: flex;
+
+    img {
+      width: 24px;
+      height: 24px;
+      object-fit: contain;
+    }
+
+    span {
+      line-height: 24px;
+      margin: 0 0.1em 0 0.2em;
+    }
+
+    small {
+      margin-left: .5em;
+      color: $fallback--faint;
+      color: var(--faint, $fallback--faint);
+    }
+
+    &.highlighted {
+      background-color: $fallback--fg;
+      background-color: var(--lightBg, $fallback--fg);
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
index 568bd080..338b77cd 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji-input/emoji-input.vue
@@ -61,55 +61,4 @@
     width: 100%;
   }
 }
-
-.autocomplete {
-  &-panel {
-    position: relative;
-
-    &-body {
-      margin: 0 0.5em 0 0.5em;
-      border-radius: $fallback--tooltipRadius;
-      border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-      position: absolute;
-      z-index: 1;
-      box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
-      // this doesn't match original but i don't care, making it uniform.
-      box-shadow: var(--popupShadow);
-      min-width: 75%;
-      background: $fallback--bg;
-      background: var(--bg, $fallback--bg);
-      color: $fallback--lightText;
-      color: var(--lightText, $fallback--lightText);
-    }
-  }
-
-  &-item {
-    cursor: pointer;
-    padding: 0.2em 0.4em 0.2em 0.4em;
-    border-bottom: 1px solid rgba(0, 0, 0, 0.4);
-    display: flex;
-
-    img {
-      width: 24px;
-      height: 24px;
-      object-fit: contain;
-    }
-
-    span {
-      line-height: 24px;
-      margin: 0 0.1em 0 0.2em;
-    }
-
-    small {
-      margin-left: .5em;
-      color: $fallback--faint;
-      color: var(--faint, $fallback--faint);
-    }
-
-    &.highlighted {
-      background-color: $fallback--fg;
-      background-color: var(--lightBg, $fallback--fg);
-    }
-  }
-}
 </style>
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index b2a1dc58..9f9f16ba 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -56,14 +56,18 @@
         </div>
       </div>
     </div>
-    <div style="position:relative;" v-if="candidates">
-        <div class="autocomplete-panel">
-          <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
-            <div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
-              <span v-if="candidate.img"><img :src="candidate.img"></img></span>
-              <span v-else>{{candidate.utf}}</span>
-              <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
-            </div>
+    <div class="autocomplete-panel" v-if="candidates">
+        <div class="autocomplete-panel-body">
+          <div
+            v-for="(candidate, index) in candidates"
+            :key="index"
+            @click="replace(candidate.utf || (candidate.screen_name + ' '))"
+            class="autocomplete-item"
+            :class="{ highlighted: candidate.highlighted }"
+          >
+            <span v-if="candidate.img"><img :src="candidate.img" /></span>
+            <span v-else>{{candidate.utf}}</span>
+            <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
           </div>
         </div>
       </div>
@@ -262,50 +266,5 @@
     cursor: pointer;
     z-index: 4;
   }
-
-  .autocomplete-panel {
-    margin: 0 0.5em 0 0.5em;
-    border-radius: $fallback--tooltipRadius;
-    border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-    position: absolute;
-    z-index: 1;
-    box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
-    // this doesn't match original but i don't care, making it uniform.
-    box-shadow: var(--popupShadow);
-    min-width: 75%;
-    background: $fallback--bg;
-    background: var(--bg, $fallback--bg);
-    color: $fallback--lightText;
-    color: var(--lightText, $fallback--lightText);
-  }
-
-  .autocomplete {
-    cursor: pointer;
-    padding: 0.2em 0.4em 0.2em 0.4em;
-    border-bottom: 1px solid rgba(0, 0, 0, 0.4);
-    display: flex;
-
-    img {
-      width: 24px;
-      height: 24px;
-      object-fit: contain;
-    }
-
-    span {
-      line-height: 24px;
-      margin: 0 0.1em 0 0.2em;
-    }
-
-    small {
-      margin-left: .5em;
-      color: $fallback--faint;
-      color: var(--faint, $fallback--faint);
-    }
-
-    &.highlighted {
-      background-color: $fallback--fg;
-      background-color: var(--lightBg, $fallback--fg);
-    }
-  }
 }
 </style>