diff --git a/web/src/app/(dashboard)/notifications/page.tsx b/web/src/app/(dashboard)/notifications/page.tsx index 5597a62c4..ea55db9a8 100644 --- a/web/src/app/(dashboard)/notifications/page.tsx +++ b/web/src/app/(dashboard)/notifications/page.tsx @@ -1,5 +1,13 @@ -import { NotificationScreen } from "src/screens/notifications/NotificationScreen"; +import { notificationList } from "@/api/openapi-server/notifications"; +import { Unready } from "@/components/site/Unready"; +import { NotificationScreen } from "@/screens/notifications/NotificationScreen"; -export default function Page() { - return ; +export default async function Page() { + try { + const { data } = await notificationList(); + + return ; + } catch (e) { + return ; + } } diff --git a/web/src/components/notifications/NotificationCardList.tsx b/web/src/components/notifications/NotificationCardList.tsx new file mode 100644 index 000000000..4866e1c7d --- /dev/null +++ b/web/src/components/notifications/NotificationCardList.tsx @@ -0,0 +1,107 @@ +import { + ArchiveBoxIcon, + InboxArrowDownIcon, +} from "@heroicons/react/24/outline"; +import Image from "next/image"; + +import { handle } from "@/api/client"; +import { NotificationStatus } from "@/api/openapi-schema"; +import { Card, CardRows } from "@/components/ui/rich-card"; +import { css } from "@/styled-system/css"; +import { Center, HStack, LStack, styled } from "@/styled-system/jsx"; +import { timestamp } from "@/utils/date"; + +import { MemberBadge } from "../member/MemberBadge/MemberBadge"; +import { Button } from "../ui/button"; + +import { NotificationItem } from "./item"; + +type Props = { + notifications: NotificationItem[]; + onMove: (id: string, status: NotificationStatus) => Promise; +}; + +export function NotificationCardList({ notifications, onMove }: Props) { + if (notifications.length === 0) { + return ( + + no notifications. + + ); + } + + return ( + + {notifications.map((n) => { + const title = n.item?.description + ? `${n.description} "${n.item?.description}"` + : n.description; + + return ( + } + > + + + ); + })} + + ); +} + +function NotificationSource(props: NotificationItem) { + if (props.source) { + return ( + + ); + } + + return ( + + + system message + + + ); +} + +function StatusControl({ + notification, + onMove, +}: { + notification: NotificationItem; + onMove: (id: string, status: NotificationStatus) => void; +}) { + function handleChangeStatus() { + handle(async () => { + const newStatus = notification.isRead ? "unread" : "read"; + onMove(notification.id, newStatus); + }); + } + + return notification.isRead ? ( + + + + ) : ( + + + + ); +} diff --git a/web/src/components/notifications/NotificationsMenu.tsx b/web/src/components/notifications/NotificationsMenu.tsx new file mode 100644 index 000000000..ab81d5aa4 --- /dev/null +++ b/web/src/components/notifications/NotificationsMenu.tsx @@ -0,0 +1,127 @@ +import { Portal } from "@ark-ui/react"; +import { ArchiveBoxIcon, Cog6ToothIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; + +import * as Menu from "@/components/ui/menu"; +import { Box, Center, HStack, LStack, styled } from "@/styled-system/jsx"; +import { hstack } from "@/styled-system/patterns"; + +import { MemberAvatar } from "../member/MemberBadge/MemberAvatar"; +import { NotificationAction } from "../site/Navigation/Actions/Notifications"; +import { Unready } from "../site/Unready"; +import { Button } from "../ui/button"; +import { LinkButton } from "../ui/link-button"; + +import { NotificationItem } from "./item"; +import { Props, useNotifications } from "./useNotifications"; + +export function NotificationsMenu(props: Props) { + const { ready, error, data, handlers } = useNotifications(props); + if (!ready) { + return ; + } + + const { unreads, notifications } = data; + + const isEmpty = notifications.length === 0; + + return ( + + + + + {!isEmpty && ( + + )} + + + + + + + + + + + Notifications ({unreads}) + + + + see all + + + + + + + + {isEmpty ? ( + + You're all caught up! + + ) : ( + notifications.map((notification) => ( + + + + handlers.handleMarkAs(notification.id, "read") + } + > + + + + {notification.source?.handle ?? "System"} + + + {notification.description} + + + + + + handlers.handleMarkAs(notification.id, "read") + } + > + + + + + )) + )} + + + + + + ); +} + +export function NotificationAvatar(props: { notification: NotificationItem }) { + if (props.notification.source) { + return ; + } + + return ; +} diff --git a/web/src/components/notifications/item.ts b/web/src/components/notifications/item.ts new file mode 100644 index 000000000..fd28f544b --- /dev/null +++ b/web/src/components/notifications/item.ts @@ -0,0 +1,12 @@ +import { DatagraphItem, ProfileReference } from "@/api/openapi-schema"; + +export type NotificationItem = { + id: string; + createdAt: Date; + title: string; + description: string; + url: string; + isRead: boolean; + source?: ProfileReference; + item?: DatagraphItem; +}; diff --git a/web/src/components/notifications/useNotifications.ts b/web/src/components/notifications/useNotifications.ts new file mode 100644 index 000000000..515100a4c --- /dev/null +++ b/web/src/components/notifications/useNotifications.ts @@ -0,0 +1,112 @@ +import { filter, flow, map } from "lodash/fp"; + +import { handle } from "@/api/client"; +import { notificationUpdate } from "@/api/openapi-client/categories"; +import { useNotificationList } from "@/api/openapi-client/notifications"; +import { + Notification, + NotificationListResult, + NotificationStatus, +} from "@/api/openapi-schema"; + +import { NotificationItem } from "./item"; + +export type Props = { + initialData?: NotificationListResult; + status: NotificationStatus; +}; + +export function useNotifications(props: Props) { + const filterByStatus = filterStatus(props.status); + const processNotifications = flow(filterByStatus, mapToItems); + + const { data, error, mutate } = useNotificationList( + { status: [props.status] }, + { + swr: { + fallbackData: props.initialData, + revalidateIfStale: true, + revalidateOnReconnect: true, + }, + }, + ); + if (!data) { + return { + ready: false as const, + error, + }; + } + + const unreads = filterUnread(data.notifications).length; + + const notifications = processNotifications(data.notifications); + + async function handleMarkAs(id: string, status: NotificationStatus) { + handle(async () => { + await notificationUpdate(id, { status }); + + if (data) { + const newList = { + ...data, + notifications: data.notifications.map((n) => { + if (n.id === id) { + return { ...n, status }; + } + return n; + }), + }; + + await mutate(newList); + } + }); + } + + return { + ready: true as const, + data: { + unreads, + notifications, + }, + handlers: { + handleMarkAs, + }, + }; +} + +const filterStatus = (s: NotificationStatus) => + filter((n) => n.status === s); + +const filterUnread = filterStatus("unread"); + +const mapToItems = map(mapToItem); + +function mapToItem(n: Notification): NotificationItem { + const content = getNotificationContent(n); + const createdAt = new Date(n.created_at); + const title = n.source?.handle ?? "System"; + const isRead = n.status === "read"; + + return { + id: n.id, + createdAt, + title, + description: content.description, + url: content.url, + isRead, + source: n.source, + item: n.item, + }; +} + +function getNotificationContent(n: Notification) { + switch (n.event) { + case "thread_reply": + return { description: "replied to your post", url: `/t/${n.item?.slug}` }; + case "post_like": + return { description: "liked your post", url: `/t/${n.item?.slug}` }; + case "profile_mention": + return { description: "mentioned you", url: `/t/${n.item?.slug}` }; + case "follow": + return { description: "followed you", url: `/m/${n.source?.handle}` }; + } +} diff --git a/web/src/components/site/Navigation/Actions/Notifications.tsx b/web/src/components/site/Navigation/Actions/Notifications.tsx new file mode 100644 index 000000000..461408919 --- /dev/null +++ b/web/src/components/site/Navigation/Actions/Notifications.tsx @@ -0,0 +1,38 @@ +import { BellIcon } from "@heroicons/react/24/outline"; + +import { IconButton } from "@/components/ui/icon-button"; +import { LinkButtonStyleProps } from "@/components/ui/link-button"; + +import { AnchorProps, MenuItem } from "../Anchors/Anchor"; + +export const NotificationsID = "notifications"; +export const NotificationsRoute = "/notifications"; +export const NotificationsLabel = "Notifications"; +export const NotificationsIcon = ; + +export function NotificationAction({ + hideLabel, + ...props +}: AnchorProps & LinkButtonStyleProps) { + return ( + + {NotificationsIcon} + {!hideLabel && ( + <> + {NotificationsLabel} + > + )} + + ); +} + +export function NotificationsMenuItem() { + return ( + + ); +} diff --git a/web/src/components/site/Navigation/components/Toolbar.tsx b/web/src/components/site/Navigation/components/Toolbar.tsx index 635bda6cd..54352e32b 100644 --- a/web/src/components/site/Navigation/components/Toolbar.tsx +++ b/web/src/components/site/Navigation/components/Toolbar.tsx @@ -7,6 +7,7 @@ import { } from "src/components/site/Navigation/Anchors/Login"; import { Account } from "@/api/openapi-schema"; +import { NotificationsMenu } from "@/components/notifications/NotificationsMenu"; import { HStack } from "@/styled-system/jsx"; import { AccountMenu } from "../AccountMenu/AccountMenu"; @@ -23,7 +24,7 @@ export function Toolbar({ session }: Props) { {account ? ( <> Post - + > ) : ( diff --git a/web/src/screens/notifications/NotificationScreen.tsx b/web/src/screens/notifications/NotificationScreen.tsx index ec05748f4..fb7903786 100644 --- a/web/src/screens/notifications/NotificationScreen.tsx +++ b/web/src/screens/notifications/NotificationScreen.tsx @@ -1,21 +1,93 @@ "use client"; -import { Mailbox } from "src/components/graphics/Mailbox"; +import { useQueryState } from "nuqs"; -import { Heading } from "@/components/ui/heading"; -import { Box } from "@/styled-system/jsx"; +import { + NotificationListResult, + NotificationStatus, +} from "@/api/openapi-schema"; +import { NotificationCardList } from "@/components/notifications/NotificationCardList"; +import { useNotifications } from "@/components/notifications/useNotifications"; +import { UnreadyBanner } from "@/components/site/Unready"; +import { Switch } from "@/components/ui/switch"; +import { HStack, LStack, styled } from "@/styled-system/jsx"; + +type Props = { + initialData: NotificationListResult; +}; + +export function useNotificationScreen(props: Props) { + const [status, setStatus] = useQueryState("status", { + defaultValue: "unread", + parse(v: string) { + switch (v) { + case "read": + return NotificationStatus.read; + default: + return NotificationStatus.unread; + } + }, + }); + const { ready, data, error, handlers } = useNotifications({ + initialData: props.initialData, + status, + }); + if (!ready) { + return { + ready: false as const, + error, + }; + } + + function handleToggleStatus() { + setStatus( + status === NotificationStatus.unread + ? NotificationStatus.read + : NotificationStatus.unread, + ); + } + + return { + ready: true as const, + data, + status, + handlers: { + handleToggleStatus, + handleMarkAs: handlers.handleMarkAs, + }, + }; +} + +export function NotificationScreen(props: Props) { + const { ready, error, data, status, handlers } = useNotificationScreen(props); + if (!ready) { + return ; + } + + const { notifications } = data; + + const showingArchived = status === NotificationStatus.read; -export function NotificationScreen() { return ( - - - - - - - Notifications - You have no notifications. - - + + + + Notifications + + + Archived + + + + + + ); }
You have no notifications.