diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 1dabdc7b6..f692788a8 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -335,7 +335,7 @@ import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue"; import { getProjectLink, getVersionLink } from "~/helpers/projects.js"; import { getUserLink } from "~/helpers/users.js"; import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js"; -import { markAsRead } from "~/helpers/notifications.js"; +import { markAsRead } from "~/helpers/notifications.ts"; import DoubleIcon from "~/components/ui/DoubleIcon.vue"; import Avatar from "~/components/ui/Avatar.vue"; import Badge from "~/components/ui/Badge.vue"; diff --git a/apps/frontend/src/helpers/notifications.js b/apps/frontend/src/helpers/notifications.js deleted file mode 100644 index e499e9e2e..000000000 --- a/apps/frontend/src/helpers/notifications.js +++ /dev/null @@ -1,170 +0,0 @@ -import { useNuxtApp } from "#imports"; - -async function getBulk(type, ids, apiVersion = 2) { - if (ids.length === 0) { - return []; - } - - const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`; - return await useBaseFetch(url, { apiVersion }); -} - -export async function fetchExtraNotificationData(notifications) { - const bulk = { - projects: [], - reports: [], - threads: [], - users: [], - versions: [], - organizations: [], - }; - - for (const notification of notifications) { - 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); - } - } - } - - 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) { - 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(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)])}`, { - method: "PATCH", - }); - return (notifications) => { - const newNotifs = notifications; - newNotifs.forEach((notif) => { - if (ids.includes(notif.id)) { - notif.read = true; - } - }); - return newNotifs; - }; - } catch (err) { - const app = useNuxtApp(); - app.$notify({ - group: "main", - title: "Error marking notification as read", - text: err.data ? err.data.description : err, - type: "error", - }); - return () => {}; - } -} diff --git a/apps/frontend/src/helpers/notifications.ts b/apps/frontend/src/helpers/notifications.ts new file mode 100644 index 000000000..789a07d30 --- /dev/null +++ b/apps/frontend/src/helpers/notifications.ts @@ -0,0 +1,185 @@ +import { useNuxtApp } from "#imports"; + +// TODO: There needs to be a standardized way to get these types, eg; @modrinth/types generated from api schema. Later problem. +type Project = { id: string }; +type Version = { id: string; project_id: string }; +type Report = { id: string; item_type: "project" | "user" | "version"; item_id: string }; +type Thread = { id: string }; +type User = { id: string }; +type Organization = { id: string }; + +export type NotificationAction = { + title: string; + action_route: [string, string]; +}; + +export type NotificationBody = { + project_id?: string; + version_id?: string; + report_id?: string; + thread_id?: string; + invited_by?: string; + organization_id?: string; +}; + +export type Notification = { + id: string; + user_id: string; + type: "project_update" | "team_invite" | "status_change" | "moderator_message"; + title: string; + text: string; + link: string; + read: boolean; + created: string; + actions: NotificationAction[]; + body?: NotificationBody; + extra_data?: Record; + grouped_notifs?: Notification[]; +}; + +async function getBulk( + type: string, + ids: string[], + apiVersion = 2, +): Promise { + if (!ids || ids.length === 0) { + return []; + } + const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`; + try { + const res = await useBaseFetch(url, { apiVersion }); + return Array.isArray(res) ? res : []; + } catch { + return []; + } +} + +export async function fetchExtraNotificationData( + notifications: Notification[], +): Promise { + const bulk = { + projects: [] as string[], + reports: [] as string[], + threads: [] as string[], + users: [] as string[], + versions: [] as string[], + organizations: [] as string[], + }; + + for (const notification of notifications) { + 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); + } + } + + const reports = (await getBulk("reports", bulk.reports)).filter(Boolean); + for (const r of reports) { + if (!r?.item_type) continue; + if (r.item_type === "project") bulk.projects.push(r.item_id); + else if (r.item_type === "user") bulk.users.push(r.item_id); + else if (r.item_type === "version") bulk.versions.push(r.item_id); + } + + const versions = (await getBulk("versions", bulk.versions)).filter(Boolean); + for (const v of versions) bulk.projects.push(v.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 n of notifications) { + n.extra_data = {}; + if (n.body) { + if (n.body.project_id) + n.extra_data.project = projects.find((x) => x.id === n.body!.project_id); + if (n.body.organization_id) + n.extra_data.organization = organizations.find((x) => x.id === n.body!.organization_id); + if (n.body.report_id) { + n.extra_data.report = reports.find((x) => x.id === n.body!.report_id); + const t = (n.extra_data.report as Report | undefined)?.item_type; + if (t === "project") + n.extra_data.project = projects.find( + (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id, + ); + else if (t === "user") + n.extra_data.user = users.find( + (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id, + ); + else if (t === "version") { + n.extra_data.version = versions.find( + (x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id, + ); + n.extra_data.project = projects.find( + (x) => x.id === (n.extra_data?.version as Version | undefined)?.project_id, + ); + } + } + if (n.body.thread_id) n.extra_data.thread = threads.find((x) => x.id === n.body!.thread_id); + if (n.body.invited_by) + n.extra_data.invited_by = users.find((x) => x.id === n.body!.invited_by); + if (n.body.version_id) + n.extra_data.version = versions.find((x) => x.id === n.body!.version_id); + } + } + return notifications; +} + +export function groupNotifications(notifications: Notification[]): Notification[] { + const grouped: Notification[] = []; + 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; + } else { + grouped.push(current); + } + } + return grouped; +} + +function isSimilar(a: Notification, b: Notification | undefined): boolean { + return !!a?.body?.project_id && a.body!.project_id === b?.body?.project_id; +} + +export async function markAsRead( + ids: string[], +): Promise<(notifications: Notification[]) => Notification[]> { + try { + await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, { + method: "PATCH", + }); + return (notifications: Notification[]) => { + const newNotifs = notifications ?? []; + newNotifs.forEach((n) => { + if (ids.includes(n.id)) n.read = true; + }); + return newNotifs; + }; + } catch (err: any) { + const app: any = useNuxtApp(); + app.$notify({ + group: "main", + title: "Error marking notification as read", + text: err?.data?.description ?? err, + type: "error", + }); + return () => []; + } +} diff --git a/apps/frontend/src/pages/dashboard/index.vue b/apps/frontend/src/pages/dashboard/index.vue index 5560110f2..bacc6e4d1 100644 --- a/apps/frontend/src/pages/dashboard/index.vue +++ b/apps/frontend/src/pages/dashboard/index.vue @@ -99,7 +99,7 @@ import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets"; import Avatar from "~/components/ui/Avatar.vue"; import NotificationItem from "~/components/ui/NotificationItem.vue"; -import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.js"; +import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.ts"; useHead({ title: "Dashboard - Modrinth", diff --git a/apps/frontend/src/pages/dashboard/notifications.vue b/apps/frontend/src/pages/dashboard/notifications.vue index 931b38463..cc1be438c 100644 --- a/apps/frontend/src/pages/dashboard/notifications.vue +++ b/apps/frontend/src/pages/dashboard/notifications.vue @@ -12,7 +12,7 @@

Notifications