diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 5b8f647..f2fe72d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as constants from "../constants.js"; import type * as files from "../files.js"; import type * as follows from "../follows.js"; import type * as http from "../http.js"; +import type * as notification from "../notification.js"; import type * as stripe from "../stripe.js"; import type * as thumbnails from "../thumbnails.js"; import type * as users from "../users.js"; @@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{ files: typeof files; follows: typeof follows; http: typeof http; + notification: typeof notification; stripe: typeof stripe; thumbnails: typeof thumbnails; users: typeof users; diff --git a/convex/notification.ts b/convex/notification.ts new file mode 100644 index 0000000..06c5614 --- /dev/null +++ b/convex/notification.ts @@ -0,0 +1,59 @@ +import { getProfile } from "./users"; +import { authMutation, authQuery } from "./util"; + +export const markAllRead = authMutation({ + args: {}, + handler: async (ctx) => { + const unreadNotifications = await ctx.db + .query("notifications") + .withIndex("by_userId", (q) => q.eq("userId", ctx.user._id)) + .filter((q) => q.eq(q.field("isRead"), false)) + .collect(); + + await Promise.all( + unreadNotifications.map(async (notification) => { + return await ctx.db.patch(notification._id, { + isRead: true, + }); + }) + ); + }, +}); + +export const hasUnread = authQuery({ + args: {}, + handler: async (ctx) => { + if (!ctx.user) return false; + + const unreadNotifications = await ctx.db + .query("notifications") + .withIndex("by_userId", (q) => q.eq("userId", ctx.user._id)) + .filter((q) => q.eq(q.field("isRead"), false)) + .collect(); + + return unreadNotifications.length > 0; + }, +}); + +export const getNotifications = authQuery({ + args: {}, + handler: async (ctx) => { + if (!ctx.user) return []; + + const notifications = await ctx.db + .query("notifications") + .filter((q) => q.eq(q.field("userId"), ctx.user._id)) + .order("desc") + .collect(); + + return Promise.all( + notifications.map(async (notification) => { + return { + ...notification, + thumbnail: await ctx.db.get(notification.thumbnailId), + profile: await getProfile(ctx, { userId: notification.userId }), + }; + }) + ); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 43b938f..7f87ce7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -22,7 +22,9 @@ export default defineSchema({ follows: defineTable({ userId: v.id("users"), targetUserId: v.id("users"), - }).index("by_userId_targetUserId", ["userId", "targetUserId"]), + }) + .index("by_userId_targetUserId", ["userId", "targetUserId"]) + .index("by_targetUserId", ["targetUserId"]), users: defineTable({ userId: v.string(), email: v.string(), @@ -36,4 +38,15 @@ export default defineSchema({ }) .index("by_userId", ["userId"]) .index("by_subscriptionId", ["subscriptionId"]), + notifications: defineTable({ + userId: v.id("users"), + thumbnailId: v.id("thumbnails"), + isRead: v.boolean(), + type: v.union( + v.literal("thumbnail"), + v.literal("vote"), + v.literal("comment") + ), + from: v.id("users"), + }).index("by_userId", ["userId"]), }); diff --git a/convex/thumbnails.ts b/convex/thumbnails.ts index 01150df..3c01648 100644 --- a/convex/thumbnails.ts +++ b/convex/thumbnails.ts @@ -1,5 +1,5 @@ import { ConvexError, v } from "convex/values"; -import { action, internalMutation, query } from "./_generated/server"; +import { internalMutation, query } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { adminAuthMutation, authAction, authMutation, authQuery } from "./util"; import { internal } from "./_generated/api"; @@ -29,6 +29,23 @@ export const createThumbnail = internalMutation({ name: user.name, }); + const following = await ctx.db + .query("follows") + .withIndex("by_targetUserId", (q) => q.eq("targetUserId", user._id)) + .collect(); + + await Promise.all( + following.map(async (followingUser) => { + return await ctx.db.insert("notifications", { + userId: followingUser.userId, + from: user._id, + isRead: false, + thumbnailId: id, + type: "thumbnail", + }); + }) + ); + return id; }, }); @@ -87,6 +104,14 @@ export const addComment = authMutation({ name: ctx.user.name ?? "Annoymous", profileUrl: ctx.user.profileImage ?? "", }); + + await ctx.db.insert("notifications", { + from: ctx.user._id, + isRead: false, + thumbnailId: args.thumbnailId, + type: "comment", + userId: thumbnail.userId, + }); }, }); @@ -172,6 +197,14 @@ export const voteOnThumbnail = authMutation({ thumbnail.votes[voteIdx]++; thumbnail.voteIds.push(ctx.user._id); + await ctx.db.insert("notifications", { + from: ctx.user._id, + isRead: false, + thumbnailId: args.thumbnailId, + type: "vote", + userId: thumbnail.userId, + }); + await ctx.db.patch(thumbnail._id, thumbnail); }, }); diff --git a/src/app/header.tsx b/src/app/header.tsx index 8e7d29a..c0d613d 100644 --- a/src/app/header.tsx +++ b/src/app/header.tsx @@ -6,6 +6,22 @@ import Link from "next/link"; import { useSession } from "@/lib/utils"; import MobileNav, { MenuToggle, useMobileNavState } from "./mobile-nav"; import Image from "next/image"; +import { BellIcon } from "lucide-react"; +import { useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; + +const NotificationIcon = () => { + const hasUnread = useQuery(api.notification.hasUnread); + + return ( + + + {hasUnread && ( +
+ )} + + ); +}; export function Header() { const { isLoading, isAuthenticated } = useSession(); @@ -52,11 +68,14 @@ export function Header() { )} +
{!isLoading && ( <> {isAuthenticated && ( <> + + )} diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx new file mode 100644 index 0000000..1d9e028 --- /dev/null +++ b/src/app/notifications/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useMutation, useQuery } from "convex/react"; +import { api } from "../../../convex/_generated/api"; +import { Button } from "@/components/ui/button"; +import Image from "next/image"; +import { useSession } from "@/lib/utils"; +import Link from "next/link"; +import { SkeletonCard } from "@/components/skeleton-card"; +import { PictureInPictureIcon, SpeechIcon } from "lucide-react"; +import { Id } from "../../../convex/_generated/dataModel"; +import { ReactNode, useEffect } from "react"; +import { timeFrom } from "@/util/time-from"; + +function Notification({ + notification, + title, + description, + icon, +}: { + notification: { + _creationTime: number; + _id: Id<"notifications">; + from: Id<"users">; + thumbnailId: Id<"thumbnails">; + profile: { + name: string | undefined; + }; + }; + title: string; + description: string; + icon: ReactNode; +}) { + return ( +
+ {icon} + +
+
{title}
+
{timeFrom(notification._creationTime)}
+
+ + {notification.profile.name}{" "} + + {description} +
+
+ + +
+ ); +} + +export default function NotificationsPage() { + const { isAuthenticated } = useSession(); + + const notifications = useQuery( + api.notification.getNotifications, + !isAuthenticated ? "skip" : undefined + ); + + const markAllRead = useMutation(api.notification.markAllRead); + + useEffect(() => { + markAllRead(); + }, [markAllRead]); + + return ( +
+

Notifications

+ + {notifications === undefined && ( +
+ + + +
+ )} + + {notifications && notifications.length === 0 && ( +
+ no found icon +
You have no notifications
+
+ )} + +
+ {notifications?.map((notification) => { + if (notification.type === "thumbnail") { + return ( + } + title="New Thumbnail" + notification={notification} + /> + ); + } else if (notification.type === "comment") { + return ( + } + title="New Comment" + notification={notification} + /> + ); + } else { + return ( + } + title="New Vote" + notification={notification} + /> + ); + } + })} +
+
+ ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/util/time-from.ts b/src/util/time-from.ts new file mode 100644 index 0000000..9d56a62 --- /dev/null +++ b/src/util/time-from.ts @@ -0,0 +1,7 @@ +import { formatDistance } from "date-fns"; + +export function timeFrom(datetime: number) { + return formatDistance(new Date(datetime), new Date(), { + addSuffix: true, + }); +}