From f2e5fb479c4b1acc331190993ed8163307958c7d Mon Sep 17 00:00:00 2001 From: Pouria Delfanazari Date: Sun, 11 Feb 2024 19:26:51 -0800 Subject: [PATCH] Add ability to filter notifications --- src/app/dashboard/feeds/page.tsx | 1 - src/app/dashboard/notifications/loading.tsx | 4 +- .../notification/NotificationItem.tsx | 30 ++--- .../notification/NotificationSkeleton.tsx | 2 +- .../profileHeader/ProfileHeaderSkeleton.tsx | 2 +- .../FilteredNotificationsContainer.tsx | 124 ++++++++++++++++++ .../notifications/NotificationsContainer.tsx | 104 ++++----------- src/lib/consts/notification.ts | 14 ++ .../bsky/notification/useNotification.tsx | 9 +- src/lib/utils/icon.tsx | 20 ++- types/notification.ts | 7 + 11 files changed, 212 insertions(+), 105 deletions(-) create mode 100644 src/containers/notifications/FilteredNotificationsContainer.tsx create mode 100644 types/notification.ts diff --git a/src/app/dashboard/feeds/page.tsx b/src/app/dashboard/feeds/page.tsx index 39fa1a00..4ad41e9a 100644 --- a/src/app/dashboard/feeds/page.tsx +++ b/src/app/dashboard/feeds/page.tsx @@ -5,7 +5,6 @@ import SavedFeedListSkeleton from "@/components/contentDisplay/savedFeedList/Sav import Search from "@/components/filter/search/Search"; import Link from "next/link"; import { Suspense } from "react"; -import { BiCog } from "react-icons/bi"; import { FaSlidersH } from "react-icons/fa"; interface Props { diff --git a/src/app/dashboard/notifications/loading.tsx b/src/app/dashboard/notifications/loading.tsx index 333608a3..a1909913 100644 --- a/src/app/dashboard/notifications/loading.tsx +++ b/src/app/dashboard/notifications/loading.tsx @@ -1,12 +1,14 @@ import NotificationSkeleton from "@/components/contentDisplay/notification/NotificationSkeleton"; +import { TabsSkeleton } from "@/components/contentDisplay/profileHeader/ProfileHeaderSkeleton"; export default function Loading() { return (
-

+

Notifications

