Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a notifications page #1478

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions components/atoms/NotificationsCard/notification-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,14 @@ import Link from "next/link";
import React from "react";
import { FaRegSmile, FaUserCircle } from "react-icons/fa";

import { getNotificationURL } from "lib/utils/get-notification-url";

interface NotificationCard {
type: "highlight_reaction" | "follow" | "collaboration";
message: string;
id: string;
}

const getSourceURL = (type: string, id: string) => {
switch (type) {
case "highlight_reaction":
return `/feed/${id}`;
case "follow":
return `/user/${id}`;
}
};

const NotificationCard = ({ type, message, id }: NotificationCard) => {
const Icons = {
highlight_reaction: FaRegSmile,
Expand All @@ -25,7 +18,7 @@ const NotificationCard = ({ type, message, id }: NotificationCard) => {
};

const Icon = Icons[type];
const linkURL = getSourceURL(type, id);
const linkURL = getNotificationURL(type, id);

return (
<div className="flex items-center gap-3 p-2 transition cursor-pointer rounded-xl group hover:text-sauced-orange hover:bg-sauced-light">
Expand Down
70 changes: 8 additions & 62 deletions components/molecules/AuthSection/auth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import Button from "components/atoms/Button/button";
import Text from "components/atoms/Typography/text";
import GitHubIcon from "img/icons/github-icon.svg";
import Icon from "components/atoms/Icon/icon";
import NotificationCard from "components/atoms/NotificationsCard/notification-card";
import { Spinner } from "components/atoms/SpinLoader/spin-loader";
import { Popover, PopoverContent, PopoverTrigger } from "../Popover/popover";
import DropdownList from "../DropdownList/dropdown-list";
import OnboardingButton from "../OnboardingButton/onboarding-button";
import userAvatar from "../../../img/ellipse-1.png";
Expand All @@ -30,38 +27,17 @@ const AuthSection: React.FC = ({}) => {
const router = useRouter();
const currentPath = router.asPath;

const { signIn, signOut, user, sessionToken } = useSupabaseAuth();
const { signIn, signOut, user } = useSupabaseAuth();
const { onboarded, session } = useSession(true);
const [notifications, setNotifications] = useState<DbUserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [userInfo, setUserInfo] = useState<DbUser | undefined>(undefined);
const [host, setHost] = useState<string>("");

useEffect(() => {
if (typeof window !== "undefined") {
setHost(window.location.origin as string);
}
}, []);

// Fetch user notifications
const fetchNotifications = async () => {
if (userInfo && userInfo.notification_count > 0) {
setLoading(true);
const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/notifications`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});
setLoading(false);
if (req.ok) {
const notifications = await req.json();
setNotifications(notifications.data as DbUserNotification[]);
}
} else {
return;
}
};

useEffect(() => {
if (session && !userInfo) {
setUserInfo(session);
Expand Down Expand Up @@ -128,42 +104,12 @@ const AuthSection: React.FC = ({}) => {
) : (
""
)}
<Popover
onOpenChange={(state) => {
// reset the notification state to empty when the popover is closed
if (!loading && !state) setUserInfo(undefined);
}}
>
<PopoverTrigger onClick={async () => await fetchNotifications()} asChild>
<button className="relative cursor-pointer">
{userInfo && userInfo.notification_count > 0 && (
<span className="absolute right-0 block w-2 h-2 bg-orange-300 rounded-full"></span>
)}
<IoNotifications className="text-xl text-light-slate-9" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="bg-white !rounded-xl p-1 ">
{loading ? (
<div className="flex items-center justify-center py-2">
<Spinner />
</div>
) : (
<>
{notifications.length > 0 ? (
<div className="space-y-1">
{notifications.map(({ type, message, id, meta_id }) => (
<NotificationCard key={id} message={message} type={type} id={meta_id} />
))}
</div>
) : (
<div className="px-3 py-2 text-sm text-center text-light-slate-9">
You don&apos;t have any unread notifications
</div>
)}
</>
)}
</PopoverContent>
</Popover>
<button className="relative cursor-pointer" onClick={() => router.push(`/user/notifications`)}>
{userInfo && userInfo.notification_count > 0 && (
<span className="absolute right-0 block w-2 h-2 bg-orange-300 rounded-full"></span>
)}
<IoNotifications className="text-xl text-light-slate-9" />
</button>

<DropdownList menuContent={authMenu.authed}>
<div className="flex justify-end min-w-[60px] gap-2">
Expand Down
16 changes: 16 additions & 0 deletions lib/utils/get-notification-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Returns the URL for a notification based on its type and ID or username.
* @param type - The type of the notification.
* @param id - The ID or username associated with the notification.
* @returns The URL for the notification.
*/
export const getNotificationURL = (type: string, id: string | number) => {
switch (type) {
case "highlight_reaction":
return `/feed/${id}`;
case "follow":
return `/user/${id}`;
default:
return "/";
}
};
215 changes: 215 additions & 0 deletions pages/user/notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/router";
import clsx from "clsx";
import Link from "next/link";

import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
import useSupabaseAuth from "lib/hooks/useSupabaseAuth";
import Title from "components/atoms/Typography/title";
import Button from "components/atoms/Button/button";
import SkeletonWrapper from "components/atoms/SkeletonLoader/skeleton-wrapper";
import PaginationResults from "components/molecules/PaginationResults/pagination-result";
import Pagination from "components/molecules/Pagination/pagination";
import DashContainer from "components/atoms/DashedContainer/DashContainer";
import Avatar from "components/atoms/Avatar/avatar";
import { getAvatarByUsername } from "lib/utils/github";
import { getNotificationURL } from "lib/utils/get-notification-url";
import changeCapitalization from "lib/utils/change-capitalization";
import ProfileLayout from "layouts/profile";
import { WithPageLayout } from "interfaces/with-page-layout";
takanome-dev marked this conversation as resolved.
Show resolved Hide resolved

interface NotificationResponse {
data: DbUserNotification[];
meta: Meta;
}

const Notifications: WithPageLayout = () => {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [notificationsResponse, setNotificationsResponse] = useState<NotificationResponse>();
const [filter, setFilter] = useState<"all" | "follow" | "highlight_reaction">("all");

const topRef = useRef<HTMLDivElement>(null);
const { sessionToken, user } = useSupabaseAuth(true);

const router = useRouter();
const username = user?.user_metadata.user_name as string;

const fetchNotifications = async (page = 1) => {
if (!sessionToken) return;
setLoading(true);
const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/user/notifications?limit=10&page=${page}`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${sessionToken}`,
},
});
const notifications = await req.json();
setNotificationsResponse(notifications);
setLoading(false);
};

const notifications = useMemo(() => {
if (!notificationsResponse?.data) return [];
if (filter === "all")
return notificationsResponse?.data?.sort(
(a, b) => new Date(b.notified_at).getTime() - new Date(a.notified_at).getTime()
);
return notificationsResponse?.data
.filter((notification) => notification.type === filter)
?.sort((a, b) => new Date(b.notified_at).getTime() - new Date(a.notified_at).getTime());
}, [notificationsResponse?.data, filter]);

useEffect(() => {
fetchNotifications();

if (sessionToken) {
router.push(`/user/notifications?filter=all`);
}
}, [sessionToken]);

const handlePageChange = (page: number) => {
setPage(page);
fetchNotifications(page);
if (topRef.current) {
topRef.current.scrollIntoView({
behavior: "smooth",
});
}
};

return (
<div className="w-full ">
<div
className="container flex flex-col justify-between w-full px-2 pt-16 mx-auto overflow-hidden md:px-16 md:flex-row md:gap-20 lg:gap-40"
ref={topRef}
>
<div className="flex flex-col gap-4 w-80">
<Title className="!text-2xl !text-light-slate-12" level={3}>
Notifications
</Title>
<aside className="flex flex-col gap-2">
<Button
variant="link"
className={clsx("hover:text-orange-600", filter === "all" ? "text-orange-600" : "text-light-slate-11")}
onClick={() => {
setFilter("all");
router.push(`/user/notifications?filter=all`);
}}
>
All
</Button>
<Button
variant="link"
className={clsx("hover:text-orange-600", filter === "follow" ? "text-orange-600" : "text-light-slate-11")}
onClick={() => {
setFilter("follow");
router.push(`/user/notifications?filter=follow`);
}}
>
Follows
</Button>
<Button
variant="link"
className={clsx(
"hover:text-orange-600",
filter === "highlight_reaction" ? "text-orange-600" : "text-light-slate-11"
)}
onClick={() => {
setFilter("highlight_reaction");
router.push(`/user/notifications?filter=highlight_reaction`);
}}
>
Reactions
</Button>
</aside>
</div>
<div className="flex-1 mt-10 md:mt-0">
{loading ? (
<div className="flex flex-col gap-4">
{Array.from({ length: 8 }).map((_, index) => (
<div className="flex gap-2" key={index}>
<SkeletonWrapper width={50} height={50} />
<div className="">
<SkeletonWrapper height={20} width={500} classNames="mb-2" />
<SkeletonWrapper height={10} width={100} />
</div>
</div>
))}
</div>
) : notifications?.length <= 0 && !loading ? (
<DashContainer>
<div className="text-center">
<p>
You don&apos;t have any{" "}
{changeCapitalization(filter === "highlight_reaction" ? "reaction" : filter, true)} notifications yet!{" "}
<br />
</p>
</div>
</DashContainer>
) : (
<div className="flex flex-col gap-2 mb-10">
{notifications?.map((notification) => (
<div className="p-2 border bg-light-slate-2 rounded-lg flex items-center gap-4" key={notification.id}>
<Avatar
initialsClassName="text-[100px] leading-none"
initials={notification.meta_id.charAt(0)}
hasBorder
avatarURL={getAvatarByUsername(notification.meta_id, 300)}
size={50}
isCircle
/>
<div className="flex flex-col gap-2">
<p className="text-light-slate-12 flex gap-2">
<Link href={getNotificationURL(notification.type, notification.meta_id)} className="font-bold">
{notification.meta_id}
</Link>
<span>{notification.message.replace(notification.meta_id, " ")}</span>
</p>
<span className="text-xs font-normal text-light-slate-11">
{formatDistanceToNowStrict(new Date(notification.notified_at), { addSuffix: true })}
</span>
</div>
</div>
))}
</div>
)}
{notifications?.length > 0 && (notificationsResponse?.meta?.pageCount ?? 0) > 1 && (
<div className="my-10 flex px-2 items-center justify-between">
<div className="flex items-center w-max gap-x-4">
<PaginationResults
metaInfo={
notificationsResponse?.meta ?? {
itemCount: 0,
limit: 0,
page: 0,
hasNextPage: false,
hasPreviousPage: false,
pageCount: 0,
}
}
total={notificationsResponse?.meta?.itemCount ?? 0}
entity={"notifications"}
/>
</div>
<Pagination
pages={[]}
totalPage={notificationsResponse?.meta?.pageCount ?? 0}
page={notificationsResponse?.meta?.page ?? 0}
pageSize={notificationsResponse?.meta?.itemCount ?? 0}
goToPage
hasNextPage={notificationsResponse?.meta?.hasNextPage ?? false}
hasPreviousPage={notificationsResponse?.meta?.hasPreviousPage ?? false}
onPageChange={(pageNumber) => handlePageChange(pageNumber)}
/>
</div>
)}
</div>
</div>
</div>
);
};

Notifications.PageLayout = ProfileLayout;
Notifications.isPrivateRoute = true;
takanome-dev marked this conversation as resolved.
Show resolved Hide resolved
export default Notifications;
24 changes: 24 additions & 0 deletions tests/lib/utils/get-notifications-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getNotificationURL } from "lib/utils/get-notification-url";

describe("[lib] getNotificationURL()", () => {
it("should return the correct URL for a highlight reaction notification", () => {
const type = "highlight_reaction";
const idOrUsername = 30;
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/feed/30");
});

it("should return the correct URL for a follow notification", () => {
const type = "follow";
const idOrUsername = "takanome-dev";
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/user/takanome-dev");
});

it("should return the correct URL for an unknown notification", () => {
const type = "unknown";
const idOrUsername = "unknown";
const result = getNotificationURL(type, idOrUsername);
expect(result).toBe("/");
});
});