commit: a0f780c4550b77d4574e0de8932a2dff288784a3
parent 8e049297ff6a125298ba9d06a3a4a563b231360f
Author: Shpuld Shpludson <shp@cock.li>
Date:   Sat,  2 May 2020 14:17:27 +0000
Merge branch 'rc/2.0.3' into 'master'
Update MASTER with 2.0.3 for real
See merge request pleroma/pleroma-fe!1099
Diffstat:
14 files changed, 240 insertions(+), 54 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -2,8 +2,20 @@
 All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-
 ## [Unreleased]
+### Changed
+- Removed the use of with_move parameters when fetching notifications
+
+## [2.0.3] - 2020-05-02
+### Fixed
+- Show more/less works correctly with auto-collapsed subjects and long posts
+- RTL characters won't look messed up in notifications
+
+### Changed
+- Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach:
+
+### Add
+- Follow request notification support
 
 ## [2.0.2] - 2020-04-08
 ### Fixed
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
@@ -29,17 +29,29 @@ export default data => input => {
 export const suggestEmoji = emojis => input => {
   const noPrefix = input.toLowerCase().substr(1)
   return emojis
-    .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
+    .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
     .sort((a, b) => {
       let aScore = 0
       let bScore = 0
 
-      // Make custom emojis a priority
-      aScore += a.imageUrl ? 10 : 0
-      bScore += b.imageUrl ? 10 : 0
+      // An exact match always wins
+      aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
+      bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
 
-      // Sort alphabetically
-      const alphabetically = a.displayText > b.displayText ? 1 : -1
+      // Prioritize custom emoji a lot
+      aScore += a.imageUrl ? 100 : 0
+      bScore += b.imageUrl ? 100 : 0
+
+      // Prioritize prefix matches somewhat
+      aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+      bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+
+      // Sort by length
+      aScore -= a.displayText.length
+      bScore -= b.displayText.length
+
+      // Break ties alphabetically
+      const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
 
       return bScore - aScore + alphabetically
     })
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
@@ -1,4 +1,5 @@
 import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
 
 const FollowRequestCard = {
   props: ['user'],
@@ -6,13 +7,32 @@ const FollowRequestCard = {
     BasicUserCard
   },
   methods: {
+    findFollowRequestNotificationId () {
+      const notif = notificationsFromStore(this.$store).find(
+        (notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
+      )
+      return notif && notif.id
+    },
     approveUser () {
       this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
       this.$store.dispatch('removeFollowRequest', this.user)
+
+      const notifId = this.findFollowRequestNotificationId()
+      this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
+      this.$store.dispatch('updateNotification', {
+        id: notifId,
+        updater: notification => {
+          notification.type = 'follow'
+        }
+      })
     },
     denyUser () {
+      const notifId = this.findFollowRequestNotificationId()
       this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
-      this.$store.dispatch('removeFollowRequest', this.user)
+        .then(() => {
+          this.$store.dispatch('dismissNotificationLocal', { id: notifId })
+          this.$store.dispatch('removeFollowRequest', this.user)
+        })
     }
   }
 }
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
@@ -2,6 +2,7 @@ import Status from '../status/status.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import UserCard from '../user_card/user_card.vue'
 import Timeago from '../timeago/timeago.vue'
+import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 
@@ -32,6 +33,24 @@ const Notification = {
     },
     toggleMute () {
       this.unmuted = !this.unmuted
+    },
+    approveUser () {
+      this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
+      this.$store.dispatch('removeFollowRequest', this.user)
+      this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
+      this.$store.dispatch('updateNotification', {
+        id: this.notification.id,
+        updater: notification => {
+          notification.type = 'follow'
+        }
+      })
+    },
+    denyUser () {
+      this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
+        .then(() => {
+          this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
+          this.$store.dispatch('removeFollowRequest', this.user)
+        })
     }
   },
   computed: {
@@ -57,6 +76,9 @@ const Notification = {
     },
     needMute () {
       return this.user.muted
+    },
+    isStatusNotification () {
+      return isStatusNotification(this.notification.type)
     }
   }
 }
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
@@ -47,7 +47,7 @@
         <span class="notification-details">
           <div class="name-and-action">
             <!-- eslint-disable vue/no-v-html -->
-            <span
+            <bdi
               v-if="!!notification.from_profile.name_html"
               class="username"
               :title="'@'+notification.from_profile.screen_name"
@@ -74,6 +74,10 @@
               <i class="fa icon-user-plus lit" />
               <small>{{ $t('notifications.followed_you') }}</small>
             </span>
+            <span v-if="notification.type === 'follow_request'">
+              <i class="fa icon-user lit" />
+              <small>{{ $t('notifications.follow_request') }}</small>
+            </span>
             <span v-if="notification.type === 'move'">
               <i class="fa icon-arrow-curved lit" />
               <small>{{ $t('notifications.migrated_to') }}</small>
@@ -87,30 +91,30 @@
             </span>
           </div>
           <div
-            v-if="notification.type === 'follow' || notification.type === 'move'"
+            v-if="isStatusNotification"
             class="timeago"
           >
-            <span class="faint">
+            <router-link
+              v-if="notification.status"
+              :to="{ name: 'conversation', params: { id: notification.status.id } }"
+              class="faint-link"
+            >
               <Timeago
                 :time="notification.created_at"
                 :auto-update="240"
               />
-            </span>
+            </router-link>
           </div>
           <div
             v-else
             class="timeago"
           >
-            <router-link
-              v-if="notification.status"
-              :to="{ name: 'conversation', params: { id: notification.status.id } }"
-              class="faint-link"
-            >
+            <span class="faint">
               <Timeago
                 :time="notification.created_at"
                 :auto-update="240"
               />
-            </router-link>
+            </span>
           </div>
           <a
             v-if="needMute"
@@ -119,12 +123,30 @@
           ><i class="button-icon icon-eye-off" /></a>
         </span>
         <div
-          v-if="notification.type === 'follow'"
+          v-if="notification.type === 'follow' || notification.type === 'follow_request'"
           class="follow-text"
         >
-          <router-link :to="userProfileLink">
+          <router-link
+            :to="userProfileLink"
+            class="follow-name"
+          >
             @{{ notification.from_profile.screen_name }}
           </router-link>
+          <div
+            v-if="notification.type === 'follow_request'"
+            style="white-space: nowrap;"
+          >
+            <i
+              class="icon-ok button-icon follow-request-accept"
+              :title="$t('tool_tip.accept_follow_request')"
+              @click="approveUser()"
+            />
+            <i
+              class="icon-cancel button-icon follow-request-reject"
+              :title="$t('tool_tip.reject_follow_request')"
+              @click="denyUser()"
+            />
+          </div>
         </div>
         <div
           v-else-if="notification.type === 'move'"
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
@@ -79,9 +79,38 @@
     }
   }
 
