Преглед на файлове

Update Notification components, add autospam notification support

Daniel Supernault преди 2 години
родител
ревизия
0d3b4bc225
променени са 2 файла, в които са добавени 616 реда и са изтрити 0 реда
  1. 585 0
      resources/assets/components/Notifications.vue
  2. 31 0
      resources/assets/components/sections/Notifications.vue

+ 585 - 0
resources/assets/components/Notifications.vue

@@ -0,0 +1,585 @@
+<template>
+	<div class="web-wrapper notification-metro-component">
+		<div v-if="isLoaded" class="container-fluid mt-3">
+			<div class="row">
+				<div class="col-md-3 d-md-block">
+					<sidebar :user="profile" />
+				</div>
+
+				<div class="col-md-9 col-lg-9 col-xl-5 offset-xl-1">
+					<template v-if="tabIndex === 0">
+						<h1 class="font-weight-bold">
+							Notifications
+						</h1>
+						<p class="small mt-n2">&nbsp;</p>
+					</template>
+					<template v-else-if="tabIndex === 10">
+						<div class="d-flex align-items-center mb-3">
+							<a class="text-muted" href="#" @click.prevent="tabIndex = 0" style="opacity:0.3">
+								<i class="far fa-chevron-circle-left fa-2x mr-3" title="Go back to notifications"></i>
+							</a>
+							<h1 class="font-weight-bold">
+								Follow Requests
+							</h1>
+						</div>
+					</template>
+					<template v-else>
+						<h1 class="font-weight-bold">
+							{{ tabs[tabIndex].name }}
+						</h1>
+						<p class="small text-lighter mt-n2">{{ tabs[tabIndex].description }}</p>
+					</template>
+
+					<div v-if="!notificationsLoaded">
+						<placeholder />
+					</div>
+
+					<template v-else>
+						<ul v-if="tabIndex != 10 && notificationsLoaded && notifications && notifications.length" class="notification-filters nav nav-tabs nav-fill mb-3">
+							<li v-for="(item, idx) in tabs" class="nav-item">
+								<a
+									class="nav-link"
+									:class="{ active: tabIndex === idx }"
+									href="#"
+									@click.prevent="toggleTab(idx)">
+									<i
+										class="mr-1 nav-link-icon"
+										:class="[ item.icon ]"
+										>
+									</i>
+									<span class="d-none d-xl-inline-block">
+										{{ item.name }}
+									</span>
+								</a>
+							</li>
+						</ul>
+
+						<div v-if="notificationsEmpty && followRequestsChecked && !followRequests.accounts.length && notificationRetries <= 2">
+							<div class="row justify-content-center">
+								<div class="col-12 col-md-10 text-center">
+									<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+									<p class="lead text-muted font-weight-bold">{{ $t('notifications.noneFound') }}</p>
+								</div>
+							</div>
+						</div>
+
+						<div v-else-if="!notificationsLoaded || tabSwitching || notificationRetries != 2 || (!notifications && !followRequests && !followRequests.accounts && !followRequests.accounts.length)">
+							<placeholder />
+						</div>
+
+						<div v-else>
+							<div v-if="tabIndex === 0">
+								<div
+									v-if="followRequests && followRequests.hasOwnProperty('accounts') && followRequests.accounts.length"
+									class="card card-body shadow-none border border-warning rounded-pill mb-3 py-2">
+									<div class="media align-items-center">
+										<i class="far fa-exclamation-circle mr-3 text-warning"></i>
+										<div class="media-body">
+											<p class="mb-0">
+												<strong>{{ followRequests.count }} follow {{ followRequests.count > 1 ? 'requests' : 'request' }}</strong>
+											</p>
+										</div>
+										<a
+											class="ml-2 small d-flex font-weight-bold primary text-uppercase mb-0"
+											href="#"
+											@click.prevent="showFollowRequests()">
+											View<span class="d-none d-md-block">&nbsp;Follow Requests</span>
+										</a>
+									</div>
+								</div>
+
+								<div v-if="notificationsLoaded">
+									<notification
+										v-for="(n, index) in notifications"
+										:key="`notification:${index}:${n.id}`"
+										:n="n" />
+
+									<div v-if="notifications && notificationsLoaded && !notifications.length && notificationRetries <= 2">
+										<div class="row justify-content-center">
+											<div class="col-12 col-md-10 text-center">
+												<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+												<p class="lead text-muted font-weight-bold">{{ $t('notifications.noneFound') }}</p>
+											</div>
+										</div>
+									</div>
+
+									<div v-if="canLoadMore">
+										<intersect @enter="enterIntersect">
+											<placeholder />
+										</intersect>
+									</div>
+								</div>
+							</div>
+
+							<div v-else-if="tabIndex === 10">
+								<div v-if="followRequests && followRequests.accounts && followRequests.accounts.length" class="list-group">
+									<div v-for="(acct, index) in followRequests.accounts" class="list-group-item">
+										<div class="media align-items-center">
+											<router-link :to="`/i/web/profile/${acct.account.id}`" class="primary">
+												<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';">
+											</router-link>
+											<div class="media-body mr-3">
+												<p class="font-weight-bold mb-0 text-break" style="font-size:17px">
+													<router-link :to="`/i/web/profile/${acct.account.id}`" class="primary">
+														{{ acct.username }}
+													</router-link>
+												</p>
+												<p class="mb-1 text-muted text-break" style="font-size:11px">{{ truncate(acct.account.note_text, 100) }}</p>
+												<div class="d-flex text-lighter" style="font-size:11px">
+													<span class="mr-3">
+														<span class="font-weight-bold">{{ acct.account.statuses_count }}</span>
+														<span>Posts</span>
+													</span>
+													<span>
+														<span class="font-weight-bold">{{ acct.account.followers_count }}</span>
+														<span>Followers</span>
+													</span>
+												</div>
+											</div>
+											<div class="d-flex flex-column d-md-block">
+												<button
+													class="btn btn-outline-success py-1 btn-sm font-weight-bold rounded-pill mr-2 mb-1"
+													@click.prevent="handleFollowRequest('accept', index)"
+													>
+													Accept
+												</button>
+
+												<button class="btn btn-outline-lighter py-1 btn-sm font-weight-bold rounded-pill mb-1"
+													@click.prevent="handleFollowRequest('reject', index)"
+													>
+													Reject
+												</button>
+											</div>
+										</div>
+									</div>
+								</div>
+							</div>
+
+							<div v-else>
+								<div v-if="filteredLoaded">
+									<div class="card card-body bg-transparent shadow-none border p-2 mb-3 rounded-pill text-lighter">
+										<div class="media align-items-center small">
+											<i class="far fa-exclamation-triangle mx-2"></i>
+											<div class="media-body">
+												<p class="mb-0 font-weight-bold">Filtering results may not include older notifications</p>
+											</div>
+										</div>
+									</div>
+
+									<div v-if="filteredFeed.length">
+										<notification
+											v-for="(n, index) in filteredFeed"
+											:key="`notification:filtered:${index}:${n.id}`"
+											:n="n" />
+									</div>
+
+									<div v-else>
+										<div v-if="filteredEmpty && notificationRetries <= 2">
+											<div class="card card-body shadow-sm border-0 d-flex flex-row align-items-center" style="border-radius: 20px;gap:1rem;">
+												<i class="far fa-inbox fa-2x text-muted"></i>
+												<div class="font-weight-bold">No recent {{ tabs[tabIndex].name }}!</div>
+											</div>
+										</div>
+
+										<placeholder v-else />
+									</div>
+
+									<div v-if="canLoadMoreFiltered">
+										<intersect @enter="enterFilteredIntersect">
+											<placeholder />
+										</intersect>
+									</div>
+								</div>
+
+								<div v-else>
+									<placeholder />
+								</div>
+							</div>
+						</div>
+					</template>
+				</div>
+			</div>
+			<drawer />
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Drawer from './partials/drawer.vue';
+	import Sidebar from './partials/sidebar.vue';
+	import Notification from './partials/timeline/Notification.vue';
+	import Placeholder from './partials/placeholders/NotificationPlaceholder.vue';
+	import Intersect from 'vue-intersect';
+
+	export default {
+		 components: {
+		 	"drawer": Drawer,
+            "sidebar": Sidebar,
+            "intersect": Intersect,
+            "notification": Notification,
+            "placeholder": Placeholder,
+        },
+
+        data() {
+        	return {
+        		isLoaded: false,
+        		profile: undefined,
+        		ids: [],
+        		notifications: undefined,
+        		notificationsLoaded: false,
+        		notificationRetries: 0,
+        		notificationsEmpty: true,
+        		notificationRetryTimeout: undefined,
+        		max_id: undefined,
+        		canLoadMore: false,
+        		isIntersecting: false,
+        		tabIndex: 0,
+        		tabs: [
+        			{
+        				id: 'all',
+        				name: 'All',
+        				icon: 'far fa-bell',
+        				types: []
+        			},
+
+        			{
+        				id: 'mentions',
+        				name: 'Mentions',
+        				description: 'Replies to your posts and posts you were mentioned in',
+        				icon: 'far fa-at',
+        				types: ['comment', 'mention']
+        			},
+
+        			{
+        				id: 'likes',
+        				name: 'Likes',
+        				description: 'Accounts that liked your posts',
+        				icon: 'far fa-heart',
+        				types: ['favourite']
+        			},
+
+        			{
+        				id: 'followers',
+        				name: 'Followers',
+        				description: 'Accounts that followed you',
+        				icon: 'far fa-user-plus',
+        				types: ['follow']
+        			},
+
+        			{
+        				id: 'reblogs',
+        				name: 'Reblogs',
+        				description: 'Accounts that shared or reblogged your posts',
+        				icon: 'far fa-retweet',
+        				types: ['share']
+        			},
+
+        			{
+        				id: 'direct',
+        				name: 'DMs',
+        				description: 'Direct messages you have with other accounts',
+        				icon: 'far fa-envelope',
+        				types: ['direct']
+        			},
+        		],
+        		tabSwitching: false,
+        		filteredFeed: [],
+        		filteredLoaded: false,
+        		filteredIsIntersecting: false,
+        		filteredMaxId: undefined,
+        		canLoadMoreFiltered: true,
+        		filterPaginationTimeout: undefined,
+        		filteredIterations: 0,
+        		filteredEmpty: false,
+        		followRequests: [],
+        		followRequestsChecked: false,
+        		followRequestsPage: 1
+        	}
+        },
+
+        updated() {
+        },
+
+        mounted() {
+			this.profile = window._sharedData.user;
+			this.isLoaded = true;
+			if(this.profile.locked) {
+				this.fetchFollowRequests();
+			}
+			this.fetchNotifications();
+        },
+
+        beforeDestroy() {
+        	clearTimeout(this.notificationRetryTimeout);
+        },
+
+        methods: {
+        	fetchNotifications() {
+				axios.get('/api/pixelfed/v1/notifications?pg=true')
+				.then(res => {
+					this.notificationRetries++;
+					if(!res || !res.data || !res.data.length) {
+						if(this.notificationRetries == 2) {
+							clearTimeout(this.notificationRetryTimeout);
+							this.canLoadMore = false;
+							this.notificationsLoaded = true;
+							this.notificationsEmpty = true;
+							return;
+						}
+ 						this.notificationRetryTimeout = setTimeout(() => {
+							this.fetchNotifications();
+						}, 1000);
+						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;
+						}
+						return true;
+					});
+					let ids = res.data.map(n => n.id);
+					this.max_id = Math.min(...ids);
+					this.ids.push(...ids);
+					this.notifications = data;
+					this.notificationsLoaded = true;
+					this.notificationsEmpty = false;
+					this.canLoadMore = true;
+				});
+			},
+
+			enterIntersect() {
+				if(this.isIntersecting) {
+					return;
+				}
+
+				if(!isFinite(this.max_id)) {
+					return;
+				}
+
+				this.isIntersecting = true;
+
+				axios.get('/api/pixelfed/v1/notifications', {
+					params: {
+						max_id: this.max_id
+					}
+				}).then(res => {
+					if(!res.data.length) {
+						this.canLoadMore = false;
+					}
+					let ids = res.data.map(n => n.id);
+					this.max_id = Math.min(...ids);
+					this.notifications.push(...res.data);
+					this.isIntersecting = false;
+				})
+			},
+
+			toggleTab(idx) {
+				this.tabSwitching = true;
+				this.canLoadMoreFiltered = true;
+				this.filteredEmpty = false;
+				this.filteredIterations = 0;
+				this.filterFeed(this.tabs[idx].id);
+			},
+
+			filterFeed(type) {
+				switch(type) {
+					case 'all':
+						this.tabIndex = 0;
+						this.filteredFeed = [];
+						this.filteredLoaded = false;
+						this.filteredIsIntersecting = false;
+						this.filteredMaxId = undefined;
+						this.canLoadMoreFiltered = false;
+						this.tabSwitching = false;
+					break;
+
+					case 'mentions':
+						this.tabIndex = 1;
+						this.filteredMaxId = this.max_id;
+						this.filteredFeed = this.notifications.filter(n => this.tabs[this.tabIndex].types.includes(n.type));
+						this.filteredIsIntersecting = false;
+						this.tabSwitching = false;
+						this.filteredLoaded = true;
+					break;
+
+					case 'likes':
+						this.tabIndex = 2;
+						this.filteredMaxId = this.max_id;
+						this.filteredFeed = this.notifications.filter(n => n.type === 'favourite');
+						this.filteredIsIntersecting = false;
+						this.tabSwitching = false;
+						this.filteredLoaded = true;
+					break;
+
+					case 'followers':
+						this.tabIndex = 3;
+						this.filteredMaxId = this.max_id;
+						this.filteredFeed = this.notifications.filter(n => n.type === 'follow');
+						this.filteredIsIntersecting = false;
+						this.tabSwitching = false;
+						this.filteredLoaded = true;
+					break;
+
+					case 'reblogs':
+						this.tabIndex = 4;
+						this.filteredMaxId = this.max_id;
+						this.filteredFeed = this.notifications.filter(n => n.type === 'share');
+						this.filteredIsIntersecting = false;
+						this.tabSwitching = false;
+						this.filteredLoaded = true;
+					break;
+
+					case 'direct':
+						this.tabIndex = 5;
+						this.filteredMaxId = this.max_id;
+						this.filteredFeed = this.notifications.filter(n => n.type === 'direct');
+						this.filteredIsIntersecting = false;
+						this.tabSwitching = false;
+						this.filteredLoaded = true;
+					break;
+				}
+			},
+
+			enterFilteredIntersect() {
+				if( !this.canLoadMoreFiltered ||
+					this.filteredIsIntersecting ||
+					this.filteredIterations > 10
+				) {
+					if(this.filteredFeed.length == 0) {
+						this.filteredEmpty = true;
+						this.canLoadMoreFiltered = false;
+					}
+					return;
+				}
+
+				if(!isFinite(this.max_id) || !isFinite(this.filteredMaxId)) {
+					this.canLoadMoreFiltered = false;
+					return;
+				}
+
+				this.filteredIsIntersecting = true;
+
+				axios.get('/api/pixelfed/v1/notifications', {
+					params: {
+						max_id: this.filteredMaxId,
+						limit: 40
+					}
+				})
+				.then(res => {
+					let mids = res.data.map(n => n.id);
+					let max_id = Math.min(...mids);
+					if(max_id < this.max_id) {
+						this.max_id = max_id;
+						res.data.forEach(n => {
+							if(this.ids.indexOf(n.id) == -1) {
+								this.ids.push(n.id);
+								this.notifications.push(n);
+							} else {
+							}
+						});
+					}
+					this.filteredIterations++;
+					if(this.filterPaginationTimeout && this.filterPaginationTimeout < 500) {
+						clearTimeout(this.filterPaginationTimeout);
+					}
+					if(!res.data || !res.data.length) {
+						this.canLoadMoreFiltered = false;
+					}
+					if(!res.data.length) {
+						this.canLoadMoreFiltered = false;
+					}
+					let ids = res.data.map(n => n.id);
+					this.filteredMaxId = Math.min(...ids);
+					let types = this.tabs[this.tabIndex].types;
+					let data = res.data.filter(n => types.includes(n.type));
+					this.filteredFeed.push(...data);
+					this.filteredIsIntersecting = false;
+					if(this.filteredFeed.length < 10) {
+						setTimeout(() => this.enterFilteredIntersect(), 500);
+					}
+					this.filterPaginationTimeout = setTimeout(() => {
+						this.canLoadMoreFiltered = false;
+					}, 2000);
+				})
+				.catch(err => {
+					this.canLoadMoreFiltered = false;
+				})
+			},
+
+			fetchFollowRequests() {
+				axios.get('/account/follow-requests.json')
+				.then(res => {
+					if(this.followRequestsPage == 1) {
+						this.followRequests = res.data;
+						this.followRequestsChecked = true;
+					} else {
+						this.followRequests.accounts.push(...res.data.accounts);
+					}
+					this.followRequestsPage++;
+				});
+			},
+
+			showFollowRequests() {
+				this.tabSwitching = false;
+				this.filteredEmpty = false;
+				this.filteredIterations = 0;
+				this.tabIndex = 10;
+			},
+
+			handleFollowRequest(action, index) {
+				if(!window.confirm('Are you sure you want to ' + action + ' this follow request?')) {
+					return;
+				}
+
+				axios.post('/account/follow-requests', {
+					action: action,
+					id: this.followRequests.accounts[index].rid
+				})
+				.then(res => {
+					this.followRequests.count--;
+					this.followRequests.accounts.splice(index, 1);
+					this.toggleTab(0);
+				})
+			},
+
+			truncate(str, len = 40) {
+				return _.truncate(str, { length: len });
+			}
+        }
+	}
+</script>
+
+<style lang="scss" scoped>
+	.notification-metro-component {
+		.notification-filters {
+			.nav-link {
+				font-size: 12px;
+
+				&.active {
+					font-weight: bold;
+				}
+
+				&-icon:not(.active) {
+					opacity: 0.5;
+				}
+
+				&:not(.active) {
+					color: #9ca3af;
+				}
+			}
+		}
+	}
+</style>

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

