Update Notification components, add autospam notification support

This commit is contained in:
Daniel Supernault 2023-05-13 05:39:47 -06:00
parent ea943333a5
commit 0d3b4bc225
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
2 changed files with 616 additions and 0 deletions

View file

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

View file

@ -37,6 +37,15 @@
<div v-for="(n, index) in feed" class="mb-2"> <div v-for="(n, index) in feed" class="mb-2">
<div class="media align-items-center"> <div class="media align-items-center">
<img <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" class="mr-2 rounded-circle shadow-sm"
:src="n.account.avatar" :src="n.account.avatar"
width="32" width="32"
@ -58,6 +67,14 @@
</span> </span>
</p> </p>
</div> </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'"> <div v-else-if="n.type == 'comment'">
<p class="my-0"> <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>. <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> </script>