+  .follow-request-accept {
+    cursor: pointer;
+
+    &:hover {
+      color: $fallback--text;
+      color: var(--text, $fallback--text);
+    }
+  }
+
+  .follow-request-reject {
+    cursor: pointer;
+
+    &:hover {
+      color: $fallback--cRed;
+      color: var(--cRed, $fallback--cRed);
+    }
+  }
+
+
   .follow-text, .move-text {
     padding: 0.5em 0;
     overflow-wrap: break-word;
+    display: flex;
+    justify-content: space-between;
+
+    .follow-name {
+      display: block;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
   }
 
   .status-el {
@@ -143,6 +172,11 @@
       color: var(--cGreen, $fallback--cGreen);
     }
 
+    .icon-user.lit {
+      color: $fallback--cBlue;
+      color: var(--cBlue, $fallback--cBlue);
+    }
+
     .icon-user-plus.lit {
       color: $fallback--cBlue;
       color: var(--cBlue, $fallback--cBlue);
diff --git a/src/components/status/status.js b/src/components/status/status.js
@@ -188,23 +188,22 @@ const Status = {
       }
       return this.status.attentions.length > 0
     },
+
+    // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
+    mightHideBecauseSubject () {
+      return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
+    },
+    mightHideBecauseTall () {
+      return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
+    },
     hideSubjectStatus () {
-      if (this.tallStatus && !this.localCollapseSubjectDefault) {
-        return false
-      }
-      return !this.expandingSubject && this.status.summary
+      return this.mightHideBecauseSubject && !this.expandingSubject
     },
     hideTallStatus () {
-      if (this.status.summary && this.localCollapseSubjectDefault) {
-        return false
-      }
-      if (this.showingTall) {
-        return false
-      }
-      return this.tallStatus
+      return this.mightHideBecauseTall && !this.showingTall
     },
     showingMore () {
-      return (this.tallStatus && this.showingTall) || (this.status.summary && this.expandingSubject)
+      return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
     },
     nsfwClickthrough () {
       if (!this.status.nsfw) {
@@ -408,14 +407,10 @@ const Status = {
       this.userExpanded = !this.userExpanded
     },
     toggleShowMore () {
-      if (this.showingTall) {
-        this.showingTall = false
-      } else if (this.expandingSubject && this.status.summary) {
-        this.expandingSubject = false
-      } else if (this.hideTallStatus) {
-        this.showingTall = true
-      } else if (this.hideSubjectStatus && this.status.summary) {
-        this.expandingSubject = true
+      if (this.mightHideBecauseTall) {
+        this.showingTall = !this.showingTall
+      } else if (this.mightHideBecauseSubject) {
+        this.expandingSubject = !this.expandingSubject
       }
     },
     generateUserProfileLink (id, name) {
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -124,6 +124,7 @@
     "broken_favorite": "Unknown status, searching for it...",
     "favorited_you": "favorited your status",
     "followed_you": "followed you",
+    "follow_request": "wants to follow you",
     "load_older": "Load older notifications",
     "notifications": "Notifications",
     "read": "Read!",
@@ -697,7 +698,9 @@
     "reply": "Reply",
     "favorite": "Favorite",
     "add_reaction": "Add Reaction",
-    "user_settings": "User Settings"
+    "user_settings": "User Settings",
+    "accept_follow_request": "Accept follow request",
+    "reject_follow_request": "Reject follow request"
   },
   "upload":{
     "error": {
diff --git a/src/modules/config.js b/src/modules/config.js
@@ -34,7 +34,8 @@ export const defaultState = {
     likes: true,
     repeats: true,
     moves: true,
-    emojiReactions: false
+    emojiReactions: false,
+    followRequest: true
   },
   webPushNotifications: false,
   muteWords: [],
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
@@ -13,6 +13,7 @@ import {
   omitBy
 } from 'lodash'
 import { set } from 'vue'
+import { isStatusNotification } from '../services/notification_utils/notification_utils.js'
 import apiService from '../services/api/api.service.js'
 // import parse from '../services/status_parser/status_parser.js'
 
@@ -321,7 +322,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
 
 const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => {
   each(notifications, (notification) => {
-    if (notification.type !== 'follow' && notification.type !== 'move') {
+    if (isStatusNotification(notification.type)) {
       notification.action = addStatusToGlobalStorage(state, notification.action).item
       notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
     }
@@ -361,13 +362,16 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
           case 'move':
             i18nString = 'migrated_to'
             break
+          case 'follow_request':
+            i18nString = 'follow_request'
+            break
         }
 
         if (notification.type === 'pleroma:emoji_reaction') {
           notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
         } else if (i18nString) {
           notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
-        } else {
+        } else if (isStatusNotification(notification.type)) {
           notifObj.body = notification.status.text
         }
 
@@ -521,6 +525,17 @@ export const mutations = {
       notification.seen = true
     })
   },
+  markSingleNotificationAsSeen (state, { id }) {
+    const notification = find(state.notifications.data, n => n.id === id)
+    if (notification) notification.seen = true
+  },
+  dismissNotification (state, { id }) {
+    state.notifications.data = state.notifications.data.filter(n => n.id !== id)
+  },
+  updateNotification (state, { id, updater }) {
+    const notification = find(state.notifications.data, n => n.id === id)
+    notification && updater(notification)
+  },
   queueFlush (state, { timeline, id }) {
     state.timelines[timeline].flushMarker = id
   },
@@ -680,6 +695,24 @@ const statuses = {
         credentials: rootState.users.currentUser.credentials
       })
     },
+    markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
+      commit('markSingleNotificationAsSeen', { id })
+      apiService.markNotificationsAsSeen({
+        single: true,
+        id,
+        credentials: rootState.users.currentUser.credentials
+      })
+    },
+    dismissNotificationLocal ({ rootState, commit }, { id }) {
+      commit('dismissNotification', { id })
+    },
+    dismissNotification ({ rootState, commit }, { id }) {
+      commit('dismissNotification', { id })
+      rootState.api.backendInteractor.dismissNotification({ id })
+    },
+    updateNotification ({ rootState, commit }, { id, updater }) {
+      commit('updateNotification', { id, updater })
+    },
     fetchFavsAndRepeats ({ rootState, commit }, id) {
       Promise.all([
         rootState.api.backendInteractor.fetchFavoritedByUsers({ id }),
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
@@ -4,7 +4,6 @@ import 'whatwg-fetch'
 import { RegistrationError, StatusCodeError } from '../errors/errors'
 
 /* eslint-env browser */
-const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
 const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
 const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
 const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
@@ -17,6 +16,7 @@ const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
 const ADMIN_USERS_URL = '/api/pleroma/admin/users'
 const SUGGESTIONS_URL = '/api/v1/suggestions'
 const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
+const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
 
 const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
 const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
@@ -29,6 +29,7 @@ const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
 const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
 const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
 const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
+const MASTODON_DISMISS_NOTIFICATION_URL = id => `/api/v1/notifications/${id}/dismiss`
 const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
 const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite`
 const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog`
@@ -540,7 +541,7 @@ const fetchTimeline = ({
     params.push(['with_move', withMove])
   }
 
-  params.push(['count', 20])
+  params.push(['limit', 20])
   params.push(['with_muted', withMuted])
 
   const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
@@ -844,12 +845,16 @@ const suggestions = ({ credentials }) => {
   }).then((data) => data.json())
 }
 
-const markNotificationsAsSeen = ({ id, credentials }) => {
+const markNotificationsAsSeen = ({ id, credentials, single = false }) => {
   const body = new FormData()
 
-  body.append('latest_id', id)
+  if (single) {
+    body.append('id', id)
+  } else {
+    body.append('max_id', id)
+  }
 
-  return fetch(QVITTER_USER_NOTIFICATIONS_READ_URL, {
+  return fetch(NOTIFICATION_READ_URL, {
     body,
     headers: authHeaders(credentials),
     method: 'POST'
@@ -1010,6 +1015,15 @@ const unmuteDomain = ({ domain, credentials }) => {
   })
 }
 
+const dismissNotification = ({ credentials, id }) => {
+  return promisedRequest({
+    url: MASTODON_DISMISS_NOTIFICATION_URL(id),
+    method: 'POST',
+    payload: { id },
+    credentials
+  })
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1165,6 +1179,7 @@ const apiService = {
   denyUser,
   suggestions,
   markNotificationsAsSeen,
+  dismissNotification,
   vote,
   fetchPoll,
   fetchFavoritedByUsers,
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,4 +1,5 @@
 import escape from 'escape-html'
+import { isStatusNotification } from '../notification_utils/notification_utils.js'
 
 const qvitterStatusType = (status) => {
   if (status.is_post_verb) {
@@ -346,9 +347,7 @@ export const parseNotification = (data) => {
   if (masto) {
     output.type = mastoDict[data.type] || data.type
     output.seen = data.pleroma.is_seen
-    output.status = output.type === 'follow' || output.type === 'move'
-      ? null
-      : parseStatus(data.status)
+    output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
     output.action = output.status // TODO: Refactor, this is unneeded
     output.target = output.type !== 'move'
       ? null
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
@@ -1,4 +1,4 @@
-import { filter, sortBy } from 'lodash'
+import { filter, sortBy, includes } from 'lodash'
 
 export const notificationsFromStore = store => store.state.statuses.notifications.data
 
@@ -7,10 +7,15 @@ export const visibleTypes = store => ([
   store.state.config.notificationVisibility.mentions && 'mention',
   store.state.config.notificationVisibility.repeats && 'repeat',
   store.state.config.notificationVisibility.follows && 'follow',
+  store.state.config.notificationVisibility.followRequest && 'follow_request',
   store.state.config.notificationVisibility.moves && 'move',
   store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
 ].filter(_ => _))
 
+const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
+
+export const isStatusNotification = (type) => includes(statusNotifications, type)
+
 const sortById = (a, b) => {
   const seqA = Number(a.id)
   const seqB = Number(b.id)
diff --git a/static/fontello.json b/static/fontello.json
@@ -345,6 +345,18 @@
       "css": "link",
       "code": 59427,
       "src": "fontawesome"
+    },
+    {
+      "uid": "8b80d36d4ef43889db10bc1f0dc9a862",
+      "css": "user",
+      "code": 59428,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "12f4ece88e46abd864e40b35e05b11cd",
+      "css": "ok",
+      "code": 59431,
+      "src": "fontawesome"
     }
   ]
-}
+}
+\ No newline at end of file