Skip to content

Commit

Permalink
feat[NOTIFICATIONS]: Added Notification functionality
Browse files Browse the repository at this point in the history
Now users can get notifications based on other user activity
  • Loading branch information
parazeeknova committed Oct 20, 2024
1 parent b4675a9 commit d6b3385
Show file tree
Hide file tree
Showing 17 changed files with 694 additions and 265 deletions.
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"next-themes": "^0.3.0",
"react": "19.0.0-rc-459fd418-20241001",
"react-cropper": "^2.3.3",
"react-dom": "19.0.0-rc.0",
"react-dom": "19.0.0-rc-459fd418-20241001",
"react-hook-form": "^7.53.1",
"react-image-file-resizer": "^0.4.8",
"react-intersection-observer": "^9.13.1",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/app/(main)/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { prisma } from "@zephyr/db";
export default async function Navbar() {
const { user } = await validateRequest();

if (!user) return null;

const unreadNotificationCount = await prisma.notification.count({
where: {
recipientId: user.id,
read: false
}
});

let bookmarkCount = 0;
if (user) {
bookmarkCount = await prisma.bookmark.count({
Expand All @@ -14,7 +23,10 @@ export default async function Navbar() {

return (
<div className="sticky top-0 z-50">
<Header bookmarkCount={bookmarkCount} />
<Header
bookmarkCount={bookmarkCount}
unreadCount={unreadNotificationCount}
/>
</div>
);
}
60 changes: 60 additions & 0 deletions apps/web/src/app/(main)/notifications/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
import type { NotificationType } from "@prisma/client";
import UserAvatar from "@zephyr-ui/Layouts/UserAvatar";
import type { NotificationData } from "@zephyr/db";
import { Heart, MessageCircle, User2 } from "lucide-react";
import Link from "next/link";

interface NotificationProps {
notification: NotificationData;
}

export default function Notification({ notification }: NotificationProps) {
const notificationTypeMap: Record<
NotificationType,
{ message: string; icon: JSX.Element; href: string }
> = {
FOLLOW: {
message: `${notification.issuer.displayName} followed you`,
icon: <User2 className="size-7 text-primary" />,
href: `/users/${notification.issuer.username}`
},
COMMENT: {
message: `${notification.issuer.displayName} eddied on your post`,
icon: <MessageCircle className="size-7 fill-primary text-primary" />,
href: `/posts/${notification.postId}`
},
AMPLIFY: {
message: `${notification.issuer.displayName} amplified your post`,
icon: <Heart className="size-7 fill-red-500 text-red-500" />,
href: `/posts/${notification.postId}`
}
};

const { message, icon, href } = notificationTypeMap[notification.type];

return (
<Link href={href} className="block">
<article
className={cn(
"flex gap-3 rounded-2xl bg-card p-5 shadow-sm transition-colors hover:bg-card/70",
!notification.read && "bg-primary/10"
)}
>
<div className="my-1">{icon}</div>
<div className="space-y-3">
<UserAvatar avatarUrl={notification.issuer.avatarUrl} size={36} />
<div>
<span className="font-bold">{notification.issuer.displayName}</span>{" "}
<span>{message}</span>
</div>
{notification.post && (
<div className="line-clamp-3 whitespace-pre-line text-muted-foreground">
{notification.post.content}
</div>
)}
</div>
</article>
</Link>
);
}
88 changes: 88 additions & 0 deletions apps/web/src/app/(main)/notifications/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import kyInstance from "@/lib/ky";
import {
useInfiniteQuery,
useMutation,
useQueryClient
} from "@tanstack/react-query";
import InfiniteScrollContainer from "@zephyr-ui/Layouts/InfiniteScrollContainer";
import PostsLoadingSkeleton from "@zephyr-ui/Posts/PostsLoadingSkeleton";
import type { NotificationsPage } from "@zephyr/db";
import { Loader2 } from "lucide-react";
import { useEffect } from "react";
import Notification from "./Notification";

export default function Notifications() {
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status
} = useInfiniteQuery({
queryKey: ["notifications"],
queryFn: ({ pageParam }) =>
kyInstance
.get(
"/api/notifications",
pageParam ? { searchParams: { cursor: pageParam } } : {}
)
.json<NotificationsPage>(),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor
});

const queryClient = useQueryClient();

const { mutate } = useMutation({
mutationFn: () => kyInstance.patch("/api/notifications/mark-as-read"),
onSuccess: () => {
queryClient.setQueryData(["unread-notification-count"], {
unreadCount: 0
});
},
onError(error) {
console.error("Failed to mark notifications as read", error);
}
});

useEffect(() => {
mutate();
}, [mutate]);

const notifications = data?.pages.flatMap((page) => page.notifications) || [];

if (status === "pending") {
return <PostsLoadingSkeleton />;
}

if (status === "success" && !notifications.length && !hasNextPage) {
return (
<p className="text-center text-muted-foreground">
You don&apos;t have any rustles yet.
</p>
);
}

if (status === "error") {
return (
<p className="text-center text-destructive">
An error occurred while loading rustles.
</p>
);
}

return (
<InfiniteScrollContainer
className="space-y-5"
onBottomReached={() => hasNextPage && !isFetching && fetchNextPage()}
>
{notifications.map((notification) => (
<Notification key={notification.id} notification={notification} />
))}
{isFetchingNextPage && <Loader2 className="mx-auto my-3 animate-spin" />}
</InfiniteScrollContainer>
);
}
49 changes: 49 additions & 0 deletions apps/web/src/app/(main)/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import NavigationCard from "@/components/Home/sidebars/left/NavigationCard";
import SuggestedConnections from "@/components/Home/sidebars/right/SuggestedConnections";
import TrendingTopics from "@/components/Home/sidebars/right/TrendingTopics";
import StickyFooter from "@zephyr-ui/Layouts/StinkyFooter";
import type { Metadata } from "next";
import Notifications from "./Notifications";

export const metadata: Metadata = {
title: "Rustles"
};

export default async function Page() {
return (
<main className="flex w-full min-w-0 gap-5">
<aside className="sticky top-[5rem] ml-1 h-[calc(100vh-5.25rem)] w-64 flex-shrink-0 overflow-y-auto ">
<div className="mr-2">
<NavigationCard
isCollapsed={false}
className="h-[calc(100vh-4.5rem)]"
stickyTop="5rem"
/>
</div>
<div className="mt-2 mr-2">
<SuggestedConnections />
</div>
</aside>
<div className="mt-5 w-full min-w-0 space-y-5">
<Notifications />
</div>

<div className="sticky mt-5 hidden h-fit w-80 flex-none lg:block">
<div className="space-y-5 rounded-2xl border border-border bg-card p-5 shadow-sm">
<h2 className="font-bold text-xl">Rustle Info</h2>
<p className="text-muted-foreground">
Here you can view all your rustles aka notifications. You can also
mark them as read.
</p>
</div>
<div className="mt-2 mb-2">
<TrendingTopics />
</div>

<div className="mt-4">
<StickyFooter />
</div>
</div>
</main>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/app/api/notifications/mark-as-read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { validateRequest } from "@zephyr/auth/auth";
import { prisma } from "@zephyr/db";
export async function PATCH() {
try {
const { user } = await validateRequest();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
await prisma.notification.updateMany({
where: {
recipientId: user.id,
read: false
},
data: {
read: true
}
});
return new Response();
} catch (error) {
console.error(error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
36 changes: 36 additions & 0 deletions apps/web/src/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { validateRequest } from "@zephyr/auth/auth";
import {
type NotificationsPage,
notificationsInclude,
prisma
} from "@zephyr/db";
import type { NextRequest } from "next/server";
export async function GET(req: NextRequest) {
try {
const cursor = req.nextUrl.searchParams.get("cursor") || undefined;
const pageSize = 10;
const { user } = await validateRequest();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const notifications = await prisma.notification.findMany({
where: {
recipientId: user.id
},
include: notificationsInclude,
orderBy: { createdAt: "desc" },
take: pageSize + 1,
cursor: cursor ? { id: cursor } : undefined
});
const nextCursor =
notifications.length > pageSize ? notifications[pageSize].id : null;
const data: NotificationsPage = {
notifications: notifications.slice(0, pageSize),
nextCursor
};
return Response.json(data);
} catch (error) {
console.error(error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
23 changes: 23 additions & 0 deletions apps/web/src/app/api/notifications/unread-count/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { validateRequest } from "@zephyr/auth/auth";
import { prisma } from "@zephyr/db";
export async function PATCH() {
try {
const { user } = await validateRequest();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
await prisma.notification.updateMany({
where: {
recipientId: user.id,
read: false
},
data: {
read: true
}
});
return new Response();
} catch (error) {
console.error(error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
Loading

0 comments on commit d6b3385

Please sign in to comment.