diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 1e7418397..d706313e2 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -2,6 +2,7 @@ import { MaterialThemeProvider } from "components/layout/misc/MaterialThemeProvi import { LoadingScreen } from "components/layout/screens/LoadingScreen"; import { AllLUGPTStatsContextProvider } from "context/AllLUGPTStatsContext"; import { BannedContextProvider } from "context/BannedContext"; +import { NotificationsContextProvider } from "context/NotificationsContext"; import { SubheaderContextProvider } from "context/SubheaderContext"; import { UserContextProvider } from "context/UserContext"; import { UserSettingsContextProvider } from "context/UserSettingsContext"; @@ -21,20 +22,22 @@ export default function App({ basename }: { basename: string }) { - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/client/src/app/pages/dashboard/users/NotificationsPage.tsx b/client/src/app/pages/dashboard/users/NotificationsPage.tsx index dbda57464..cf1f73e8a 100644 --- a/client/src/app/pages/dashboard/users/NotificationsPage.tsx +++ b/client/src/app/pages/dashboard/users/NotificationsPage.tsx @@ -3,13 +3,12 @@ import { ErrorPage } from "app/pages/ErrorPage"; import useSetSubheader from "components/layout/header/useSetSubheader"; import NotificationRow from "components/notifications/NotificationRow"; import MiniTable from "components/tables/components/MiniTable"; -import ApiError from "components/util/ApiError"; import Loading from "components/util/Loading"; -import useApiQuery from "components/util/query/useApiQuery"; +import { NotificationsContext } from "context/NotificationsContext"; import { UserContext } from "context/UserContext"; import React, { useContext, useEffect, useMemo } from "react"; import { Button, Col, Row } from "react-bootstrap"; -import { NotificationDocument, UserDocument } from "tachi-common"; +import { UserDocument } from "tachi-common"; export default function NotificationsPage() { const { user } = useContext(UserContext); @@ -24,34 +23,31 @@ export default function NotificationsPage() { } function NotificationsInnerPage({ user }: { user: UserDocument }) { - const { data, error } = useApiQuery>( - `/users/${user.id}/notifications` - ); + const { notifications, reload } = useContext(NotificationsContext); const unread = useMemo(() => { - if (!data) { + if (!notifications) { return 0; } - return data.filter((e) => e.read === false).length; - }, [data]); + return notifications.filter((e) => e.read === false).length; + }, [notifications]); - // mark all notifications the user has seen as being read + // mark all notifications the user has seen as being read, this runs on mount + // and never again useEffect(() => { - if (data) { - APIFetchV1(`/users/${user.id}/notifications/mark-all-read`, { method: "POST" }); + if (notifications) { + APIFetchV1(`/users/${user.id}/notifications/mark-all-read`, { method: "POST" }).then( + () => reload() + ); } - }, [data]); + }, []); - if (error) { - return ; - } - - if (!data) { + if (!notifications) { return ; } - if (data.length === 0) { + if (notifications.length === 0) { return (
You've got no mail!
@@ -63,7 +59,7 @@ function NotificationsInnerPage({ user }: { user: UserDocument }) { - {data.map((e) => ( + {notifications.map((e) => ( ))} @@ -74,9 +70,11 @@ function NotificationsInnerPage({ user }: { user: UserDocument }) {
- {data.length} Notifications. + {notifications.length} Notifications. {unread > 0 && ( <> {" "} diff --git a/client/src/components/layout/header/UserNotificationButton.tsx b/client/src/components/layout/header/UserNotificationButton.tsx index 47eb6230d..a4d20869e 100644 --- a/client/src/components/layout/header/UserNotificationButton.tsx +++ b/client/src/components/layout/header/UserNotificationButton.tsx @@ -1,20 +1,20 @@ import Icon from "components/util/Icon"; import useApiQuery from "components/util/query/useApiQuery"; -import React, { useMemo } from "react"; +import { NotificationsContext } from "context/NotificationsContext"; +import React, { useContext, useMemo } from "react"; import { Link } from "react-router-dom"; import { NotificationDocument, UserDocument } from "tachi-common"; export function UserNotificationButton({ user }: { user: UserDocument }) { - // don't care whether it errors or not, tbh - const { data } = useApiQuery>(`/users/${user.id}/notifications`); + const { notifications } = useContext(NotificationsContext); const unread = useMemo(() => { - if (!data) { + if (!notifications) { return 0; } - return data.filter((e) => e.read === false).length; - }, [data]); + return notifications.filter((e) => e.read === false).length; + }, [notifications]); return (
diff --git a/client/src/components/notifications/NotificationRow.tsx b/client/src/components/notifications/NotificationRow.tsx index b8fd28bb2..7633d61eb 100644 --- a/client/src/components/notifications/NotificationRow.tsx +++ b/client/src/components/notifications/NotificationRow.tsx @@ -28,9 +28,14 @@ export default function NotificationRow({ notif }: { notif: NotificationDocument function NotifToURL(notif: NotificationDocument) { switch (notif.body.type) { - case "QUEST_CHANGED": - return "/idkyet"; + case "QUEST_CHANGED": { + const { game, playtype, questID } = notif.body.content; + + return `/dashboard/games/${game}/${playtype}/quests/${questID}`; + } case "RIVALED_BY": return `/dashboard/users/${notif.body.content.userID}/games/${notif.body.content.game}/${notif.body.content.playtype}`; + case "SITE_ANNOUNCEMENT": + return `#`; } } diff --git a/client/src/context/NotificationsContext.tsx b/client/src/context/NotificationsContext.tsx new file mode 100644 index 000000000..b4dba1ae2 --- /dev/null +++ b/client/src/context/NotificationsContext.tsx @@ -0,0 +1,49 @@ +import { APIFetchV1 } from "util/api"; +import React, { createContext, useContext, useEffect, useState } from "react"; +import { NotificationDocument } from "tachi-common"; +import { JustChildren } from "types/react"; +import { UserContext } from "./UserContext"; + +export const NotificationsContext = createContext<{ + notifications: Array; + reload: () => Promise; +}>({ + notifications: [], + // eslint-disable-next-line require-await + reload: async () => void 0, +}); + +export function NotificationsContextProvider({ children }: JustChildren) { + const { user } = useContext(UserContext); + + const [notifications, setNotifications] = useState>([]); + + const reload = async () => { + if (!user) { + setNotifications([]); + return; + } + + await APIFetchV1>(`/users/${user.id}/notifications`).then( + (r) => { + if (!r.success) { + setNotifications([]); + return; + } + + setNotifications(r.body); + } + ); + }; + + // fetch the target subscriptions from the api. + useEffect(() => { + reload(); + }, [user]); + + return ( + + {children} + + ); +} diff --git a/common/src/types.ts b/common/src/types.ts index 90b3c8d4a..c9731343e 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -1571,6 +1571,8 @@ export type NotificationBody = type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed. content: { questID: string; + game: Game; + playtype: Playtype; }; } | { @@ -1580,6 +1582,11 @@ export type NotificationBody = game: Game; playtype: Playtype; }; + } + | { + type: "SITE_ANNOUNCEMENT"; // Emitted as a site announcement + // eslint-disable-next-line @typescript-eslint/ban-types + content: {}; }; export type NotificationDocument = BaseNotification & { diff --git a/server/src/lib/notifications/notification-wrappers.ts b/server/src/lib/notifications/notification-wrappers.ts index 05c3e872d..e3d1d736c 100644 --- a/server/src/lib/notifications/notification-wrappers.ts +++ b/server/src/lib/notifications/notification-wrappers.ts @@ -1,4 +1,4 @@ -import { SendNotification } from "./notifications"; +import { BulkSendNotification, SendNotification } from "./notifications"; import db from "external/mongo/db"; import { FormatGame } from "tachi-common"; import type { Game, integer, Playtype, UserDocument } from "tachi-common"; @@ -44,3 +44,27 @@ export async function SendSetRivalNotification( } ); } + +export async function SendSiteAnnouncementNotification( + title: string, + maybeGame?: Game, + maybePlaytype?: Playtype +) { + let toUserIDs = []; + + if (maybeGame && maybePlaytype) { + toUserIDs = ( + await db["game-stats"].find( + { game: maybeGame, playtype: maybePlaytype }, + { projection: { userID: 1 } } + ) + ).map((e) => e.userID); + } else { + toUserIDs = (await db.users.find({}, { projection: { id: 1 } })).map((e) => e.id); + } + + return BulkSendNotification(title, toUserIDs, { + type: "SITE_ANNOUNCEMENT", + content: {}, + }); +} diff --git a/server/src/lib/targets/quests.ts b/server/src/lib/targets/quests.ts index 4b91d1598..76053d251 100644 --- a/server/src/lib/targets/quests.ts +++ b/server/src/lib/targets/quests.ts @@ -319,6 +319,8 @@ export async function UpdateQuestSubscriptions(questID: string) { type: "QUEST_CHANGED", content: { questID, + game: maybeQuest.game, + playtype: maybeQuest.playtype, }, } ); diff --git a/server/src/server/router/api/v1/admin/router.ts b/server/src/server/router/api/v1/admin/router.ts index 0c70414c3..ba2a9191d 100644 --- a/server/src/server/router/api/v1/admin/router.ts +++ b/server/src/server/router/api/v1/admin/router.ts @@ -4,6 +4,7 @@ import db from "external/mongo/db"; import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import { ONE_MINUTE } from "lib/constants/time"; import CreateLogCtx, { ChangeRootLogLevel, GetLogLevel } from "lib/logger/logger"; +import { SendSiteAnnouncementNotification } from "lib/notifications/notification-wrappers"; import { DeleteMultipleScores, DeleteScore } from "lib/score-mutation/delete-scores"; import { ServerConfig, TachiConfig } from "lib/setup/config"; import p from "prudence"; @@ -300,4 +301,46 @@ router.post("/recalc", async (req, res) => { }); }); +/** + * Send an announcement to the site. + * + * @name POST /api/v1/admin/announcement + */ +router.post( + "/announcement", + prValidate({ + game: p.optional(p.isIn(TachiConfig.GAMES)), + playtype: "*string", + title: "string", + }), + async (req, res) => { + const { game, playtype, title } = req.safeBody as { + game?: Game; + playtype?: string; + title: string; + }; + + let maybePlaytype: Playtype | undefined; + + if (game && playtype) { + if (!IsValidPlaytype(game, playtype)) { + return res.status(400).json({ + success: false, + description: `Invalid playtype '${playtype}' for game '${game}'.`, + }); + } + + maybePlaytype = playtype; + } + + await SendSiteAnnouncementNotification(title, game, maybePlaytype); + + return res.status(200).json({ + success: true, + description: `Sent notification '${title}'.`, + body: {}, + }); + } +); + export default router;