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 (
+
+