ProfileFollowers.vue 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. <template>
  2. <div class="profile-followers-component">
  3. <div class="row justify-content-center">
  4. <div class="col-12 col-md-8">
  5. <div v-if="isLoaded" class="d-flex justify-content-between align-items-center mb-4">
  6. <div>
  7. <button
  8. class="btn btn-outline-dark rounded-pill font-weight-bold"
  9. @click="goBack()">
  10. Back
  11. </button>
  12. </div>
  13. <div class="d-flex align-items-center justify-content-center flex-column w-100 overflow-hidden">
  14. <p class="small text-muted mb-0 text-uppercase font-weight-light cursor-pointer text-truncate text-center" style="width: 70%;" @click="goBack()">&commat;{{ profile.acct }}</p>
  15. <p class="lead font-weight-bold mt-n1 mb-0">{{ $t('profile.followers') }}</p>
  16. </div>
  17. <div>
  18. <a class="btn btn-dark rounded-pill font-weight-bold spacer-btn" href="#">Back</a>
  19. </div>
  20. </div>
  21. <div v-if="isLoaded" class="list-group scroll-card">
  22. <div v-for="(account, idx) in feed" class="list-group-item">
  23. <a
  24. :id="'apop_'+account.id"
  25. :href="account.url"
  26. @click.prevent="goToProfile(account)"
  27. class="text-decoration-none">
  28. <div class="media">
  29. <img
  30. :src="account.avatar"
  31. width="40"
  32. height="40"
  33. style="border-radius: 8px;"
  34. class="mr-3 shadow-sm"
  35. draggable="false"
  36. loading="lazy"
  37. onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
  38. <div class="media-body">
  39. <p class="mb-0 text-truncate">
  40. <span class="text-dark font-weight-bold text-decoration-none" v-html="getUsername(account)"></span>
  41. </p>
  42. <p class="mb-0 mt-n1 text-muted small text-break">&commat;{{ account.acct }}</p>
  43. </div>
  44. </div>
  45. </a>
  46. <b-popover :target="'apop_'+account.id" triggers="hover" placement="left" delay="1000" custom-class="shadow border-0 rounded-px">
  47. <profile-hover-card :profile="account" />
  48. </b-popover>
  49. </div>
  50. <div v-if="canLoadMore">
  51. <intersect @enter="enterIntersect">
  52. <placeholder />
  53. </intersect>
  54. </div>
  55. <div v-if="!canLoadMore && !feed.length">
  56. <div class="list-group-item text-center">
  57. <div v-if="isWarmingCache" class="px-4">
  58. <p class="mb-0 lead font-weight-bold">Loading Followers...</p>
  59. <div class="py-3">
  60. <b-spinner variant="primary" style="width: 1.5rem; height: 1.5rem;" />
  61. </div>
  62. <p class="small text-muted mb-0">Please wait while we collect followers of this account, this shouldn't take long!</p>
  63. </div>
  64. <p v-else class="mb-0 font-weight-bold">No followers yet!</p>
  65. </div>
  66. </div>
  67. </div>
  68. <div v-else class="list-group">
  69. <placeholder />
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. </template>
  75. <script type="text/javascript">
  76. import Intersect from 'vue-intersect'
  77. import Placeholder from './../post/LikeListPlaceholder.vue';
  78. import ProfileHoverCard from './ProfileHoverCard.vue';
  79. import { mapGetters } from 'vuex';
  80. import { parseLinkHeader } from '@web3-storage/parse-link-header';
  81. export default {
  82. props: {
  83. profile: {
  84. type: Object
  85. }
  86. },
  87. components: {
  88. ProfileHoverCard,
  89. Intersect,
  90. Placeholder
  91. },
  92. computed: {
  93. ...mapGetters([
  94. 'getCustomEmoji'
  95. ])
  96. },
  97. data() {
  98. return {
  99. isLoaded: false,
  100. feed: [],
  101. page: 1,
  102. cursor: null,
  103. canLoadMore: true,
  104. isFetchingMore: false,
  105. isWarmingCache: false,
  106. cacheWarmTimeout: undefined,
  107. cacheWarmInterations: 0,
  108. }
  109. },
  110. mounted() {
  111. this.fetchFollowers();
  112. },
  113. beforeDestroy() {
  114. clearTimeout(this.cacheWarmTimeout);
  115. },
  116. methods: {
  117. fetchFollowers() {
  118. axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
  119. params: {
  120. cursor: this.cursor,
  121. '_pe': 1
  122. }
  123. }).then(res => {
  124. if(!res.data.length) {
  125. this.canLoadMore = false;
  126. this.isLoaded = true;
  127. if(this.cursor == null && this.profile.followers_count) {
  128. this.isWarmingCache = true;
  129. this.setCacheWarmTimeout();
  130. }
  131. return;
  132. }
  133. if(res.headers && res.headers.link) {
  134. const links = parseLinkHeader(res.headers.link);
  135. if(links.prev) {
  136. this.cursor = links.prev.cursor;
  137. this.canLoadMore = true;
  138. } else {
  139. this.canLoadMore = false;
  140. }
  141. } else {
  142. this.canLoadMore = false;
  143. }
  144. this.feed.push(...res.data);
  145. this.isLoaded = true;
  146. this.isFetchingMore = false;
  147. if(this.isWarmingCache || this.cacheWarmTimeout) {
  148. this.isWarmingCache = false;
  149. clearTimeout(this.cacheWarmTimeout);
  150. this.cacheWarmTimeout = undefined;
  151. }
  152. })
  153. .catch(err => {
  154. this.canLoadMore = false;
  155. this.isLoaded = true;
  156. this.isFetchingMore = false;
  157. })
  158. },
  159. enterIntersect() {
  160. if(this.isFetchingMore) {
  161. return;
  162. }
  163. this.isFetchingMore = true;
  164. this.fetchFollowers();
  165. },
  166. getUsername(profile) {
  167. let self = this;
  168. let dn = profile.display_name;
  169. if(!dn || !dn.trim().length) {
  170. return profile.username;
  171. }
  172. if(dn.includes(':')) {
  173. let re = /(<a?)?:\w+:(\d{18}>)?/g;
  174. let un = dn.replaceAll(re, function(em) {
  175. let shortcode = em.slice(1, em.length - 1);
  176. let emoji = self.getCustomEmoji.filter(e => {
  177. return e.shortcode == shortcode;
  178. });
  179. return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
  180. });
  181. return un;
  182. } else {
  183. return dn;
  184. }
  185. },
  186. goToProfile(account) {
  187. this.$router.push({
  188. path: `/i/web/profile/${account.id}`,
  189. params: {
  190. id: account.id,
  191. cachedProfile: account,
  192. cachedUser: this.profile
  193. }
  194. })
  195. },
  196. goBack() {
  197. this.$emit('back');
  198. },
  199. setCacheWarmTimeout() {
  200. if(this.cacheWarmInterations >= 5) {
  201. this.isWarmingCache = false;
  202. swal('Oops', 'Its taking longer than expected to collect this account followers. Please try again later', 'error');
  203. return;
  204. }
  205. this.cacheWarmTimeout = setTimeout(() => {
  206. this.cacheWarmInterations++;
  207. this.fetchFollowers();
  208. }, 45000);
  209. }
  210. }
  211. }
  212. </script>
  213. <style lang="scss">
  214. .profile-followers-component {
  215. .list-group-item {
  216. border: none;
  217. &:not(:last-child) {
  218. border-bottom: 1px solid rgba(0, 0, 0, 0.125);
  219. }
  220. }
  221. .scroll-card {
  222. max-height: calc(100vh - 250px);
  223. overflow-y: auto;
  224. -ms-overflow-style: none;
  225. scrollbar-width: none;
  226. scroll-behavior: smooth;
  227. &::-webkit-scrollbar {
  228. display: none;
  229. }
  230. }
  231. .spacer-btn {
  232. opacity: 0;
  233. pointer-events: none;
  234. }
  235. }
  236. </style>