@@ -37,6 +37,15 @@
 						<div v-for="(n, index) in feed" class="mb-2">
 							<div class="media align-items-center">
 								<img
+									v-if="n.type === 'autospam.warning'"
+									class="mr-2 rounded-circle shadow-sm p-1"
+									style="border: 2px solid var(--danger)"
+									src="/img/pixelfed-icon-color.svg"
+									width="32"
+									height="32"
+									/>
+								<img
+									v-else
 									class="mr-2 rounded-circle shadow-sm"
 									:src="n.account.avatar"
 									width="32"
@@ -58,6 +67,14 @@
 											</span>
 										</p>
 									</div>
+									<div v-else-if="n.type == 'autospam.warning'">
+										<p class="my-0">
+											Your recent <a :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)" class="font-weight-bold">post</a> has been unlisted.
+										</p>
+										<p class="mt-n1 mb-0">
+											<span class="small text-muted"><a href="#" class="font-weight-bold" @click.prevent="showAutospamInfo(n.status)">Click here</a> for more info.</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>.
@@ -383,6 +400,20 @@
 					}
 				})
 			},
+
+			showAutospamInfo(status) {
+				let el = document.createElement('p');
+				el.classList.add('text-left');
+				el.classList.add('mb-0');
+				el.innerHTML = '<p class="">We use automated systems to help detect potential abuse and spam. Your recent <a href="/i/web/post/' + status.id + '" class="font-weight-bold">post</a> was flagged for review. <br /> <p class=""><span class="font-weight-bold">Don\'t worry! Your post will be reviewed by a human</span>, and they will restore your post if they determine it appropriate.</p><p style="font-size:12px">Once a human approves your post, any posts you create after will not be marked as unlisted. If you delete this post and share more posts before a human can approve any of them, you will need to wait for at least one unlisted post to be reviewed by a human.';
+				let wrapper = document.createElement('div');
+				wrapper.appendChild(el);
+				swal({
+					title: 'Why was my post unlisted?',
+					content: wrapper,
+					icon: 'warning'
+				})
+			}
 		}
 	}
 </script>