Timeline.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. <template>
  2. <div class="timeline-section-component">
  3. <div v-if="!isLoaded">
  4. <status-placeholder />
  5. <status-placeholder />
  6. <status-placeholder />
  7. <status-placeholder />
  8. </div>
  9. <div v-else>
  10. <status
  11. v-for="(status, index) in feed"
  12. :key="'pf_feed:' + status.id + ':idx:' + index + ':fui:' + forceUpdateIdx"
  13. :status="status"
  14. :profile="profile"
  15. v-on:like="likeStatus(index)"
  16. v-on:unlike="unlikeStatus(index)"
  17. v-on:share="shareStatus(index)"
  18. v-on:unshare="unshareStatus(index)"
  19. v-on:menu="openContextMenu(index)"
  20. v-on:counter-change="counterChange(index, $event)"
  21. v-on:likes-modal="openLikesModal(index)"
  22. v-on:shares-modal="openSharesModal(index)"
  23. v-on:follow="follow(index)"
  24. v-on:unfollow="unfollow(index)"
  25. v-on:comment-likes-modal="openCommentLikesModal"
  26. v-on:handle-report="handleReport"
  27. v-on:bookmark="handleBookmark(index)"
  28. v-on:mod-tools="handleModTools(index)"
  29. />
  30. <div v-if="showLoadMore" class="text-center">
  31. <button
  32. class="btn btn-primary rounded-pill font-weight-bold"
  33. @click="tryToLoadMore">
  34. Load more
  35. </button>
  36. </div>
  37. <div v-if="canLoadMore">
  38. <intersect @enter="enterIntersect">
  39. <status-placeholder style="margin-bottom: 10rem;"/>
  40. </intersect>
  41. </div>
  42. <div v-if="!isLoaded && feed.length && endFeedReached" style="margin-bottom: 50vh">
  43. <div class="card card-body shadow-sm mb-3" style="border-radius: 15px;">
  44. <p class="display-4 text-center">✨</p>
  45. <p class="lead mb-0 text-center">You have reached the end of this feed</p>
  46. </div>
  47. </div>
  48. <timeline-onboarding
  49. v-if="scope == 'home' && !feed.length"
  50. :profile="profile"
  51. v-on:update-profile="updateProfile" />
  52. <empty-timeline v-if="isLoaded && scope !== 'home' && !feed.length" />
  53. </div>
  54. <context-menu
  55. v-if="showMenu"
  56. ref="contextMenu"
  57. :status="feed[postIndex]"
  58. :profile="profile"
  59. v-on:moderate="commitModeration"
  60. v-on:delete="deletePost"
  61. v-on:report-modal="handleReport"
  62. v-on:edit="handleEdit"
  63. />
  64. <likes-modal
  65. v-if="showLikesModal"
  66. ref="likesModal"
  67. :status="likesModalPost"
  68. :profile="profile"
  69. />
  70. <shares-modal
  71. v-if="showSharesModal"
  72. ref="sharesModal"
  73. :status="sharesModalPost"
  74. :profile="profile"
  75. />
  76. <report-modal
  77. ref="reportModal"
  78. :key="reportedStatusId"
  79. :status="reportedStatus"
  80. />
  81. <post-edit-modal
  82. ref="editModal"
  83. v-on:update="mergeUpdatedPost"
  84. />
  85. </div>
  86. </template>
  87. <script type="text/javascript">
  88. import StatusPlaceholder from './../partials/StatusPlaceholder.vue';
  89. import Status from './../partials/TimelineStatus.vue';
  90. import Intersect from 'vue-intersect';
  91. import ContextMenu from './../partials/post/ContextMenu.vue';
  92. import LikesModal from './../partials/post/LikeModal.vue';
  93. import SharesModal from './../partials/post/ShareModal.vue';
  94. import ReportModal from './../partials/modal/ReportPost.vue';
  95. import EmptyTimeline from './../partials/placeholders/EmptyTimeline.vue'
  96. import TimelineOnboarding from './../partials/placeholders/TimelineOnboarding.vue'
  97. import PostEditModal from './../partials/post/PostEditModal.vue';
  98. export default {
  99. props: {
  100. scope: {
  101. type: String,
  102. default: "home"
  103. },
  104. profile: {
  105. type: Object
  106. },
  107. refresh: {
  108. type: Boolean,
  109. default: false
  110. }
  111. },
  112. components: {
  113. "intersect": Intersect,
  114. "status-placeholder": StatusPlaceholder,
  115. "status": Status,
  116. "context-menu": ContextMenu,
  117. "likes-modal": LikesModal,
  118. "shares-modal": SharesModal,
  119. "report-modal": ReportModal,
  120. "empty-timeline": EmptyTimeline,
  121. "timeline-onboarding": TimelineOnboarding,
  122. "post-edit-modal": PostEditModal
  123. },
  124. data() {
  125. return {
  126. isLoaded: false,
  127. feed: [],
  128. ids: [],
  129. max_id: 0,
  130. canLoadMore: true,
  131. showLoadMore: false,
  132. loadMoreTimeout: undefined,
  133. loadMoreAttempts: 0,
  134. isFetchingMore: false,
  135. endFeedReached: false,
  136. postIndex: 0,
  137. showMenu: false,
  138. showLikesModal: false,
  139. likesModalPost: {},
  140. showReportModal: false,
  141. reportedStatus: {},
  142. reportedStatusId: 0,
  143. showSharesModal: false,
  144. sharesModalPost: {},
  145. forceUpdateIdx: 0
  146. }
  147. },
  148. mounted() {
  149. if(window.App.config.features.hasOwnProperty('timelines')) {
  150. if(this.scope == 'local' && !window.App.config.features.timelines.local) {
  151. swal('Error', 'Cannot load this timeline', 'error');
  152. return;
  153. };
  154. if(this.scope == 'network' && !window.App.config.features.timelines.network) {
  155. swal('Error', 'Cannot load this timeline', 'error');
  156. return;
  157. };
  158. }
  159. this.fetchTimeline();
  160. },
  161. methods: {
  162. getScope() {
  163. switch(this.scope) {
  164. case 'local':
  165. return 'public'
  166. break;
  167. case 'global':
  168. return 'network'
  169. break;
  170. default:
  171. return 'home';
  172. break;
  173. }
  174. },
  175. fetchTimeline(scrollToTop = false) {
  176. let url = `/api/pixelfed/v1/timelines/${this.getScope()}`;
  177. axios.get(url, {
  178. params: {
  179. max_id: this.max_id,
  180. limit: 6
  181. }
  182. }).then(res => {
  183. let ids = res.data.map(p => {
  184. if(p && p.hasOwnProperty('relationship')) {
  185. this.$store.commit('updateRelationship', [p.relationship]);
  186. }
  187. return p.id
  188. });
  189. this.isLoaded = true;
  190. if(res.data.length == 0) {
  191. return;
  192. }
  193. this.ids = ids;
  194. this.max_id = Math.min(...ids);
  195. this.feed = res.data;
  196. if(res.data.length !== 6) {
  197. this.canLoadMore = false;
  198. this.showLoadMore = true;
  199. }
  200. })
  201. .then(() => {
  202. if(scrollToTop) {
  203. this.$nextTick(() => {
  204. window.scrollTo({
  205. top: 0,
  206. left: 0,
  207. behavior: 'smooth'
  208. });
  209. this.$emit('refreshed');
  210. });
  211. }
  212. })
  213. },
  214. enterIntersect() {
  215. if(this.isFetchingMore) {
  216. return;
  217. }
  218. this.isFetchingMore = true;
  219. let url = `/api/pixelfed/v1/timelines/${this.getScope()}`;
  220. axios.get(url, {
  221. params: {
  222. max_id: this.max_id,
  223. limit: 6
  224. }
  225. }).then(res => {
  226. if(!res.data.length) {
  227. this.endFeedReached = true;
  228. this.canLoadMore = false;
  229. this.isFetchingMore = false;
  230. }
  231. setTimeout(() => {
  232. res.data.forEach(p => {
  233. if(this.ids.indexOf(p.id) == -1) {
  234. if(this.max_id > p.id) {
  235. this.max_id = p.id;
  236. }
  237. this.ids.push(p.id);
  238. this.feed.push(p);
  239. if(p && p.hasOwnProperty('relationship')) {
  240. this.$store.commit('updateRelationship', [p.relationship]);
  241. }
  242. }
  243. });
  244. this.isFetchingMore = false;
  245. }, 100);
  246. });
  247. },
  248. tryToLoadMore() {
  249. this.loadMoreAttempts++;
  250. if(this.loadMoreAttempts >= 3) {
  251. this.showLoadMore = false;
  252. }
  253. this.showLoadMore = false;
  254. this.canLoadMore = true;
  255. this.loadMoreTimeout = setTimeout(() => {
  256. this.canLoadMore = false;
  257. this.showLoadMore = true;
  258. }, 5000);
  259. },
  260. likeStatus(index) {
  261. let status = this.feed[index];
  262. let state = status.favourited;
  263. let count = status.favourites_count;
  264. this.feed[index].favourites_count = count + 1;
  265. this.feed[index].favourited = !status.favourited;
  266. axios.post('/api/v1/statuses/' + status.id + '/favourite')
  267. .then(res => {
  268. //
  269. }).catch(err => {
  270. this.feed[index].favourites_count = count;
  271. this.feed[index].favourited = false;
  272. let el = document.createElement('p');
  273. el.classList.add('text-left');
  274. el.classList.add('mb-0');
  275. el.innerHTML = '<span class="lead">We limit certain interactions to keep our community healthy and it appears that you have reached that limit. <span class="font-weight-bold">Please try again later.</span></span>';
  276. let wrapper = document.createElement('div');
  277. wrapper.appendChild(el);
  278. if(err.response.status === 429) {
  279. swal({
  280. title: 'Too many requests',
  281. content: wrapper,
  282. icon: 'warning',
  283. buttons: {
  284. // moreInfo: {
  285. // text: "Contact a human",
  286. // visible: true,
  287. // value: "more",
  288. // className: "text-lighter bg-transparent border"
  289. // },
  290. confirm: {
  291. text: "OK",
  292. value: false,
  293. visible: true,
  294. className: "bg-transparent primary",
  295. closeModal: true
  296. }
  297. }
  298. })
  299. .then((val) => {
  300. if(val == 'more') {
  301. location.href = '/site/contact'
  302. }
  303. return;
  304. });
  305. }
  306. })
  307. },
  308. unlikeStatus(index) {
  309. let status = this.feed[index];
  310. let state = status.favourited;
  311. let count = status.favourites_count;
  312. this.feed[index].favourites_count = count - 1;
  313. this.feed[index].favourited = !status.favourited;
  314. axios.post('/api/v1/statuses/' + status.id + '/unfavourite')
  315. .then(res => {
  316. //
  317. }).catch(err => {
  318. this.feed[index].favourites_count = count;
  319. this.feed[index].favourited = false;
  320. })
  321. },
  322. openContextMenu(idx) {
  323. this.postIndex = idx;
  324. this.showMenu = true;
  325. this.$nextTick(() => {
  326. this.$refs.contextMenu.open();
  327. });
  328. },
  329. handleModTools(idx) {
  330. this.postIndex = idx;
  331. this.showMenu = true;
  332. this.$nextTick(() => {
  333. this.$refs.contextMenu.openModMenu();
  334. });
  335. },
  336. openLikesModal(idx) {
  337. this.postIndex = idx;
  338. this.likesModalPost = this.feed[this.postIndex];
  339. this.showLikesModal = true;
  340. this.$nextTick(() => {
  341. this.$refs.likesModal.open();
  342. });
  343. },
  344. openSharesModal(idx) {
  345. this.postIndex = idx;
  346. this.sharesModalPost = this.feed[this.postIndex];
  347. this.showSharesModal = true;
  348. this.$nextTick(() => {
  349. this.$refs.sharesModal.open();
  350. });
  351. },
  352. commitModeration(type) {
  353. let idx = this.postIndex;
  354. switch(type) {
  355. case 'addcw':
  356. this.feed[idx].sensitive = true;
  357. break;
  358. case 'remcw':
  359. this.feed[idx].sensitive = false;
  360. break;
  361. case 'unlist':
  362. this.feed.splice(idx, 1);
  363. break;
  364. case 'spammer':
  365. let id = this.feed[idx].account.id;
  366. this.feed = this.feed.filter(post => {
  367. return post.account.id != id;
  368. });
  369. break;
  370. }
  371. },
  372. deletePost() {
  373. this.feed.splice(this.postIndex, 1);
  374. },
  375. counterChange(index, type) {
  376. switch(type) {
  377. case 'comment-increment':
  378. this.feed[index].reply_count = this.feed[index].reply_count + 1;
  379. break;
  380. case 'comment-decrement':
  381. this.feed[index].reply_count = this.feed[index].reply_count - 1;
  382. break;
  383. }
  384. },
  385. openCommentLikesModal(post) {
  386. this.likesModalPost = post;
  387. this.showLikesModal = true;
  388. this.$nextTick(() => {
  389. this.$refs.likesModal.open();
  390. });
  391. },
  392. shareStatus(index) {
  393. let status = this.feed[index];
  394. let state = status.reblogged;
  395. let count = status.reblogs_count;
  396. this.feed[index].reblogs_count = count + 1;
  397. this.feed[index].reblogged = !status.reblogged;
  398. axios.post('/api/v1/statuses/' + status.id + '/reblog')
  399. .then(res => {
  400. //
  401. }).catch(err => {
  402. this.feed[index].reblogs_count = count;
  403. this.feed[index].reblogged = false;
  404. })
  405. },
  406. unshareStatus(index) {
  407. let status = this.feed[index];
  408. let state = status.reblogged;
  409. let count = status.reblogs_count;
  410. this.feed[index].reblogs_count = count - 1;
  411. this.feed[index].reblogged = !status.reblogged;
  412. axios.post('/api/v1/statuses/' + status.id + '/unreblog')
  413. .then(res => {
  414. //
  415. }).catch(err => {
  416. this.feed[index].reblogs_count = count;
  417. this.feed[index].reblogged = false;
  418. })
  419. },
  420. handleReport(post) {
  421. this.reportedStatusId = post.id;
  422. this.$nextTick(() => {
  423. this.reportedStatus = post;
  424. this.$refs.reportModal.open();
  425. });
  426. },
  427. handleBookmark(index) {
  428. let p = this.feed[index];
  429. axios.post('/i/bookmark', {
  430. item: p.id
  431. })
  432. .then(res => {
  433. this.feed[index].bookmarked = !p.bookmarked;
  434. })
  435. .catch(err => {
  436. // this.feed[index].bookmarked = false;
  437. this.$bvToast.toast('Cannot bookmark post at this time.', {
  438. title: 'Bookmark Error',
  439. variant: 'danger',
  440. autoHideDelay: 5000
  441. });
  442. });
  443. },
  444. follow(index) {
  445. // this.feed[index].relationship.following = true;
  446. axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/follow')
  447. .then(res => {
  448. this.$store.commit('updateRelationship', [res.data]);
  449. this.updateProfile({ following_count: this.profile.following_count + 1 });
  450. this.feed[index].account.followers_count = this.feed[index].account.followers_count + 1;
  451. }).catch(err => {
  452. swal('Oops!', 'An error occured when attempting to follow this account.', 'error');
  453. this.feed[index].relationship.following = false;
  454. });
  455. },
  456. unfollow(index) {
  457. // this.feed[index].relationship.following = false;
  458. axios.post('/api/v1/accounts/' + this.feed[index].account.id + '/unfollow')
  459. .then(res => {
  460. this.$store.commit('updateRelationship', [res.data]);
  461. this.updateProfile({ following_count: this.profile.following_count - 1 });
  462. this.feed[index].account.followers_count = this.feed[index].account.followers_count - 1;
  463. }).catch(err => {
  464. swal('Oops!', 'An error occured when attempting to unfollow this account.', 'error');
  465. this.feed[index].relationship.following = true;
  466. });
  467. },
  468. updateProfile(delta) {
  469. this.$emit('update-profile', delta);
  470. },
  471. handleRefresh() {
  472. this.isLoaded = false;
  473. this.feed = [];
  474. this.ids = [];
  475. this.max_id = 0;
  476. this.canLoadMore = true;
  477. this.showLoadMore = false;
  478. this.loadMoreTimeout = undefined;
  479. this.loadMoreAttempts = 0;
  480. this.isFetchingMore = false;
  481. this.endFeedReached = false;
  482. this.postIndex = 0;
  483. this.showMenu = false;
  484. this.showLikesModal = false;
  485. this.likesModalPost = {};
  486. this.showReportModal = false;
  487. this.reportedStatus = {};
  488. this.reportedStatusId = 0;
  489. this.showSharesModal = false;
  490. this.sharesModalPost = {};
  491. this.$nextTick(() => {
  492. this.fetchTimeline(true);
  493. });
  494. },
  495. handleEdit(status) {
  496. this.$refs.editModal.show(status);
  497. },
  498. mergeUpdatedPost(post) {
  499. this.feed = this.feed.map(p => {
  500. if(p.id == post.id) {
  501. p = post;
  502. }
  503. return p;
  504. });
  505. this.$nextTick(() => {
  506. this.forceUpdateIdx++;
  507. });
  508. }
  509. },
  510. watch: {
  511. 'refresh': 'handleRefresh'
  512. },
  513. beforeDestroy() {
  514. clearTimeout(this.loadMoreTimeout);
  515. }
  516. }
  517. </script>