Add notification pagination (#1584)

* Add notification pagination

* Add grouping to dashboard home
This commit is contained in:
Prospector 2024-01-27 09:23:57 -08:00 committed by GitHub
parent 75f0b2b82c
commit 0195e94aa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 224 additions and 199 deletions

View File

@ -6,174 +6,143 @@ async function getBulk(type, ids, apiVersion = 2) {
}
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url, { apiVersion }))
return bulkFetch.value
return await useBaseFetch(url, { apiVersion })
}
export async function fetchNotifications() {
try {
const auth = (await useAuth()).value
const { data: notifications } = await useAsyncData(`user/${auth.user.id}/notifications`, () =>
useBaseFetch(`user/${auth.user.id}/notifications`)
)
const projectIds = []
const reportIds = []
const threadIds = []
const userIds = []
const versionIds = []
const organizationIds = []
for (const notification of notifications.value) {
if (notification.body) {
if (notification.body.project_id) {
projectIds.push(notification.body.project_id)
}
if (notification.body.version_id) {
versionIds.push(notification.body.version_id)
}
if (notification.body.report_id) {
reportIds.push(notification.body.report_id)
}
if (notification.body.thread_id) {
threadIds.push(notification.body.thread_id)
}
if (notification.body.invited_by) {
userIds.push(notification.body.invited_by)
}
if (notification.body.organization_id) {
organizationIds.push(notification.body.organization_id)
}
}
}
const reports = await getBulk('reports', reportIds)
for (const report of reports) {
if (report.item_type === 'project') {
projectIds.push(report.item_id)
} else if (report.item_type === 'user') {
userIds.push(report.item_id)
} else if (report.item_type === 'version') {
versionIds.push(report.item_id)
}
}
const versions = await getBulk('versions', versionIds)
for (const version of versions) {
projectIds.push(version.project_id)
}
const [projects, threads, users, organizations] = await Promise.all([
getBulk('projects', projectIds),
getBulk('threads', threadIds),
getBulk('users', userIds),
getBulk('organizations', organizationIds, 3),
])
for (const notification of notifications.value) {
notification.extra_data = {}
if (notification.body) {
if (notification.body.project_id) {
notification.extra_data.project = projects.find(
(x) => x.id === notification.body.project_id
)
}
if (notification.body.organization_id) {
notification.extra_data.organization = organizations.find(
(x) => x.id === notification.body.organization_id
)
}
if (notification.body.report_id) {
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
const type = notification.extra_data.report.item_type
if (type === 'project') {
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'user') {
notification.extra_data.user = users.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'version') {
notification.extra_data.version = versions.find(
(x) => x.id === notification.extra_data.report.item_id
)
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.version.project_id
)
}
}
if (notification.body.thread_id) {
notification.extra_data.thread = threads.find((x) => x.id === notification.body.thread_id)
}
if (notification.body.invited_by) {
notification.extra_data.invited_by = users.find(
(x) => x.id === notification.body.invited_by
)
}
if (notification.body.version_id) {
notification.extra_data.version = versions.find(
(x) => x.id === notification.body.version_id
)
}
}
}
return notifications.value
} catch (error) {
const app = useNuxtApp()
app.$notify({
group: 'main',
title: 'Error loading notifications',
text: error.data ? error.data.description : error,
type: 'error',
})
export async function fetchExtraNotificationData(notifications) {
const bulk = {
projects: [],
reports: [],
threads: [],
users: [],
versions: [],
organizations: [],
}
return null
}
export function groupNotifications(notifications, includeRead = false) {
const grouped = []
for (const notification of notifications) {
notification.grouped_notifs = []
if (notification.body) {
if (notification.body.project_id) {
bulk.projects.push(notification.body.project_id)
}
if (notification.body.version_id) {
bulk.versions.push(notification.body.version_id)
}
if (notification.body.report_id) {
bulk.reports.push(notification.body.report_id)
}
if (notification.body.thread_id) {
bulk.threads.push(notification.body.thread_id)
}
if (notification.body.invited_by) {
bulk.users.push(notification.body.invited_by)
}
if (notification.body.organization_id) {
bulk.organizations.push(notification.body.organization_id)
}
}
}
for (const notification of notifications.filter((notif) => includeRead || !notif.read)) {
// Group notifications of the same thread or project id
const reports = await getBulk('reports', bulk.reports)
for (const report of reports) {
if (report.item_type === 'project') {
bulk.projects.push(report.item_id)
} else if (report.item_type === 'user') {
bulk.users.push(report.item_id)
} else if (report.item_type === 'version') {
bulk.versions.push(report.item_id)
}
}
const versions = await getBulk('versions', bulk.versions)
for (const version of versions) {
bulk.projects.push(version.project_id)
}
const [projects, threads, users, organizations] = await Promise.all([
getBulk('projects', bulk.projects),
getBulk('threads', bulk.threads),
getBulk('users', bulk.users),
getBulk('organizations', bulk.organizations, 3),
])
for (const notification of notifications) {
notification.extra_data = {}
if (notification.body) {
const index = grouped.findIndex(
(notif) =>
((notif.body.thread_id === notification.body.thread_id && !!notif.body.thread_id) ||
(notif.body.project_id === notification.body.project_id && !!notif.body.project_id)) &&
notification.read === notif.read
)
const notif = grouped[index]
if (
notif &&
(notification.body.type === 'moderator_message' ||
notification.body.type === 'project_update')
) {
let groupedNotifs = notif.grouped_notifs
if (!groupedNotifs) {
groupedNotifs = []
}
groupedNotifs.push(notification)
grouped[index].grouped_notifs = groupedNotifs
} else {
grouped.push(notification)
if (notification.body.project_id) {
notification.extra_data.project = projects.find(
(x) => x.id === notification.body.project_id
)
}
if (notification.body.organization_id) {
notification.extra_data.organization = organizations.find(
(x) => x.id === notification.body.organization_id
)
}
if (notification.body.report_id) {
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
const type = notification.extra_data.report.item_type
if (type === 'project') {
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'user') {
notification.extra_data.user = users.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'version') {
notification.extra_data.version = versions.find(
(x) => x.id === notification.extra_data.report.item_id
)
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.version.project_id
)
}
}
if (notification.body.thread_id) {
notification.extra_data.thread = threads.find((x) => x.id === notification.body.thread_id)
}
if (notification.body.invited_by) {
notification.extra_data.invited_by = users.find(
(x) => x.id === notification.body.invited_by
)
}
if (notification.body.version_id) {
notification.extra_data.version = versions.find(
(x) => x.id === notification.body.version_id
)
}
}
}
return notifications
}
export function groupNotifications(notifications) {
const grouped = []
for (let i = 0; i < notifications.length; i++) {
const current = notifications[i]
const next = notifications[i + 1]
if (current.body && i < notifications.length - 1 && isSimilar(current, next)) {
current.grouped_notifs = [next]
let j = i + 2
while (j < notifications.length && isSimilar(current, notifications[j])) {
current.grouped_notifs.push(notifications[j])
j++
}
grouped.push(current)
i = j - 1 // skip i to the last ungrouped
} else {
grouped.push(notification)
grouped.push(current)
}
}
return grouped
}
function isSimilar(notifA, notifB) {
return !!notifA.body.project_id && notifA.body.project_id === notifB.body.project_id
}
export async function markAsRead(ids) {
try {
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {

View File

@ -28,12 +28,13 @@
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
v-model:notifications="allNotifs"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
compact
@update:notifications="() => refresh()"
/>
<nuxt-link
v-if="extraNotifs > 0"
@ -112,7 +113,7 @@ import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import HistoryIcon from '~/assets/images/utils/history.svg'
import Avatar from '~/components/ui/Avatar.vue'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import { fetchNotifications, groupNotifications } from '~/helpers/notifications.js'
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/notifications.js'
useHead({
title: 'Dashboard - Modrinth',
@ -135,10 +136,26 @@ const followersProjectCount = computed(
() => projects.value.filter((project) => project.followers > 0).length
)
const allNotifs = groupNotifications(await fetchNotifications())
const { data, refresh } = await useAsyncData(async () => {
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const notifications = computed(() => allNotifs.slice(0, 3))
const extraNotifs = computed(() => allNotifs.length - notifications.value.length)
const filteredNotifications = notifications.filter((notif) => !notif.read)
const slice = filteredNotifications.slice(0, 30) // send first 30 notifs to be grouped before trimming to 3
return fetchExtraNotificationData(slice).then((notifications) => {
notifications = groupNotifications(notifications).slice(0, 3)
return { notifications, extraNotifs: filteredNotifications.length - slice.length }
})
})
const notifications = computed(() => {
if (data.value === null) {
return []
}
return data.value.notifications
})
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0))
</script>
<style lang="scss">
.dashboard-overview {

View File

@ -12,45 +12,55 @@
<h2 v-else>Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="allNotifs && allNotifs.some((notif) => notif.read)" @click="updateRoute()">
<HistoryIcon /> View history
</Button>
<Button v-if="hasRead" @click="updateRoute()"> <HistoryIcon /> View history </Button>
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
<CheckCheckIcon /> Mark all as read
</Button>
</template>
</div>
<template v-if="notifications.length > 0">
<Chips
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="
(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')
"
:capitalize="false"
/>
<Chips
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')"
:capitalize="false"
/>
<p v-if="pending">Loading notifications...</p>
<template v-else-if="error">
<p>Error loading notifications:</p>
<pre>
{{ error }}
</pre>
</template>
<template v-else-if="notifications && notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
v-model:notifications="allNotifs"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
@update:notifications="() => refresh()"
/>
</template>
<p v-else>You don't have any unread notifications.</p>
<Pagination :page="page" :count="pages" @switch-page="changePage" />
</section>
</div>
</template>
<script setup>
import { Button, HistoryIcon } from 'omorphia'
import { fetchNotifications, groupNotifications, markAsRead } from '~/helpers/notifications.js'
import {
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from '~/helpers/notifications.js'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import Chips from '~/components/ui/Chips.vue'
import CheckCheckIcon from '~/assets/images/utils/check-check.svg'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import Pagination from '~/components/ui/Pagination.vue'
useHead({
title: 'Notifications - Modrinth',
@ -65,39 +75,59 @@ const history = computed(() => {
return route.name === 'dashboard-notifications-history'
})
const allNotifs = ref(null)
const selectedType = ref('all')
const page = ref(1)
const notifTypes = computed(() => {
if (allNotifs.value === null) {
return []
}
const types = [
...new Set(
allNotifs.value
.filter((notification) => {
return history.value || !notification.read
})
.map((notif) => notif.type)
),
]
return types.length > 1 ? ['all', ...types] : types
})
const perPage = ref(50)
const { data, pending, error, refresh } = await useAsyncData(
async () => {
const pageNum = page.value - 1
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const showRead = history.value
const hasRead = notifications.some((notif) => notif.read)
const types = [
...new Set(
notifications
.filter((notification) => {
return showRead || !notification.read
})
.map((notification) => notification.type)
),
]
const filteredNotifications = notifications.filter(
(notification) =>
(selectedType.value === 'all' || notification.type === selectedType.value) &&
(showRead || !notification.read)
)
const pages = Math.ceil(filteredNotifications.length / perPage.value)
return fetchExtraNotificationData(
filteredNotifications.slice(pageNum * perPage.value, perPage.value + pageNum * perPage.value)
).then((notifications) => {
return {
notifications,
types: types.length > 1 ? ['all', ...types] : types,
pages,
hasRead,
}
})
},
{ watch: [page, history, selectedType] }
)
const notifications = computed(() => {
if (allNotifs.value === null) {
if (data.value === null) {
return []
}
const groupedNotifs = groupNotifications(allNotifs.value, history.value)
return groupedNotifs.filter(
(notif) => selectedType.value === 'all' || notif.type === selectedType.value
)
})
const selectedType = ref('all')
await fetchNotifications().then((result) => {
allNotifs.value = result
return groupNotifications(data.value.notifications, history.value)
})
const notifTypes = computed(() => data.value.types)
const pages = computed(() => data.value.pages)
const hasRead = computed(() => data.value.hasRead)
function updateRoute() {
if (history.value) {
@ -105,6 +135,8 @@ function updateRoute() {
} else {
router.push('/dashboard/notifications/history')
}
selectedType.value = 'all'
page.value = 1
}
async function readAll() {
@ -116,6 +148,13 @@ async function readAll() {
const updateNotifs = await markAsRead(ids)
allNotifs.value = updateNotifs(allNotifs.value)
}
function changePage(newPage) {
page.value = newPage
if (process.client) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
</script>
<style lang="scss" scoped>
.read-toggle-input {