-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f6db55e
commit 27a3679
Showing
8 changed files
with
495 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
107
web/src/components/notifications/NotificationCardList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'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" />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}` }; | ||
} | ||
} |
Oops, something went wrong.