Notifications.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. <template>
  2. <div class="web-wrapper notification-metro-component">
  3. <div v-if="isLoaded" class="container-fluid mt-3">
  4. <div class="row">
  5. <div class="col-md-3 d-md-block">
  6. <sidebar :user="profile" />
  7. </div>
  8. <div class="col-md-9 col-lg-9 col-xl-5 offset-xl-1">
  9. <template v-if="tabIndex === 0">
  10. <h1 class="font-weight-bold">
  11. Notifications
  12. </h1>
  13. <p class="small mt-n2">&nbsp;</p>
  14. </template>
  15. <template v-else-if="tabIndex === 10">
  16. <div class="d-flex align-items-center mb-3">
  17. <a class="text-muted" href="#" @click.prevent="tabIndex = 0" style="opacity:0.3">
  18. <i class="far fa-chevron-circle-left fa-2x mr-3" title="Go back to notifications"></i>
  19. </a>
  20. <h1 class="font-weight-bold">
  21. Follow Requests
  22. </h1>
  23. </div>
  24. </template>
  25. <template v-else>
  26. <h1 class="font-weight-bold">
  27. {{ tabs[tabIndex].name }}
  28. </h1>
  29. <p class="small text-lighter mt-n2">{{ tabs[tabIndex].description }}</p>
  30. </template>
  31. <div v-if="!notificationsLoaded">
  32. <placeholder />
  33. </div>
  34. <template v-else>
  35. <ul v-if="tabIndex != 10 && notificationsLoaded && notifications && notifications.length" class="notification-filters nav nav-tabs nav-fill mb-3">
  36. <li v-for="(item, idx) in tabs" class="nav-item">
  37. <a
  38. class="nav-link"
  39. :class="{ active: tabIndex === idx }"
  40. href="#"
  41. @click.prevent="toggleTab(idx)">
  42. <i
  43. class="mr-1 nav-link-icon"
  44. :class="[ item.icon ]"
  45. >
  46. </i>
  47. <span class="d-none d-xl-inline-block">
  48. {{ item.name }}
  49. </span>
  50. </a>
  51. </li>
  52. </ul>
  53. <div v-if="notificationsEmpty && followRequestsChecked && !followRequests.accounts.length && notificationRetries < 2">
  54. <div class="row justify-content-center">
  55. <div class="col-12 col-md-10 text-center">
  56. <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
  57. <p class="lead text-muted font-weight-bold">{{ $t('notifications.noneFound') }}</p>
  58. </div>
  59. </div>
  60. </div>
  61. <div v-else-if="!notificationsLoaded || tabSwitching || ((notificationsEmpty && notificationRetries < 2 ) || !notifications && !followRequests && !followRequests.accounts && !followRequests.accounts.length)">
  62. <placeholder />
  63. </div>
  64. <div v-else>
  65. <div v-if="tabIndex === 0">
  66. <div
  67. v-if="followRequests && followRequests.hasOwnProperty('accounts') && followRequests.accounts.length"
  68. class="card card-body shadow-none border border-warning rounded-pill mb-3 py-2">
  69. <div class="media align-items-center">
  70. <i class="far fa-exclamation-circle mr-3 text-warning"></i>
  71. <div class="media-body">
  72. <p class="mb-0">
  73. <strong>{{ followRequests.count }} follow {{ followRequests.count > 1 ? 'requests' : 'request' }}</strong>
  74. </p>
  75. </div>
  76. <a
  77. class="ml-2 small d-flex font-weight-bold primary text-uppercase mb-0"
  78. href="#"
  79. @click.prevent="showFollowRequests()">
  80. View<span class="d-none d-md-block">&nbsp;Follow Requests</span>
  81. </a>
  82. </div>
  83. </div>
  84. <div v-if="notificationsLoaded">
  85. <notification
  86. v-for="(n, index) in notifications"
  87. :key="`notification:${index}:${n.id}`"
  88. :n="n" />
  89. <div v-if="notifications && notificationsLoaded && !notifications.length && notificationRetries <= 2">
  90. <div class="row justify-content-center">
  91. <div class="col-12 col-md-10 text-center">
  92. <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
  93. <p class="lead text-muted font-weight-bold">{{ $t('notifications.noneFound') }}</p>
  94. </div>
  95. </div>
  96. </div>
  97. <div v-if="canLoadMore">
  98. <intersect @enter="enterIntersect">
  99. <placeholder />
  100. </intersect>
  101. </div>
  102. </div>
  103. </div>
  104. <div v-else-if="tabIndex === 10">
  105. <div v-if="followRequests && followRequests.accounts && followRequests.accounts.length" class="list-group">
  106. <div v-for="(acct, index) in followRequests.accounts" class="list-group-item">
  107. <div class="media align-items-center">
  108. <router-link :to="`/i/web/profile/${acct.account.id}`" class="primary">
  109. <img :src="acct.avatar" width="80" height="80" class="rounded-lg shadow mr-3" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
  110. </router-link>
  111. <div class="media-body mr-3">
  112. <p class="font-weight-bold mb-0 text-break" style="font-size:17px">
  113. <router-link :to="`/i/web/profile/${acct.account.id}`" class="primary">
  114. {{ acct.username }}
  115. </router-link>
  116. </p>
  117. <p class="mb-1 text-muted text-break" style="font-size:11px">{{ truncate(acct.account.note_text, 100) }}</p>
  118. <div class="d-flex text-lighter" style="font-size:11px">
  119. <span class="mr-3">
  120. <span class="font-weight-bold">{{ acct.account.statuses_count }}</span>
  121. <span>Posts</span>
  122. </span>
  123. <span>
  124. <span class="font-weight-bold">{{ acct.account.followers_count }}</span>
  125. <span>Followers</span>
  126. </span>
  127. </div>
  128. </div>
  129. <div class="d-flex flex-column d-md-block">
  130. <button
  131. class="btn btn-outline-success py-1 btn-sm font-weight-bold rounded-pill mr-2 mb-1"
  132. @click.prevent="handleFollowRequest('accept', index)"
  133. >
  134. Accept
  135. </button>
  136. <button class="btn btn-outline-lighter py-1 btn-sm font-weight-bold rounded-pill mb-1"
  137. @click.prevent="handleFollowRequest('reject', index)"
  138. >
  139. Reject
  140. </button>
  141. </div>
  142. </div>
  143. </div>
  144. </div>
  145. </div>
  146. <div v-else>
  147. <div v-if="filteredLoaded">
  148. <div class="card card-body bg-transparent shadow-none border p-2 mb-3 rounded-pill text-lighter">
  149. <div class="media align-items-center small">
  150. <i class="far fa-exclamation-triangle mx-2"></i>
  151. <div class="media-body">
  152. <p class="mb-0 font-weight-bold">Filtering results may not include older notifications</p>
  153. </div>
  154. </div>
  155. </div>
  156. <div v-if="filteredFeed.length">
  157. <notification
  158. v-for="(n, index) in filteredFeed"
  159. :key="`notification:filtered:${index}:${n.id}`"
  160. :n="n" />
  161. </div>
  162. <div v-else>
  163. <div v-if="filteredEmpty && notificationRetries <= 2">
  164. <div class="card card-body shadow-sm border-0 d-flex flex-row align-items-center" style="border-radius: 20px;gap:1rem;">
  165. <i class="far fa-inbox fa-2x text-muted"></i>
  166. <div class="font-weight-bold">No recent {{ tabs[tabIndex].name }}!</div>
  167. </div>
  168. </div>
  169. <placeholder v-else />
  170. </div>
  171. <div v-if="canLoadMoreFiltered">
  172. <intersect @enter="enterFilteredIntersect">
  173. <placeholder />
  174. </intersect>
  175. </div>
  176. </div>
  177. <div v-else>
  178. <placeholder />
  179. </div>
  180. </div>
  181. </div>
  182. </template>
  183. </div>
  184. </div>
  185. <drawer />
  186. </div>
  187. </div>
  188. </template>
  189. <script type="text/javascript">
  190. import Drawer from './partials/drawer.vue';
  191. import Sidebar from './partials/sidebar.vue';
  192. import Notification from './partials/timeline/Notification.vue';
  193. import Placeholder from './partials/placeholders/NotificationPlaceholder.vue';
  194. import Intersect from 'vue-intersect';
  195. export default {
  196. components: {
  197. "drawer": Drawer,
  198. "sidebar": Sidebar,
  199. "intersect": Intersect,
  200. "notification": Notification,
  201. "placeholder": Placeholder,
  202. },
  203. data() {
  204. return {
  205. isLoaded: false,
  206. profile: undefined,
  207. ids: [],
  208. notifications: undefined,
  209. notificationsLoaded: false,
  210. notificationRetries: 0,
  211. notificationsEmpty: true,
  212. notificationRetryTimeout: undefined,
  213. max_id: undefined,
  214. canLoadMore: false,
  215. isIntersecting: false,
  216. tabIndex: 0,
  217. tabs: [
  218. {
  219. id: 'all',
  220. name: 'All',
  221. icon: 'far fa-bell',
  222. types: []
  223. },
  224. {
  225. id: 'mentions',
  226. name: 'Mentions',
  227. description: 'Replies to your posts and posts you were mentioned in',
  228. icon: 'far fa-at',
  229. types: ['comment', 'mention']
  230. },
  231. {
  232. id: 'likes',
  233. name: 'Likes',
  234. description: 'Accounts that liked your posts',
  235. icon: 'far fa-heart',
  236. types: ['favourite']
  237. },
  238. {
  239. id: 'followers',
  240. name: 'Followers',
  241. description: 'Accounts that followed you',
  242. icon: 'far fa-user-plus',
  243. types: ['follow']
  244. },
  245. {
  246. id: 'reblogs',
  247. name: 'Reblogs',
  248. description: 'Accounts that shared or reblogged your posts',
  249. icon: 'far fa-retweet',
  250. types: ['share']
  251. },
  252. {
  253. id: 'direct',
  254. name: 'DMs',
  255. description: 'Direct messages you have with other accounts',
  256. icon: 'far fa-envelope',
  257. types: ['direct']
  258. },
  259. ],
  260. tabSwitching: false,
  261. filteredFeed: [],
  262. filteredLoaded: false,
  263. filteredIsIntersecting: false,
  264. filteredMaxId: undefined,
  265. canLoadMoreFiltered: true,
  266. filterPaginationTimeout: undefined,
  267. filteredIterations: 0,
  268. filteredEmpty: false,
  269. followRequests: [],
  270. followRequestsChecked: false,
  271. followRequestsPage: 1
  272. }
  273. },
  274. updated() {
  275. },
  276. mounted() {
  277. this.profile = window._sharedData.user;
  278. this.isLoaded = true;
  279. if(this.profile.locked) {
  280. this.fetchFollowRequests();
  281. }
  282. this.fetchNotifications();
  283. },
  284. beforeDestroy() {
  285. clearTimeout(this.notificationRetryTimeout);
  286. },
  287. methods: {
  288. fetchNotifications() {
  289. this.notificationRetries++;
  290. axios.get('/api/pixelfed/v1/notifications?pg=true')
  291. .then(res => {
  292. if(!res || !res.data || !res.data.length) {
  293. if(this.notificationRetries == 2) {
  294. clearTimeout(this.notificationRetryTimeout);
  295. this.canLoadMore = false;
  296. this.notificationsLoaded = true;
  297. this.notificationsEmpty = true;
  298. return;
  299. }
  300. this.notificationRetryTimeout = setTimeout(() => {
  301. this.fetchNotifications();
  302. }, 1000);
  303. return;
  304. }
  305. let data = res.data.filter(n => {
  306. if(n.type == 'share' && !n.status) {
  307. return false;
  308. }
  309. if(n.type == 'comment' && !n.status) {
  310. return false;
  311. }
  312. if(n.type == 'mention' && !n.status) {
  313. return false;
  314. }
  315. if(n.type == 'favourite' && !n.status) {
  316. return false;
  317. }
  318. if(n.type == 'follow' && !n.account) {
  319. return false;
  320. }
  321. return true;
  322. });
  323. let ids = res.data.map(n => n.id);
  324. this.max_id = Math.min(...ids);
  325. this.ids.push(...ids);
  326. this.notifications = data;
  327. this.notificationsLoaded = true;
  328. this.notificationsEmpty = false;
  329. this.canLoadMore = true;
  330. });
  331. },
  332. enterIntersect() {
  333. if(this.isIntersecting) {
  334. return;
  335. }
  336. if(!isFinite(this.max_id)) {
  337. return;
  338. }
  339. this.isIntersecting = true;
  340. axios.get('/api/pixelfed/v1/notifications', {
  341. params: {
  342. max_id: this.max_id
  343. }
  344. }).then(res => {
  345. if(!res.data.length) {
  346. this.canLoadMore = false;
  347. }
  348. let ids = res.data.map(n => n.id);
  349. this.max_id = Math.min(...ids);
  350. this.notifications.push(...res.data);
  351. this.isIntersecting = false;
  352. })
  353. },
  354. toggleTab(idx) {
  355. this.tabSwitching = true;
  356. this.canLoadMoreFiltered = true;
  357. this.filteredEmpty = false;
  358. this.filteredIterations = 0;
  359. this.filterFeed(this.tabs[idx].id);
  360. },
  361. filterFeed(type) {
  362. switch(type) {
  363. case 'all':
  364. this.tabIndex = 0;
  365. this.filteredFeed = [];
  366. this.filteredLoaded = false;
  367. this.filteredIsIntersecting = false;
  368. this.filteredMaxId = undefined;
  369. this.canLoadMoreFiltered = false;
  370. this.tabSwitching = false;
  371. break;
  372. case 'mentions':
  373. this.tabIndex = 1;
  374. this.filteredMaxId = this.max_id;
  375. this.filteredFeed = this.notifications.filter(n => this.tabs[this.tabIndex].types.includes(n.type));
  376. this.filteredIsIntersecting = false;
  377. this.tabSwitching = false;
  378. this.filteredLoaded = true;
  379. break;
  380. case 'likes':
  381. this.tabIndex = 2;
  382. this.filteredMaxId = this.max_id;
  383. this.filteredFeed = this.notifications.filter(n => n.type === 'favourite');
  384. this.filteredIsIntersecting = false;
  385. this.tabSwitching = false;
  386. this.filteredLoaded = true;
  387. break;
  388. case 'followers':
  389. this.tabIndex = 3;
  390. this.filteredMaxId = this.max_id;
  391. this.filteredFeed = this.notifications.filter(n => n.type === 'follow');
  392. this.filteredIsIntersecting = false;
  393. this.tabSwitching = false;
  394. this.filteredLoaded = true;
  395. break;
  396. case 'reblogs':
  397. this.tabIndex = 4;
  398. this.filteredMaxId = this.max_id;
  399. this.filteredFeed = this.notifications.filter(n => n.type === 'share');
  400. this.filteredIsIntersecting = false;
  401. this.tabSwitching = false;
  402. this.filteredLoaded = true;
  403. break;
  404. case 'direct':
  405. this.tabIndex = 5;
  406. this.filteredMaxId = this.max_id;
  407. this.filteredFeed = this.notifications.filter(n => n.type === 'direct');
  408. this.filteredIsIntersecting = false;
  409. this.tabSwitching = false;
  410. this.filteredLoaded = true;
  411. break;
  412. }
  413. },
  414. enterFilteredIntersect() {
  415. if( !this.canLoadMoreFiltered ||
  416. this.filteredIsIntersecting ||
  417. this.filteredIterations > 10
  418. ) {
  419. if(this.filteredFeed.length == 0) {
  420. this.filteredEmpty = true;
  421. this.canLoadMoreFiltered = false;
  422. }
  423. return;
  424. }
  425. if(!isFinite(this.max_id) || !isFinite(this.filteredMaxId)) {
  426. this.canLoadMoreFiltered = false;
  427. return;
  428. }
  429. this.filteredIsIntersecting = true;
  430. axios.get('/api/pixelfed/v1/notifications', {
  431. params: {
  432. max_id: this.filteredMaxId,
  433. limit: 40
  434. }
  435. })
  436. .then(res => {
  437. let mids = res.data.map(n => n.id);
  438. let max_id = Math.min(...mids);
  439. if(max_id < this.max_id) {
  440. this.max_id = max_id;
  441. res.data.forEach(n => {
  442. if(this.ids.indexOf(n.id) == -1) {
  443. this.ids.push(n.id);
  444. this.notifications.push(n);
  445. } else {
  446. }
  447. });
  448. }
  449. this.filteredIterations++;
  450. if(this.filterPaginationTimeout && this.filterPaginationTimeout < 500) {
  451. clearTimeout(this.filterPaginationTimeout);
  452. }
  453. if(!res.data || !res.data.length) {
  454. this.canLoadMoreFiltered = false;
  455. }
  456. if(!res.data.length) {
  457. this.canLoadMoreFiltered = false;
  458. }
  459. let ids = res.data.map(n => n.id);
  460. this.filteredMaxId = Math.min(...ids);
  461. let types = this.tabs[this.tabIndex].types;
  462. let data = res.data.filter(n => types.includes(n.type));
  463. this.filteredFeed.push(...data);
  464. this.filteredIsIntersecting = false;
  465. if(this.filteredFeed.length < 10) {
  466. setTimeout(() => this.enterFilteredIntersect(), 500);
  467. }
  468. this.filterPaginationTimeout = setTimeout(() => {
  469. this.canLoadMoreFiltered = false;
  470. }, 2000);
  471. })
  472. .catch(err => {
  473. this.canLoadMoreFiltered = false;
  474. })
  475. },
  476. fetchFollowRequests() {
  477. axios.get('/account/follow-requests.json')
  478. .then(res => {
  479. if(this.followRequestsPage == 1) {
  480. this.followRequests = res.data;
  481. this.followRequestsChecked = true;
  482. } else {
  483. this.followRequests.accounts.push(...res.data.accounts);
  484. }
  485. this.followRequestsPage++;
  486. });
  487. },
  488. showFollowRequests() {
  489. this.tabSwitching = false;
  490. this.filteredEmpty = false;
  491. this.filteredIterations = 0;
  492. this.tabIndex = 10;
  493. },
  494. handleFollowRequest(action, index) {
  495. if(!window.confirm('Are you sure you want to ' + action + ' this follow request?')) {
  496. return;
  497. }
  498. axios.post('/account/follow-requests', {
  499. action: action,
  500. id: this.followRequests.accounts[index].rid
  501. })
  502. .then(res => {
  503. this.followRequests.count--;
  504. this.followRequests.accounts.splice(index, 1);
  505. this.toggleTab(0);
  506. })
  507. },
  508. truncate(str, len = 40) {
  509. return _.truncate(str, { length: len });
  510. }
  511. }
  512. }
  513. </script>
  514. <style lang="scss" scoped>
  515. .notification-metro-component {
  516. .notification-filters {
  517. .nav-link {
  518. font-size: 12px;
  519. &.active {
  520. font-weight: bold;
  521. }
  522. &-icon:not(.active) {
  523. opacity: 0.5;
  524. }
  525. &:not(.active) {
  526. color: #9ca3af;
  527. }
  528. }
  529. }
  530. }
  531. </style>