|
@@ -0,0 +1,415 @@
|
|
|
+<template>
|
|
|
+ <div class="notifications-component">
|
|
|
+ <div class="card shadow-sm mb-3" style="overflow: hidden;border-radius: 15px !important;">
|
|
|
+ <div class="card-body pb-0">
|
|
|
+ <div class="d-flex justify-content-between align-items-center mb-3">
|
|
|
+ <span class="text-muted font-weight-bold">Notifications</span>
|
|
|
+ <div v-if="feed && feed.length">
|
|
|
+ <router-link to="/i/web/notifications" class="btn btn-outline-light btn-sm mr-2" style="color: #B8C2CC !important">
|
|
|
+ <i class="far fa-filter"></i>
|
|
|
+ </router-link>
|
|
|
+ <button
|
|
|
+ v-if="hasLoaded && feed.length"
|
|
|
+ class="btn btn-light btn-sm"
|
|
|
+ :class="{ 'text-lighter': isRefreshing }"
|
|
|
+ :disabled="isRefreshing"
|
|
|
+ @click="refreshNotifications">
|
|
|
+ <i class="fal fa-redo"></i>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="!hasLoaded" class="notifications-component-feed">
|
|
|
+ <div class="d-flex align-items-center justify-content-center flex-column bg-light rounded-lg p-3 mb-3" style="min-height: 100px;">
|
|
|
+ <b-spinner variant="grow" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="notifications-component-feed">
|
|
|
+ <template v-if="isEmpty">
|
|
|
+ <div class="d-flex align-items-center justify-content-center flex-column bg-light rounded-lg p-3 mb-3" style="min-height: 100px;">
|
|
|
+ <i class="fal fa-bell fa-2x text-lighter"></i>
|
|
|
+ <p class="mt-2 small font-weight-bold text-center mb-0">{{ $t('notifications.noneFound') }}</p>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div v-for="(n, index) in feed" class="mb-2">
|
|
|
+ <div class="media align-items-center">
|
|
|
+ <img
|
|
|
+ class="mr-2 rounded-circle shadow-sm"
|
|
|
+ :src="n.account.avatar"
|
|
|
+ width="32"
|
|
|
+ height="32"
|
|
|
+ onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
|
|
|
+
|
|
|
+ <div class="media-body font-weight-light small">
|
|
|
+ <div v-if="n.type == 'favourite'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> liked your
|
|
|
+ <span v-if="n.status && n.status.hasOwnProperty('media_attachments')">
|
|
|
+ <a class="font-weight-bold" v-bind:href="getPostUrl(n.status)" :id="'fvn-' + n.id" @click.prevent="goToPost(n.status)">post</a>.
|
|
|
+ <b-popover :target="'fvn-' + n.id" title="" triggers="hover" placement="top" boundary="window">
|
|
|
+ <img :src="notificationPreview(n)" width="100px" height="100px" style="object-fit: cover;">
|
|
|
+ </b-popover>
|
|
|
+ </span>
|
|
|
+ <span v-else>
|
|
|
+ <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
|
|
|
+ </span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'comment'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'group:comment'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="n.group_post_url">group post</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'story:react'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> reacted to your <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'story:comment'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'mention'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)" @click.prevent="goToPost(n.status)">mentioned</a> you.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'follow'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> followed you.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'share'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'modlog'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{truncate(n.account.username)}}</a> updated a <a class="font-weight-bold" v-bind:href="n.modlog.url">modlog</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'tagged'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="n.type == 'direct'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> sent a <router-link class="font-weight-bold" :to="'/i/web/direct/thread/'+n.account.id">dm</router-link>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="n.type == 'group.join.approved'">
|
|
|
+ <p class="my-0">
|
|
|
+ Your application to join the <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> group was approved!
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="n.type == 'group.join.rejected'">
|
|
|
+ <p class="my-0">
|
|
|
+ Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was rejected.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="n.type == 'group:invite'">
|
|
|
+ <p class="my-0">
|
|
|
+ <a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> invited you to join <a :href="n.group.url + '/invite/claim'" class="font-weight-bold text-dark word-break" :title="n.group.name">{{n.group.name}}</a>.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else>
|
|
|
+ <p class="my-0">
|
|
|
+ We cannot display this notification at this time.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="small text-muted font-weight-bold" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="hasLoaded && feed.length == 0">
|
|
|
+ <p class="small font-weight-bold text-center mb-0">{{ $t('notifications.noneFound') }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else>
|
|
|
+ <intersect v-if="hasLoaded && canLoadMore" @enter="enterIntersect">
|
|
|
+ <placeholder small style="margin-top: -6px" />
|
|
|
+ <placeholder small/>
|
|
|
+ <placeholder small/>
|
|
|
+ <placeholder small/>
|
|
|
+ </intersect>
|
|
|
+
|
|
|
+ <div v-else class="d-block" style="height: 10px;">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script type="text/javascript">
|
|
|
+ import Placeholder from './../partials/placeholders/NotificationPlaceholder.vue';
|
|
|
+ import Intersect from 'vue-intersect';
|
|
|
+
|
|
|
+ export default {
|
|
|
+ props: {
|
|
|
+ profile: {
|
|
|
+ type: Object
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ components: {
|
|
|
+ "intersect": Intersect,
|
|
|
+ "placeholder": Placeholder
|
|
|
+ },
|
|
|
+
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ feed: {},
|
|
|
+ maxId: undefined,
|
|
|
+ isIntersecting: false,
|
|
|
+ canLoadMore: false,
|
|
|
+ isRefreshing: false,
|
|
|
+ hasLoaded: false,
|
|
|
+ isEmpty: false,
|
|
|
+ retryTimeout: undefined,
|
|
|
+ retryAttempts: 0
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ mounted() {
|
|
|
+ this.init();
|
|
|
+ },
|
|
|
+
|
|
|
+ destroyed() {
|
|
|
+ clearTimeout(this.retryTimeout);
|
|
|
+ },
|
|
|
+
|
|
|
+ methods: {
|
|
|
+ init() {
|
|
|
+ if(this.retryAttempts == 3) {
|
|
|
+ this.hasLoaded = true;
|
|
|
+ this.isEmpty = true;
|
|
|
+ clearTimeout(this.retryTimeout);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ axios.get('/api/pixelfed/v1/notifications', {
|
|
|
+ params: {
|
|
|
+ limit: 9,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .then(res => {
|
|
|
+ if(!res || !res.data || !res.data.length) {
|
|
|
+ this.retryAttempts = this.retryAttempts + 1;
|
|
|
+ this.retryTimeout = setTimeout(() => this.init(), this.retryAttempts * 1500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let data = res.data.filter(n => {
|
|
|
+ if(n.type == 'share' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'comment' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'mention' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'favourite' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'follow' && !n.account) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'modlog' && !n.modlog) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ if(!res.data.length) {
|
|
|
+ this.canLoadMore = false;
|
|
|
+ } else {
|
|
|
+ this.canLoadMore = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if(this.retryTimeout || this.retryAttempts) {
|
|
|
+ this.retryAttempts = 0;
|
|
|
+ clearTimeout(this.retryTimeout);
|
|
|
+ }
|
|
|
+ this.maxId = res.data[res.data.length - 1].id;
|
|
|
+ this.feed = data;
|
|
|
+
|
|
|
+ this.hasLoaded = true;
|
|
|
+ setTimeout(() => {
|
|
|
+ this.isRefreshing = false;
|
|
|
+ }, 15000);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ refreshNotifications() {
|
|
|
+ event.currentTarget.blur();
|
|
|
+ this.isRefreshing = true;
|
|
|
+ this.init();
|
|
|
+ },
|
|
|
+
|
|
|
+ enterIntersect() {
|
|
|
+ if(this.isIntersecting || !this.canLoadMore) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.isIntersecting = true;
|
|
|
+
|
|
|
+ axios.get('/api/pixelfed/v1/notifications', {
|
|
|
+ params: {
|
|
|
+ limit: 9,
|
|
|
+ max_id: this.maxId
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .then(res => {
|
|
|
+ if(!res.data || !res.data.length) {
|
|
|
+ this.canLoadMore = false;
|
|
|
+ this.isIntersecting = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let data = res.data.filter(n => {
|
|
|
+ if(n.type == 'share' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'comment' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'mention' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'favourite' && !n.status) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'follow' && !n.account) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if(n.type == 'modlog' && !n.modlog) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ if(!res.data.length) {
|
|
|
+ this.canLoadMore = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.maxId = res.data[res.data.length - 1].id;
|
|
|
+ this.feed.push(...data);
|
|
|
+
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.isIntersecting = false;
|
|
|
+ })
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ truncate(text) {
|
|
|
+ if(text.length <= 15) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+
|
|
|
+ return text.slice(0,15) + '...'
|
|
|
+ },
|
|
|
+
|
|
|
+ timeAgo(ts) {
|
|
|
+ return window.App.util.format.timeAgo(ts);
|
|
|
+ },
|
|
|
+
|
|
|
+ mentionUrl(status) {
|
|
|
+ let username = status.account.username;
|
|
|
+ let id = status.id;
|
|
|
+ return '/p/' + username + '/' + id;
|
|
|
+ },
|
|
|
+
|
|
|
+ redirect(url) {
|
|
|
+ window.location.href = url;
|
|
|
+ },
|
|
|
+
|
|
|
+ notificationPreview(n) {
|
|
|
+ if(!n.status || !n.status.hasOwnProperty('media_attachments') || !n.status.media_attachments.length) {
|
|
|
+ return '/storage/no-preview.png';
|
|
|
+ }
|
|
|
+ return n.status.media_attachments[0].preview_url;
|
|
|
+ },
|
|
|
+
|
|
|
+ getProfileUrl(account) {
|
|
|
+ return '/i/web/profile/' + account.id;
|
|
|
+ },
|
|
|
+
|
|
|
+ getPostUrl(status) {
|
|
|
+ if(!status) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ return '/i/web/post/' + status.id;
|
|
|
+ },
|
|
|
+
|
|
|
+ goToPost(status) {
|
|
|
+ this.$router.push({
|
|
|
+ name: 'post',
|
|
|
+ path: `/i/web/post/${status.id}`,
|
|
|
+ params: {
|
|
|
+ id: status.id,
|
|
|
+ cachedStatus: status,
|
|
|
+ cachedProfile: this.profile
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ goToProfile(account) {
|
|
|
+ this.$router.push({
|
|
|
+ name: 'profile',
|
|
|
+ path: `/i/web/profile/${account.id}`,
|
|
|
+ params: {
|
|
|
+ id: account.id,
|
|
|
+ cachedProfile: account,
|
|
|
+ cachedUser: this.profile
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+ .notifications-component {
|
|
|
+ &-feed {
|
|
|
+ min-height: 50px;
|
|
|
+ max-height: 300px;
|
|
|
+ overflow-y: auto;
|
|
|
+
|
|
|
+ -ms-overflow-style: none;
|
|
|
+ scrollbar-width: none;
|
|
|
+ overflow-y: scroll;
|
|
|
+
|
|
|
+ &::-webkit-scrollbar {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+ .card {
|
|
|
+ width: 100%;
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-body {
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|