Skip to content

Commit

Permalink
notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
webdevcody committed Feb 5, 2024
1 parent 7db2817 commit 8a7826c
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 2 deletions.
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions convex/notification.ts
Original file line number Diff line number Diff line change
@@ -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 }),
};
})
);
},
});
15 changes: 14 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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"]),
});
35 changes: 34 additions & 1 deletion convex/thumbnails.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
},
});
Expand Down Expand Up @@ -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,
});
},
});

Expand Down Expand Up @@ -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);
},
});
Expand Down
19 changes: 19 additions & 0 deletions src/app/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link href="/notifications" className="relative">
<BellIcon />
{hasUnread && (
<div className="absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full"></div>
)}
</Link>
);
};

export function Header() {
const { isLoading, isAuthenticated } = useSession();
Expand Down Expand Up @@ -52,11 +68,14 @@ export function Header() {
</>
)}
</div>

<div className="flex gap-4 items-center">
{!isLoading && (
<>
{isAuthenticated && (
<>
<NotificationIcon />

<UserButton />
</>
)}
Expand Down
135 changes: 135 additions & 0 deletions src/app/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex items-center gap-4 border p-4 rounded"
key={notification._id}
>
{icon}

<div>
<div className="font-bold mb-2">{title}</div>
<div className="mb-2">{timeFrom(notification._creationTime)}</div>
<div>
<Link href={`/profile/${notification.from}`}>
{notification.profile.name}{" "}
</Link>
{description}
</div>
</div>

<Button asChild className="ml-auto">
<Link href={`/thumbnails/${notification.thumbnailId}`}>View</Link>
</Button>
</div>
);
}

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 (
<div className="">
<h1 className="text-center text-4xl font-bold mb-12">Notifications</h1>

{notifications === undefined && (
<div className="animate-pulse mb-12 mt-12 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}

{notifications && notifications.length === 0 && (
<div className="flex flex-col items-center gap-8">
<Image
className="rounded-lg bg-white p-12"
src="/void.svg"
alt="no found icon"
width="400"
height="400"
/>
<div className="text-2xl font-bold">You have no notifications</div>
</div>
)}

<div className="flex flex-col gap-8 max-w-xl mx-auto">
{notifications?.map((notification) => {
if (notification.type === "thumbnail") {
return (
<Notification
key={notification._id}
description=" uploaded a new thumbnail test!"
icon={<PictureInPictureIcon className="h-14 w-14" />}
title="New Thumbnail"
notification={notification}
/>
);
} else if (notification.type === "comment") {
return (
<Notification
key={notification._id}
description=" left a comment on your thumbnail."
icon={<SpeechIcon className="h-14 w-14" />}
title="New Comment"
notification={notification}
/>
);
} else {
return (
<Notification
key={notification._id}
description=" voted for one of your thumbnail images."
icon={<PictureInPictureIcon className="h-14 w-14" />}
title="New Vote"
notification={notification}
/>
);
}
})}
</div>
</div>
);
}
59 changes: 59 additions & 0 deletions src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"

const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"

const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"

export { Alert, AlertTitle, AlertDescription }
Loading

0 comments on commit 8a7826c

Please sign in to comment.