From 9923ff587b49486cf2e8ba5bdcb717db5d8dda84 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Wed, 22 Jun 2022 18:46:47 +0300
Subject: [PATCH 1/5] Add private note field to user profile

---
 src/components/user_profile/user_profile.js  | 12 ++++++++++--
 src/components/user_profile/user_profile.vue | 10 ++++++++++
 src/i18n/en.json                             |  1 +
 src/modules/users.js                         |  8 ++++++++
 src/services/api/api.service.js              | 16 +++++++++++++++-
 5 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index f779b823..3ab1adba 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -40,7 +40,8 @@ const UserProfile = {
       error: false,
       userId: null,
       tab: defaultTabKey,
-      footerRef: null
+      footerRef: null,
+      note: null
     }
   },
   created () {
@@ -110,9 +111,13 @@ const UserProfile = {
       const user = this.$store.getters.findUser(userNameOrId)
       if (user) {
         loadById(user.id)
+        this.note = user.relationship.note
       } else {
         this.$store.dispatch('fetchUser', userNameOrId)
-          .then(({ id }) => loadById(id))
+          .then(({ id, relationship }) => {
+            this.note = relationship.note
+            return loadById(id)
+          })
           .catch((reason) => {
             const errorMessage = get(reason, 'error.error')
             if (errorMessage === 'No user with such user_id') { // Known error
@@ -145,6 +150,9 @@ const UserProfile = {
       if (target.tagName === 'A') {
         window.open(target.href, '_blank')
       }
+    },
+    setNote () {
+      this.$store.dispatch('setNote', { id: this.userId, note: this.note })
     }
   },
   watch: {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index b1a20269..602c002c 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -40,6 +40,12 @@
           </dd>
         </dl>
       </div>
+      <textarea
+        v-model="note"
+        class="note resize-height"
+        :placeholder="$t('user_card.note')"
+        @input="setNote"
+      />
       <tab-switcher
         :active-tab="tab"
         :render-only-focused="true"
@@ -202,6 +208,10 @@
     align-items: middle;
     padding: 2em;
   }
+
+  .note {
+    margin: 0.5em 0.75em;
+  }
 }
 .user-profile-placeholder {
   .panel-body {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3430620b..d17310d6 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -845,6 +845,7 @@
     "domain_muted": "Unblock domain",
     "mute_domain": "Block domain",
     "bot": "Bot",
+    "note": "Private note",
     "admin_menu": {
       "moderation": "Moderation",
       "grant_admin": "Grant Admin",
diff --git a/src/modules/users.js b/src/modules/users.js
index 319b6b18..12eb621f 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -102,6 +102,11 @@ const unmuteDomain = (store, domain) => {
     .then(() => store.commit('removeDomainMute', domain))
 }
 
+const setNote = (store, { id, note }) => {
+  return store.rootState.api.backendInteractor.setNote({ id, note })
+    .then((relationship) => store.commit('updateUserRelationship', [relationship]))
+}
+
 export const mutations = {
   tagUser (state, { user: { id }, tag }) {
     const user = state.usersObject[id]
@@ -366,6 +371,9 @@ const users = {
     unmuteDomains (store, domain = []) {
       return Promise.all(domain.map(domain => unmuteDomain(store, domain)))
     },
+    setNote (store, { id, note }) {
+      return setNote(store, { id, note })
+    },
     fetchFriends ({ rootState, commit }, id) {
       const user = rootState.users.usersObject[id]
       const maxId = last(user.friendIds)
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index fa3439e9..dee5e111 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -63,6 +63,7 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
 const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
 const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
 const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
+const MASTODON_SET_NOTE_URL = id => `/api/v1/accounts/${id}/note`
 const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
 const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
 const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
@@ -310,7 +311,7 @@ const denyUser = ({ id, credentials }) => {
 }
 
 const fetchUser = ({ id, credentials }) => {
-  let url = `${MASTODON_USER_URL}/${id}`
+  let url = `${MASTODON_USER_URL}/${id}` + '?with_relationships=true'
   return promisedRequest({ url, credentials })
     .then((data) => parseUser(data))
 }
@@ -948,6 +949,18 @@ const unmuteUser = ({ id, credentials }) => {
   return promisedRequest({ url: MASTODON_UNMUTE_USER_URL(id), credentials, method: 'POST' })
 }
 
+const setNote = ({ id, note, credentials }) => {
+  const form = new FormData()
+
+  form.append('comment', note)
+
+  return fetch(MASTODON_SET_NOTE_URL(id), {
+    body: form,
+    headers: authHeaders(credentials),
+    method: 'POST'
+  }).then((data) => data.json())
+}
+
 const fetchMascot = ({ credentials }) => {
   return promisedRequest({ url: MASTODON_MASCOT_URL, credentials })
 }
@@ -1405,6 +1418,7 @@ const apiService = {
   fetchMutes,
   muteUser,
   unmuteUser,
+  setNote,
   subscribeUser,
   unsubscribeUser,
   fetchBlocks,

From fb2fc686b1fade7165fa595a56f025aeebceaadc Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Wed, 22 Jun 2022 19:42:27 +0300
Subject: [PATCH 2/5] Add debounce to API request

---
 src/components/user_profile/user_profile.js | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 3ab1adba..3390e732 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -7,6 +7,7 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import List from '../list/list.vue'
 import withLoadMore from '../../hocs/with_load_more/with_load_more'
+import { debounce } from 'lodash'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faCircleNotch
@@ -151,9 +152,9 @@ const UserProfile = {
         window.open(target.href, '_blank')
       }
     },
-    setNote () {
+    setNote: debounce(function () {
       this.$store.dispatch('setNote', { id: this.userId, note: this.note })
-    }
+    }, 1500)
   },
   watch: {
     '$route.params.id': function (newVal) {

From 4f0eabbd5526472427e33f179d277690427b2831 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Thu, 23 Jun 2022 15:02:43 +0300
Subject: [PATCH 3/5] api: turn MASTODON_USER_URL into a function

---
 src/services/api/api.service.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index dee5e111..b9be5eb0 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -47,7 +47,7 @@ const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public'
 const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home'
 const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}`
 const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context`
-const MASTODON_USER_URL = '/api/v1/accounts'
+const MASTODON_USER_URL = id => `/api/v1/accounts/${id}?with_relationships=true`
 const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
 const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
 const MASTODON_LIST_URL = id => `/api/v1/lists/${id}`
@@ -311,7 +311,7 @@ const denyUser = ({ id, credentials }) => {
 }
 
 const fetchUser = ({ id, credentials }) => {
-  let url = `${MASTODON_USER_URL}/${id}` + '?with_relationships=true'
+  const url = MASTODON_USER_URL(id)
   return promisedRequest({ url, credentials })
     .then((data) => parseUser(data))
 }

From 398b2624c83f65cb627546fce91508f623a51c22 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Thu, 23 Jun 2022 15:04:19 +0300
Subject: [PATCH 4/5] Add note saving indicator

---
 src/components/user_profile/user_profile.js  | 10 ++++--
 src/components/user_profile/user_profile.vue | 36 ++++++++++++++++----
 2 files changed, 38 insertions(+), 8 deletions(-)

diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 3390e732..b4c604f8 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -42,7 +42,8 @@ const UserProfile = {
       userId: null,
       tab: defaultTabKey,
       footerRef: null,
-      note: null
+      note: null,
+      noteLoading: false
     }
   },
   created () {
@@ -152,8 +153,13 @@ const UserProfile = {
         window.open(target.href, '_blank')
       }
     },
-    setNote: debounce(function () {
+    setNote () {
+      this.noteLoading = true
+      this.debounceSetNote()
+    },
+    debounceSetNote: debounce(function () {
       this.$store.dispatch('setNote', { id: this.userId, note: this.note })
+      this.noteLoading = false
     }, 1500)
   },
   watch: {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 602c002c..5a47cdde 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -40,12 +40,24 @@
           </dd>
         </dl>
       </div>
-      <textarea
-        v-model="note"
-        class="note resize-height"
-        :placeholder="$t('user_card.note')"
-        @input="setNote"
-      />
+      <div class="note">
+        <textarea
+          v-model="note"
+          class="resize-height"
+          :placeholder="$t('user_card.note')"
+          @input="setNote"
+        />
+        <div
+          v-show="noteLoading"
+          class="preview-spinner"
+        >
+          <FAIcon
+            class="fa-old-padding"
+            spin
+            icon="circle-notch"
+          />
+        </div>
+      </div>
       <tab-switcher
         :active-tab="tab"
         :render-only-focused="true"
@@ -210,7 +222,19 @@
   }
 
   .note {
+    position: relative;
     margin: 0.5em 0.75em;
+
+    textarea {
+      width: 100%;
+    }
+
+    .preview-spinner {
+      position: absolute;
+      top: 0;
+      right: 0;
+      margin: 0.5em 0.25em;
+    }
   }
 }
 .user-profile-placeholder {

From 231a4af7545e6989506e70bb91aef1aaa31899f7 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Thu, 23 Jun 2022 15:04:54 +0300
Subject: [PATCH 5/5] Disable private note for self

---
 src/components/user_profile/user_profile.vue | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 5a47cdde..7e3599f7 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -40,7 +40,10 @@
           </dd>
         </dl>
       </div>
-      <div class="note">
+      <div
+        v-if="!isUs"
+        class="note"
+      >
         <textarea
           v-model="note"
           class="resize-height"