feat: notifications UI

minimal, but just works (tm)
This commit is contained in:
zkldi
2022-11-10 00:22:43 +00:00
parent 8d5dbe2ef3
commit 09c84c71a2
10 changed files with 188 additions and 66 deletions
@@ -0,0 +1,92 @@
import { APIFetchV1 } from "util/api";
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 { UserContext } from "context/UserContext";
import React, { useContext, useMemo } from "react";
import { Button, Col, Row } from "react-bootstrap";
import { NotificationDocument, PublicUserDocument } from "tachi-common";
export default function NotificationsPage() {
const { user } = useContext(UserContext);
useSetSubheader(["Dashboard", "Notifications"]);
if (!user) {
return <ErrorPage statusCode={401} />;
}
return <NotificationsInnerPage user={user} />;
}
function NotificationsInnerPage({ user }: { user: PublicUserDocument }) {
const { data, error } = useApiQuery<Array<NotificationDocument>>(
`/users/${user.id}/notifications`
);
const unread = useMemo(() => {
if (!data) {
return 0;
}
return data.filter((e) => e.read === false).length;
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
if (data.length === 0) {
return (
<div className="d-flex w-100 justify-content-center">
<div>You've got no mail!</div>
</div>
);
}
return (
<Row>
<Col xs={12}>
<MiniTable headers={["Read"]}>
{data.map((e) => (
<NotificationRow notif={e} key={e.notifID} />
))}
</MiniTable>
</Col>
<Col xs={12}>
<div className="w-100 d-flex">
<div style={{ flex: 1 }}></div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<Button
onClick={async () => {
await APIFetchV1(`/users/${user.id}/delete-all`, {
method: "POST",
});
}}
variant="outline-danger"
>
Clear Notifications
</Button>
</div>
<div style={{ flex: 1, textAlign: "right" }}>
{data.length} Notifications.
{unread > 0 && (
<>
{" "}
<strong>({unread} Unread)</strong>
</>
)}
</div>
</div>
</Col>
</Row>
);
}
@@ -210,9 +210,7 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
onChange={formik.handleChange}
label="Advanced Mode"
/>
<Form.Text>
Enable advanced stuff, like being able to copy a tables contents into a CSV.
</Form.Text>
<Form.Text>Enable advanced stuff.</Form.Text>
</Form.Group>
{/* <Form.Group>
<Form.Check
@@ -10,6 +10,7 @@ import { UserContext } from "context/UserContext";
import React, { useContext, useEffect, useState } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { BackgroundContext } from "context/BackgroundContext";
import NotificationsPage from "app/pages/dashboard/users/NotificationsPage";
import { DashboardPage } from "../pages/dashboard/DashboardPage";
import CreditsPage from "../pages/dashboard/misc/CreditsPage";
import GameRoutes from "./GameRoutes";
@@ -106,6 +107,10 @@ export default function DashboardRoutes() {
<UtilRoutes />
</Route>
<Route path="/dashboard/notifications">
<NotificationsPage />
</Route>
<Route path="*">
<ErrorPage statusCode={404} />
</Route>
+2 -26
View File
@@ -3,6 +3,7 @@ import LinkButton from "components/util/LinkButton";
import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import SearchBar from "./SearchBar";
import { UserNotificationButton } from "./UserNotificationButton";
export function Topbar() {
const { user } = useContext(UserContext);
@@ -13,32 +14,7 @@ export function Topbar() {
{user ? (
<>
{/* commented out quick panel stuff for future */}
{/* <OverlayTrigger
placement="bottom"
overlay={<Tooltip id="quick-panel-tooltip">Quick panel</Tooltip>}
>
<div
className="topbar-item"
data-toggle="tooltip"
title="Quick panel"
data-placement="right"
>
<div
className="btn btn-icon btn-hover-transparent-white btn-lg mr-1"
id="kt_quick_panel_toggle"
>
<span className="svg-icon svg-icon-xl">
<SVG
src={toAbsoluteUrl(
"/cdn/svg/icons/Layout/Layout-4-blocks.svg"
)}
/>
</span>
</div>
</div>
</OverlayTrigger> */}
<UserNotificationButton user={user} />
<UserProfileDropdown user={user} />
</>
) : (
@@ -0,0 +1,35 @@
import Icon from "components/util/Icon";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { NotificationDocument, PublicUserDocument } from "tachi-common";
export function UserNotificationButton({ user }: { user: PublicUserDocument }) {
// don't care whether it errors or not, tbh
const { data } = useApiQuery<Array<NotificationDocument>>(`/users/${user.id}/notifications`);
const unread = useMemo(() => {
if (!data) {
return 0;
}
return data.filter((e) => e.read === false).length;
}, [data]);
return (
<div className="topbar-item">
<Link to={"/dashboard/notifications"}>
<div
className="btn btn-icon btn-hover-transparent-white btn-dropdown btn-lg mr-1"
id="kt_quick_notifications_toggle"
>
{unread > 0 ? (
<Icon type="envelope" colour="primary" />
) : (
<Icon type="envelope" regular colour="muted" />
)}
</div>
</Link>
</div>
);
}
@@ -14,7 +14,6 @@ import { PublicUserDocument } from "tachi-common";
export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
const { setUser } = useContext(UserContext);
const [heySplash] = useState(RFA(heySplashes));
return (
@@ -28,7 +27,7 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
<span className="text-white opacity-70 font-weight-bold font-size-base d-none d-md-inline mr-1">
{heySplash},
</span>{" "}
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-4">
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-2">
{user.username}
</span>
<span className="symbol symbol-35">
@@ -37,17 +36,6 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
</div>
</Dropdown.Toggle>
<Dropdown.Menu className="p-0 m-0 dropdown-menu-right dropdown-menu-anim dropdown-menu-top-unround dropdown-menu-xl">
<div className="d-flex align-items-center justify-content-between flex-wrap p-8 rounded-top">
<div className="symbol bg-white-o-15 mr-3">
<img className="hidden" src={ToAPIURL("/users/me/pfp")} />
</div>
<div className="text-white m-0 flex-grow-1 mr-3 font-size-h5">
{user.username}
</div>
</div>
<Divider />
<div className="navi navi-spacer-x-0 pt-5">
<Link
to={`/dashboard/users/${user.username}`}
@@ -63,6 +51,20 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
</div>
</div>
</Link>
<Link
to={`/dashboard/users/${user.username}`}
className="navi-item px-8 cursor-pointer"
>
<div className="navi-link">
<div className="navi-icon mr-2">
<Icon type="envelope" colour="success" />
</div>
<div className="navi-text">
<div className="font-weight-bold cursor-pointer">Notifications</div>
<div className="text-muted">View your notifications!</div>
</div>
</div>
</Link>
<Link
to={`/dashboard/users/${user.username}/settings`}
className="navi-item px-8 cursor-pointer"
@@ -0,0 +1,36 @@
import TimestampCell from "components/tables/cells/TimestampCell";
import Icon from "components/util/Icon";
import React from "react";
import { Link } from "react-router-dom";
import { NotificationDocument } from "tachi-common";
export default function NotificationRow({ notif }: { notif: NotificationDocument }) {
return (
<tr>
<td>
{notif.read ? (
<Icon type="envelope" regular colour="muted" />
) : (
<Icon type="envelope-open" />
)}
</td>
<td>
<strong>
<Link className="gentle-link" to={NotifToURL(notif)}>
{notif.title}
</Link>
</strong>
</td>
<TimestampCell time={notif.sentAt} />
</tr>
);
}
function NotifToURL(notif: NotificationDocument) {
switch (notif.body.type) {
case "QUEST_CHANGED":
return "/idkyet";
case "RIVALED_BY":
return `/dashboard/users/${notif.body.content.userID}/games${notif.body.content.game}/${notif.body.content.playtype}`;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Icon({
brand?: boolean;
regular?: boolean;
onClick?: () => void;
colour?: "info" | "primary" | "danger" | "warning";
colour?: "info" | "primary" | "danger" | "warning" | "success" | "muted";
style?: CSSProperties;
}) {
return (
-22
View File
@@ -1595,19 +1595,6 @@ interface BaseNotification {
}
export type NotificationBody =
| {
type: "CHALLENGE_BEAT";
content: {
userID: integer;
challenge: ChallengeWallDocument;
};
}
| {
type: "CHALLENGE_RECEIVED";
content: {
challenge: ChallengeWallDocument;
};
}
| {
type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed.
content: {
@@ -1640,15 +1627,6 @@ export interface ChallengeSubscriptionDocument {
achievedAt: number | null;
}
export interface ChallengeWallDocument {
chartID: string;
authorID: integer;
type: "lamp" | "score";
game: Game;
playtype: Playtype;
}
interface BaseImportTracker {
timeStarted: number;
importID: string;
@@ -4,7 +4,7 @@ import { FormatGame } from "tachi-common";
import type { Game, integer, Playtype, PublicUserDocument } from "tachi-common";
/**
* A utility wrapper for sending the SET_RIVAL notification.
* A utility wrapper for sending the RIVALED_BY notification.
*
* @param toUserID - The user to send this notification to.
* @param fromUser - The user who rivalled them.