Skip to content

Commit

Permalink
implement notifications UI
Browse files Browse the repository at this point in the history
  • Loading branch information
Southclaws committed Oct 8, 2024
1 parent f6db55e commit 27a3679
Show file tree
Hide file tree
Showing 8 changed files with 495 additions and 18 deletions.
14 changes: 11 additions & 3 deletions web/src/app/(dashboard)/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <NotificationScreen />;
export default async function Page() {
try {
const { data } = await notificationList();

return <NotificationScreen initialData={data} />;
} catch (e) {
return <Unready error={e} />;
}
}
107 changes: 107 additions & 0 deletions web/src/components/notifications/NotificationCardList.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
};

export function NotificationCardList({ notifications, onMove }: Props) {
if (notifications.length === 0) {
return (
<Center h="96" w="full" display="flex" flexDirection="column" gap="1">
<styled.p color="fg.muted">no notifications.</styled.p>
</Center>
);
}

return (
<CardRows>
{notifications.map((n) => {
const title = n.item?.description
? `${n.description} "${n.item?.description}"`
: n.description;

return (
<Card
key={n.id}
id={n.id}
shape="row"
title={timestamp(n.createdAt, false)}
text={title}
url={n.url}
controls={<StatusControl notification={n} onMove={onMove} />}
>
<NotificationSource {...n} />
</Card>
);
})}
</CardRows>
);
}

function NotificationSource(props: NotificationItem) {
if (props.source) {
return (
<MemberBadge profile={props.source} size="sm" name="full-horizontal" />
);
}

return (
<HStack>
<LStack gap="0">
<styled.span color="fg.subtle">system message</styled.span>
</LStack>
</HStack>
);
}

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 ? (
<Button
variant="ghost"
size="sm"
title="Mark as unread"
onClick={handleChangeStatus}
>
<InboxArrowDownIcon />
</Button>
) : (
<Button
variant="ghost"
size="sm"
title="Mark as read"
onClick={handleChangeStatus}
>
<ArchiveBoxIcon />
</Button>
);
}
127 changes: 127 additions & 0 deletions web/src/components/notifications/NotificationsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 <Unready error={error} />;
}

const { unreads, notifications } = data;

const isEmpty = notifications.length === 0;

return (
<Menu.Root closeOnSelect={false}>
<Menu.Trigger cursor="pointer" position="relative">
<NotificationAction hideLabel size="md" variant="ghost" />

{!isEmpty && (
<Box
position="absolute"
top="1"
right="1"
bgColor="red.8"
borderRadius="full"
w="2"
h="2"
/>
)}
</Menu.Trigger>

<Portal>
<Menu.Positioner>
<Menu.Content minW="48" userSelect="none">
<Menu.ItemGroup id="heading">
<Menu.ItemGroupLabel display="flex" gap="2" alignItems="center">
<LStack fontSize="sm">
<HStack w="full" justify="space-between">
<styled.p color="fg.muted">
Notifications ({unreads})
</styled.p>

<LinkButton
href="/notifications"
size="xs"
variant="outline"
>
see all
</LinkButton>
</HStack>
</LStack>
</Menu.ItemGroupLabel>

<Menu.Separator />

{isEmpty ? (
<Center w="full" py="4" color="fg.muted" fontSize="xs">
You&apos;re all caught up!
</Center>
) : (
notifications.map((notification) => (
<Menu.Item value={notification.id} height="auto" py="1">
<HStack w="full" justify="space-between">
<Link
className={hstack({
w: "full",
justify: "space-between",
})}
href={notification.url}
onClick={() =>
handlers.handleMarkAs(notification.id, "read")
}
>
<NotificationAvatar notification={notification} />
<LStack gap="0">
<styled.span fontWeight="bold">
{notification.source?.handle ?? "System"}
</styled.span>
<styled.span fontWeight="normal">
{notification.description}
</styled.span>
</LStack>
</Link>

<Button
variant="ghost"
size="sm"
title="Mark as read"
onClick={() =>
handlers.handleMarkAs(notification.id, "read")
}
>
<ArchiveBoxIcon />
</Button>
</HStack>
</Menu.Item>
))
)}
</Menu.ItemGroup>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}

export function NotificationAvatar(props: { notification: NotificationItem }) {
if (props.notification.source) {
return <MemberAvatar profile={props.notification.source} size="sm" />;
}

return <Cog6ToothIcon width="1rem" />;
}
12 changes: 12 additions & 0 deletions web/src/components/notifications/item.ts
Original file line number Diff line number Diff line change
@@ -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;
};
112 changes: 112 additions & 0 deletions web/src/components/notifications/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<Notification>((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}` };
}
}
Loading

0 comments on commit 27a3679

Please sign in to comment.