瀏覽代碼

Add Notifications section component

Daniel Supernault 2 年之前
父節點
當前提交
937e6d070e
共有 1 個文件被更改,包括 415 次插入0 次删除
  1. 415 0
      resources/assets/components/sections/Notifications.vue

+ 415 - 0
resources/assets/components/sections/Notifications.vue

@@ -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>