From dbb16d56e29b8a32fb7c1a7af56a7953f571cdb4 Mon Sep 17 00:00:00 2001
From: shpuld <shp@cock.li>
Date: Sat, 2 Feb 2019 22:29:10 +0200
Subject: [PATCH] follows/followers pagination ready for review

---
 .../followers_list/followers_list.js          | 61 +++++++++++++++
 .../followers_list/followers_list.vue         | 12 +++
 src/components/friends_list/friends_list.js   | 61 +++++++++++++++
 src/components/friends_list/friends_list.vue  | 12 +++
 src/components/user_card/user_card.vue        |  4 +-
 src/components/user_profile/user_profile.js   | 78 ++++++-------------
 src/components/user_profile/user_profile.vue  |  9 +--
 src/modules/users.js                          | 66 +++++++++++++---
 .../user_profile_link_generator.js            |  2 +-
 9 files changed, 229 insertions(+), 76 deletions(-)
 create mode 100644 src/components/followers_list/followers_list.js
 create mode 100644 src/components/followers_list/followers_list.vue
 create mode 100644 src/components/friends_list/friends_list.js
 create mode 100644 src/components/friends_list/friends_list.vue

diff --git a/src/components/followers_list/followers_list.js b/src/components/followers_list/followers_list.js
new file mode 100644
index 00000000..13aace18
--- /dev/null
+++ b/src/components/followers_list/followers_list.js
@@ -0,0 +1,61 @@
+import UserCard from '../user_card/user_card.vue'
+
+const FollowersList = {
+  data () {
+    return {
+      loading: false,
+      bottomedOut: false,
+      error: false
+    }
+  },
+  props: ['userId'],
+  created () {
+    window.addEventListener('scroll', this.scrollLoad)
+    if (this.user.followers.length === 0) {
+      this.fetchFollowers()
+    }
+  },
+  destroyed () {
+    window.removeEventListener('scroll', this.scrollLoad)
+    this.$store.dispatch('clearFriendsAndFollowers', this.userId)
+  },
+  computed: {
+    user () {
+      return this.$store.getters.userById(this.userId)
+    },
+    followers () {
+      return this.user.followers
+    }
+  },
+  methods: {
+    fetchFollowers () {
+      if (!this.loading) {
+        this.loading = true
+        this.$store.dispatch('addFollowers', this.userId).then(followers => {
+          this.error = false
+          this.loading = false
+          this.bottomedOut = followers.length === 0
+        }).catch(() => {
+          this.error = true
+          this.loading = false
+        })
+      }
+    },
+    scrollLoad (e) {
+      const bodyBRect = document.body.getBoundingClientRect()
+      const height = Math.max(bodyBRect.height, -(bodyBRect.y))
+      if (this.loading === false &&
+        this.bottomedOut === false &&
+        this.$el.offsetHeight > 0 &&
+        (window.innerHeight + window.pageYOffset) >= (height - 750)
+      ) {
+        this.fetchFollowers()
+      }
+    }
+  },
+  components: {
+    UserCard
+  }
+}
+
+export default FollowersList
diff --git a/src/components/followers_list/followers_list.vue b/src/components/followers_list/followers_list.vue
new file mode 100644
index 00000000..b6bd35e1
--- /dev/null
+++ b/src/components/followers_list/followers_list.vue
@@ -0,0 +1,12 @@
+<template>
+  <div>
+    <user-card v-for="follower in followers" :key="follower.id" :user="follower" :showFollows="true"></user-card>
+    <div @click="fetchFollowers" class="new-status-notification text-center panel-footer">
+      <span v-if="error" class="alert error">Error loading followers</span>
+      <i v-else-if="loading" class="icon-spin3 animate-spin"/>
+      <span v-else-if="bottomedOut"></span>
+    </div>
+  </div>
+</template>
+
+<script src="./followers_list.js" />
diff --git a/src/components/friends_list/friends_list.js b/src/components/friends_list/friends_list.js
new file mode 100644
index 00000000..d5c1837a
--- /dev/null
+++ b/src/components/friends_list/friends_list.js
@@ -0,0 +1,61 @@
+import UserCard from '../user_card/user_card.vue'
+
+const FriendsList = {
+  data () {
+    return {
+      loading: false,
+      bottomedOut: false,
+      error: false
+    }
+  },
+  props: ['userId'],
+  created () {
+    window.addEventListener('scroll', this.scrollLoad)
+    if (this.user.followers.length === 0) {
+      this.fetchFriends()
+    }
+  },
+  destroyed () {
+    window.removeEventListener('scroll', this.scrollLoad)
+    this.$store.dispatch('clearFriendsAndFollowers', this.userId)
+  },
+  computed: {
+    user () {
+      return this.$store.getters.userById(this.userId)
+    },
+    friends () {
+      return this.user.friends
+    }
+  },
+  methods: {
+    fetchFriends () {
+      if (!this.loading) {
+        this.loading = true
+        this.$store.dispatch('addFriends', this.userId).then(friends => {
+          this.error = false
+          this.loading = false
+          this.bottomedOut = friends.length === 0
+        }).catch(() => {
+          this.error = true
+          this.loading = false
+        })
+      }
+    },
+    scrollLoad (e) {
+      const bodyBRect = document.body.getBoundingClientRect()
+      const height = Math.max(bodyBRect.height, -(bodyBRect.y))
+      if (this.loading === false &&
+        this.bottomedOut === false &&
+        this.$el.offsetHeight > 0 &&
+        (window.innerHeight + window.pageYOffset) >= (height - 750)
+      ) {
+        this.fetchFriends()
+      }
+    }
+  },
+  components: {
+    UserCard
+  }
+}
+
+export default FriendsList
diff --git a/src/components/friends_list/friends_list.vue b/src/components/friends_list/friends_list.vue
new file mode 100644
index 00000000..75657cc8
--- /dev/null
+++ b/src/components/friends_list/friends_list.vue
@@ -0,0 +1,12 @@
+<template>
+  <div>
+    <user-card v-for="friend in friends" :key="friend.id" :user="friend" :showFollows="true"></user-card>
+    <div @click="fetchFriends" class="new-status-notification text-center panel-footer">
+      <span v-if="error" class="alert error">Error loading follows</span>
+      <i v-else-if="loading" class="icon-spin3 animate-spin"/>
+      <span v-else-if="bottomedOut"></span>
+    </div>
+  </div>
+</template>
+
+<script src="./friends_list.js" />
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index cf69606d..2a590889 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="card">
     <a href="#">