+
diff --git a/src/components/contentDisplay/notification/NotificationItem.tsx b/src/components/contentDisplay/notification/NotificationItem.tsx index e8ebe541..d056ba28 100644 --- a/src/components/contentDisplay/notification/NotificationItem.tsx +++ b/src/components/contentDisplay/notification/NotificationItem.tsx @@ -1,10 +1,11 @@ import { getNotificationLabel } from "@/lib/utils/text"; -import Avatar from "@/components/dataDisplay/avatar/Avatar"; -import { BiSolidHeart } from "react-icons/bi"; -import { BiRepost } from "react-icons/bi"; -import { BiSolidUserPlus } from "react-icons/bi"; import { getRelativeTime } from "@/lib/utils/time"; -import { GROUPABLE_NOTIFICATIONS } from "@/lib/consts/notification"; +import { getNotificationIcon } from "@/lib/utils/icon"; +import { + GROUPABLE_NOTIFICATIONS, + MAX_AUTHORS_SHOWN, +} from "@/lib/consts/notification"; +import Avatar from "@/components/dataDisplay/avatar/Avatar"; import Link from "next/link"; import { ContentFilterResult, @@ -26,25 +27,10 @@ const NotificationItem = memo(function NotificationItem(props: Props) { const subjectUri = notification.reasonSubject as AppBskyNotificationListNotifications.Notification["reasonSubject"]; - const MAX_AUTHORS_SHOWN = 6; - - const getNotificationIcon = (reason: string) => { - switch (reason) { - case "like": - return ; - case "repost": - return ; - case "follow": - return ; - default: - return null; - } - }; - if (GROUPABLE_NOTIFICATIONS.includes(reason)) { return (
@@ -114,7 +100,7 @@ const NotificationItem = memo(function NotificationItem(props: Props) { } else { return (
diff --git a/src/components/contentDisplay/notification/NotificationSkeleton.tsx b/src/components/contentDisplay/notification/NotificationSkeleton.tsx index 2ee07495..884cf38a 100644 --- a/src/components/contentDisplay/notification/NotificationSkeleton.tsx +++ b/src/components/contentDisplay/notification/NotificationSkeleton.tsx @@ -1,6 +1,6 @@ export function Skeleton() { return ( -
+
diff --git a/src/components/contentDisplay/profileHeader/ProfileHeaderSkeleton.tsx b/src/components/contentDisplay/profileHeader/ProfileHeaderSkeleton.tsx index a97f14e3..f407e359 100644 --- a/src/components/contentDisplay/profileHeader/ProfileHeaderSkeleton.tsx +++ b/src/components/contentDisplay/profileHeader/ProfileHeaderSkeleton.tsx @@ -9,7 +9,7 @@ function Skeleton() { ); } -function TabsSkeleton() { +export function TabsSkeleton() { return (
diff --git a/src/containers/notifications/FilteredNotificationsContainer.tsx b/src/containers/notifications/FilteredNotificationsContainer.tsx new file mode 100644 index 00000000..ff5d1a4f --- /dev/null +++ b/src/containers/notifications/FilteredNotificationsContainer.tsx @@ -0,0 +1,124 @@ +"use client"; + +import NotificationItem from "@/components/contentDisplay/notification/NotificationItem"; +import NotificationSkeleton from "@/components/contentDisplay/notification/NotificationSkeleton"; +import EndOfFeed from "@/components/feedback/endOfFeed/EndOfFeed"; +import FeedAlert from "@/components/feedback/feedAlert/FeedAlert"; +import LoadingSpinner from "@/components/status/loadingSpinner/LoadingSpinner"; +import usePreferences from "@/lib/hooks/bsky/actor/usePreferences"; +import useNotification from "@/lib/hooks/bsky/notification/useNotification"; +import { Fragment, useEffect, useState } from "react"; +import InfiniteScroll from "react-infinite-scroll-component"; + +interface Props { + filter: NotificationReason | "all"; +} + +export default function FilteredNotificationsContainer(props: Props) { + const { filter } = props; + const [isFetchingMore, setIsFetchingMore] = useState(false); + const { + notificationStatus, + notificationData, + notificationError, + isLoadingNotification, + isFetchingNotification, + fetchNotificationNextPage, + isFetchingNotificationNextPage, + notificationHasNextPage, + } = useNotification({ notificationType: filter }); + + // load next page if there are no filtered notifications on the current page + useEffect(() => { + if ( + notificationData && + notificationData.pages + .flatMap((page) => page?.data.notifications) + .filter((item) => (filter === "all" ? true : item.reason === filter)) + .length < 10 && + notificationHasNextPage + ) { + fetchNotificationNextPage(); + setIsFetchingMore(true); + } else { + setIsFetchingMore(false); + } + }, [ + fetchNotificationNextPage, + filter, + notificationData, + notificationHasNextPage, + ]); + + const { preferences } = usePreferences(); + const contentFilter = preferences?.contentFilter; + + const dataLength = notificationData?.pages.reduce( + (acc, page) => acc + (page?.data.notifications.length ?? 0), + 0, + ); + + const isEmpty = + !isFetchingNotification && + !isFetchingNotificationNextPage && + notificationData && + notificationData.pages + .flatMap((page) => page?.data.notifications) + .filter((item) => (filter === "all" ? true : item.reason === filter)) + .length === 0; + + return ( +
+ } + scrollThreshold={0.95} + className="no-scrollbar flex flex-col" + > + {notificationData && + contentFilter && + notificationData.pages + .flatMap((page) => page?.data.notifications) + .filter((item) => + filter === "all" ? true : item.reason === filter, + ) + .map((notification, i) => ( + + {notification && ( + + )} + + ))} + + + {isFetchingNotification && !isFetchingNotificationNextPage && ( + + )} + {notificationError && ( +
+ +
+ )} + {isFetchingMore && } + {isEmpty && !notificationHasNextPage && !isFetchingMore && ( +
+ +
+ )} + {!isEmpty && + !notificationError && + !isFetchingNotification && + !notificationHasNextPage && + !isFetchingNotificationNextPage && } +
+ ); +} diff --git a/src/containers/notifications/NotificationsContainer.tsx b/src/containers/notifications/NotificationsContainer.tsx index 253e3a79..9dbcdeb3 100644 --- a/src/containers/notifications/NotificationsContainer.tsx +++ b/src/containers/notifications/NotificationsContainer.tsx @@ -1,89 +1,41 @@ "use client"; -import NotificationItem from "@/components/contentDisplay/notification/NotificationItem"; -import NotificationSkeleton from "@/components/contentDisplay/notification/NotificationSkeleton"; -import EndOfFeed from "@/components/feedback/endOfFeed/EndOfFeed"; -import FeedAlert from "@/components/feedback/feedAlert/FeedAlert"; -import LoadingSpinner from "@/components/status/loadingSpinner/LoadingSpinner"; -import usePreferences from "@/lib/hooks/bsky/actor/usePreferences"; -import useNotification from "@/lib/hooks/bsky/notification/useNotification"; -import { Fragment } from "react"; -import InfiniteScroll from "react-infinite-scroll-component"; +import { useState } from "react"; +import FilteredNotificationsContainer from "./FilteredNotificationsContainer"; +import { NOTIFICATION_FILTER } from "@/lib/consts/notification"; export default function NotificationsContainer() { - const { - notificationStatus, - notificationData, - notificationError, - isLoadingNotification, - isFetchingNotification, - fetchNotificationNextPage, - isFetchingNotificationNextPage, - notificationHasNextPage, - } = useNotification(); - - const { preferences } = usePreferences(); - const contentFilter = preferences?.contentFilter; - - const dataLength = notificationData?.pages.reduce( - (acc, page) => acc + (page?.data.notifications.length ?? 0), - 0, + const [currentTab, setCurrentTab] = useState<"all" | NotificationReason>( + "all", ); - const isEmpty = - !isFetchingNotification && - !isFetchingNotificationNextPage && - dataLength === 0; + const handleTabChange = (tab: "all" | NotificationReason) => { + setCurrentTab(tab); + }; return (
- } - scrollThreshold={0.95} - className="no-scrollbar flex flex-col" +
- {notificationData && - contentFilter && - notificationData.pages - .flatMap((page) => page?.data.notifications) - .map((notification, i) => ( - - {notification && ( - - )} - - ))} - - - {isFetchingNotification && !isFetchingNotificationNextPage && ( - - )} - {notificationError && ( - - )} - {isEmpty && !notificationHasNextPage && ( - - )} - {!isEmpty && - !notificationError && - !isFetchingNotification && - !notificationHasNextPage && - !isFetchingNotificationNextPage && } + {NOTIFICATION_FILTER.map((type) => ( + + ))} +
+
); } diff --git a/src/lib/consts/notification.ts b/src/lib/consts/notification.ts index 568935a0..e00be68b 100644 --- a/src/lib/consts/notification.ts +++ b/src/lib/consts/notification.ts @@ -1 +1,15 @@ export const GROUPABLE_NOTIFICATIONS = ["like", "follow", "repost"]; + +export const MAX_AUTHORS_SHOWN = 6; + +export const NOTIFICATION_FILTER: { + label: string; + value: NotificationReason | "all"; +}[] = [ + { label: "All", value: "all" }, + { label: "Like", value: "like" }, + { label: "Follow", value: "follow" }, + { label: "Repost", value: "repost" }, + { label: "Quote", value: "quote" }, + { label: "Reply", value: "reply" }, +]; diff --git a/src/lib/hooks/bsky/notification/useNotification.tsx b/src/lib/hooks/bsky/notification/useNotification.tsx index 72cdf4fe..f4df383e 100644 --- a/src/lib/hooks/bsky/notification/useNotification.tsx +++ b/src/lib/hooks/bsky/notification/useNotification.tsx @@ -7,7 +7,12 @@ import { import { GroupedNotification } from "../../../../../types/feed"; import { Notification } from "@atproto/api/dist/client/types/app/bsky/notification/listNotifications"; -export default function useNotification() { +interface Props { + notificationType: NotificationReason | "all"; +} + +export default function useNotification(props: Props) { + const { notificationType } = props; const agent = useAgent(); const groupNotifications = ( notifications: Notification[], @@ -43,7 +48,7 @@ export default function useNotification() { fetchNextPage, hasNextPage, } = useInfiniteQuery({ - queryKey: ["notifications"], + queryKey: ["notifications", notificationType], queryFn: async ({ pageParam }) => { const res = await getNotifications(agent, pageParam); await updateSeenNotifications(agent); diff --git a/src/lib/utils/icon.tsx b/src/lib/utils/icon.tsx index 3278307f..edcd3b3f 100644 --- a/src/lib/utils/icon.tsx +++ b/src/lib/utils/icon.tsx @@ -2,7 +2,12 @@ import { FeedAlert } from "../../../types/feed"; import { HiMiniShieldExclamation } from "react-icons/hi2"; import { PiWarningCircleFill } from "react-icons/pi"; import { FaCircleCheck } from "react-icons/fa6"; -import { BiSolidErrorAlt } from "react-icons/bi"; +import { + BiRepost, + BiSolidErrorAlt, + BiSolidHeart, + BiSolidUserPlus, +} from "react-icons/bi"; import { IoCloudOffline } from "react-icons/io5"; export function getAlertIcon(variant: Alert) { @@ -38,3 +43,16 @@ export function getFeedAlertIcon(variant: FeedAlert) { return ; } } + +export const getNotificationIcon = (reason: string) => { + switch (reason) { + case "like": + return ; + case "repost": + return ; + case "follow": + return ; + default: + return null; + } +}; diff --git a/types/notification.ts b/types/notification.ts new file mode 100644 index 00000000..089b4992 --- /dev/null +++ b/types/notification.ts @@ -0,0 +1,7 @@ +type NotificationReason = + | "like" + | "follow" + | "repost" + | "quote" + | "reply" + | "mention";