-      <StillImage @click.prevent="toggleUserExpanded" class="avatar" :src="user.profile_image_url"/>
+      <StillImage @click.prevent.native="toggleUserExpanded" class="avatar" :src="user.profile_image_url"/>
     </a>
     <div class="usercard" v-if="userExpanded">
       <user-card-content :user="user" :switcher="false"></user-card-content>
@@ -79,7 +79,7 @@
 
 .usercard {
   width: fill-available;
-  margin: 0.2em 0 0.7em 0;
+  margin: 0.2em 0 0 0.7em;
   border-radius: $fallback--panelRadius;
   border-radius: var(--panelRadius, $fallback--panelRadius);
   border-style: solid;
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 4f09c9e7..0361d253 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,6 +1,8 @@
 import UserCardContent from '../user_card_content/user_card_content.vue'
 import UserCard from '../user_card/user_card.vue'
 import Timeline from '../timeline/timeline.vue'
+import FriendsList from '../friends_list/friends_list.vue'
+import FollowersList from '../followers_list/followers_list.vue'
 
 const UserProfile = {
   created () {
@@ -15,15 +17,7 @@ const UserProfile = {
     }
   },
   destroyed () {
-    this.$store.dispatch('stopFetching', 'user')
-    this.$store.dispatch('stopFetching', 'favorites')
-    this.$store.dispatch('stopFetching', 'media')
-  },
-  data () {
-    return {
-      followsPage: 0,
-      followersPage: 0
-    }
+    this.cleanUp(this.userId)
   },
   computed: {
     timeline () {
@@ -45,12 +39,6 @@ const UserProfile = {
       return this.userId && this.$store.state.users.currentUser.id &&
         this.userId === this.$store.state.users.currentUser.id
     },
-    friends () {
-      return this.user.friends
-    },
-    followers () {
-      return this.user.followers
-    },
     userInStore () {
       if (this.isExternal) {
         return this.$store.getters.userById(this.userId)
@@ -74,66 +62,48 @@ const UserProfile = {
     }
   },
   methods: {
-    fetchFollowers () {
-      const id = this.userId
-      this.$store.dispatch('addFollowers', { id })
-    },
-    fetchFriends () {
-      const id = this.userId
-      this.$store.dispatch('addFriends', { id })
-    },
     startFetchFavorites () {
       if (this.isUs) {
         this.$store.dispatch('startFetching', ['favorites', this.fetchBy])
       }
     },
-    nextFollowsPage () {
-      this.followsPage += 1
-      this.$store.dispatch('addFriends', { id: this.userId, page: this.followsPage })
-      console.log(this.user.friends)
-    }
-  },
-  watch: {
-    // TODO get rid of this copypasta
-    userName () {
-      if (this.isExternal) {
-        return
-      }
+    startUp () {
+      this.$store.dispatch('startFetching', ['user', this.fetchBy])
+      this.$store.dispatch('startFetching', ['media', this.fetchBy])
+
+      this.startFetchFavorites()
+    },
+    cleanUp () {
       this.$store.dispatch('stopFetching', 'user')
       this.$store.dispatch('stopFetching', 'favorites')
       this.$store.dispatch('stopFetching', 'media')
       this.$store.commit('clearTimeline', { timeline: 'user' })
       this.$store.commit('clearTimeline', { timeline: 'favorites' })
       this.$store.commit('clearTimeline', { timeline: 'media' })
-      this.$store.dispatch('startFetching', ['user', this.fetchBy])
-      this.$store.dispatch('startFetching', ['media', this.fetchBy])
-      this.startFetchFavorites()
+    }
+  },
+  watch: {
+    userName () {
+      if (this.isExternal) {
+        return
+      }
+      this.cleanUp()
+      this.startUp()
     },
     userId () {
       if (!this.isExternal) {
         return
       }
-      this.$store.dispatch('stopFetching', 'user')
-      this.$store.dispatch('stopFetching', 'favorites')
-      this.$store.dispatch('stopFetching', 'media')
-      this.$store.commit('clearTimeline', { timeline: 'user' })
-      this.$store.commit('clearTimeline', { timeline: 'favorites' })
-      this.$store.commit('clearTimeline', { timeline: 'media' })
-      this.$store.dispatch('startFetching', ['user', this.fetchBy])
-      this.$store.dispatch('startFetching', ['media', this.fetchBy])
-      this.startFetchFavorites()
-    },
-    user () {
-      if (this.user.id && !this.user.followers) {
-        this.fetchFollowers()
-        this.fetchFriends()
-      }
+      this.cleanUp()
+      this.startUp()
     }
   },
   components: {
     UserCardContent,
     UserCard,
-    Timeline
+    Timeline,
+    FriendsList,
+    FollowersList
   }
 }
 
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index baf6aef6..4ba09dc3 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -5,18 +5,13 @@
     <tab-switcher>
       <Timeline :label="$t('user_card.statuses')" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="fetchBy"/>
       <div :label="$t('user_card.followees')">
-        <div v-if="friends">
-          <user-card v-for="friend in friends" :key="friend.id" :user="friend" :showFollows="true"></user-card>
-          <div @click="nextFollowsPage" class="panel-footer">MORE</div>
-        </div>
+        <FriendsList v-if="user.friends_count > 0" :userId="userId" />
         <div class="userlist-placeholder" v-else>
           <i class="icon-spin3 animate-spin"></i>
         </div>
       </div>
       <div :label="$t('user_card.followers')">
-        <div v-if="followers">
-          <user-card v-for="follower in followers" :key="follower.id" :user="follower" :showFollows="false"></user-card>
-        </div>
+        <FollowersList v-if="user.followers_count > 0" :userId="userId" />
         <div class="userlist-placeholder" v-else>
           <i class="icon-spin3 animate-spin"></i>
         </div>
diff --git a/src/modules/users.js b/src/modules/users.js
index ca8e5606..ca2e0f31 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,5 +1,5 @@
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
-import { compact, map, each, merge, concat } from 'lodash'
+import { compact, map, each, merge, find } from 'lodash'
 import { set } from 'vue'
 import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
 import oauthApi from '../services/new_api/oauth'
@@ -52,14 +52,35 @@ export const mutations = {
     state.loggingIn = false
   },
   // TODO Clean after ourselves?
-  addFriends (state, { id, friends }) {
+  addFriends (state, { id, friends, page }) {
     const user = state.usersObject[id]
-    console.log(user.friends)
-    user.friends = concat(user.friends, friends)
+    each(friends, friend => {
+      if (!find(user.friends, { id: friend.id })) {
+        user.friends.push(friend)
+      }
+    })
+    user.friendsPage = page + 1
   },
-  addFollowers (state, { id, followers }) {
+  addFollowers (state, { id, followers, page }) {
     const user = state.usersObject[id]
-    user.followers = followers
+    each(followers, follower => {
+      if (!find(user.followers, { id: follower.id })) {
+        user.followers.push(follower)
+      }
+    })
+    user.followersPage = page + 1
+  },
+  // Because frontend doesn't have a reason to keep these stuff in memory
+  // outside of viewing someones user profile.
+  clearFriendsAndFollowers (state, userKey) {
+    const user = state.usersObject[userKey]
+    if (!user) {
+      return
+    }
+    user.friends = []
+    user.followers = []
+    user.friendsPage = 0
+    user.followersPage = 0
   },
   addNewUsers (state, users) {
     each(users, (user) => mergeOrAdd(state.users, state.usersObject, user))
@@ -116,13 +137,34 @@ const users = {
       store.rootState.api.backendInteractor.fetchUser({ id })
         .then((user) => store.commit('addNewUsers', [user]))
     },
-    addFriends ({ rootState, commit }, { id, page }) {
-      rootState.api.backendInteractor.fetchFriends({ id, page })
-        .then((friends) => commit('addFriends', { id, friends }))
+    addFriends ({ rootState, commit }, fetchBy) {
+      return new Promise((resolve, reject) => {
+        const user = rootState.users.usersObject[fetchBy]
+        const page = user.friendsPage || 1
+        rootState.api.backendInteractor.fetchFriends({ id: user.id, page })
+          .then((friends) => {
+            commit('addFriends', { id: user.id, friends, page })
+            resolve(friends)
+          }).catch(() => {
+            reject()
+          })
+      })
     },
-    addFollowers ({ rootState, commit }, { id, page }) {
-      rootState.api.backendInteractor.fetchFollowers({ id, page })
-        .then((followers) => commit('addFollowers', { id, followers }))
+    addFollowers ({ rootState, commit }, fetchBy) {
+      return new Promise((resolve, reject) => {
+        const user = rootState.users.usersObject[fetchBy]
+        const page = user.followersPage || 1
+        rootState.api.backendInteractor.fetchFollowers({ id: user.id, page })
+          .then((followers) => {
+            commit('addFollowers', { id: user.id, followers, page })
+            resolve(followers)
+          }).catch(() => {
+            reject()
+          })
+      })
+    },
+    clearFriendsAndFollowers ({ commit }, userKey) {
+      commit('clearFriendsAndFollowers', userKey)
     },
     registerPushNotifications (store) {
       const token = store.state.currentUser.credentials
diff --git a/src/services/user_profile_link_generator/user_profile_link_generator.js b/src/services/user_profile_link_generator/user_profile_link_generator.js
index bca2c9cd..a214ca48 100644
--- a/src/services/user_profile_link_generator/user_profile_link_generator.js
+++ b/src/services/user_profile_link_generator/user_profile_link_generator.js
@@ -8,6 +8,6 @@ const generateProfileLink = (id, screenName, restrictedNicknames) => {
   }
 }
 
-const isExternal = screenName => screenName.includes('@')
+const isExternal = screenName => screenName && screenName.includes('@')
 
 export default generateProfileLink