diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 0abb976b3b..e33505cc69 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -54,6 +54,7 @@ export async function newComment(data: { videoId, authorId: user.id, comment: { id, content }, + parentCommentId, }); } catch (error) { console.error("Failed to create notification:", error); diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx index 585a463ca7..4c121fe61b 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/NotificationItem.tsx @@ -29,7 +29,7 @@ export const NotificationItem = ({ @@ -62,12 +62,12 @@ export const NotificationItem = ({ - {notification.type === "comment" || - (notification.type === "reply" && ( -

+ {(notification.type === "comment" || + notification.type === "reply") && ( +

{notification.comment.content}

- ))} + )}

{moment(notification.createdAt).fromNow()}

@@ -94,8 +94,9 @@ export const NotificationItem = ({ function getLink(notification: APINotification) { switch (notification.type) { - case "comment": case "reply": + return `/s/${notification.videoId}/?reply=${notification.comment.id}` + case "comment": case "reaction": // case "mention": return `/s/${notification.videoId}/?comment=${notification.comment.id}`; diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/index.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/index.tsx index faaca4509e..c77048b6df 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/index.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/index.tsx @@ -77,13 +77,13 @@ const Notifications = forwardRef( return ( e.stopPropagation()} className={clsx( - "flex absolute right-0 top-12 flex-col rounded-xl cursor-default w-[400px] h-[450px] bg-gray-1 origin-top-right", + "flex absolute right-0 top-12 flex-col rounded-xl origin-top-right cursor-default w-[400px] h-[450px] bg-gray-1", className )} {...props} @@ -97,7 +97,7 @@ const Notifications = forwardRef( />
{notifications.isPending ? ( diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx index 67212ec2d8..4c1dfaf03f 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx @@ -38,6 +38,7 @@ const Comment: React.FC<{ const isReplying = replyingToId === comment.id; const isOwnComment = user?.id === comment.authorId; const commentParams = useSearchParams().get("comment"); + const replyParams = useSearchParams().get("reply"); const nestedReplies = level === 0 ? replies.filter((reply) => { @@ -71,9 +72,9 @@ const Comment: React.FC<{ once: true, }} whileInView={{ - scale: commentParams === comment.id ? [1, 1.08, 1] : 1, - borderColor: commentParams === comment.id ? ["#EEEEEE", "#1696e0"] : "#EEEEEE", - backgroundColor: commentParams === comment.id ? ["#F9F9F9", "#EDF6FF"] : " #F9F9F9", + scale: (commentParams || replyParams) === comment.id ? [1, 1.08, 1] : 1, + borderColor: (commentParams || replyParams) === comment.id ? ["#EEEEEE", "#1696e0"] : "#EEEEEE", + backgroundColor: (commentParams || replyParams) === comment.id ? ["#F9F9F9", "#EDF6FF"] : " #F9F9F9", }} transition={{ duration: 0.75, ease: "easeInOut", delay: 0.15 }} className={"flex-1 p-3 rounded-xl border border-gray-3 bg-gray-2"}> diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx index 2dafb1a9a2..1d48920195 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx @@ -29,6 +29,7 @@ export const Comments = Object.assign( const { optimisticComments, setOptimisticComments, setComments, handleCommentSuccess } = props; const commentParams = useSearchParams().get("comment"); + const replyParams = useSearchParams().get("reply"); const { user } = props; const [replyingTo, setReplyingTo] = useState(null); @@ -36,7 +37,7 @@ export const Comments = Object.assign( const commentsContainerRef = useRef(null); useEffect(() => { - if (commentParams) return; + if (commentParams || replyParams) return; if (commentsContainerRef.current) { commentsContainerRef.current.scrollTop = commentsContainerRef.current.scrollHeight; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 69bc511474..7233600378 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -1,5 +1,5 @@ import { db } from "@cap/database"; -import { eq, InferSelectModel, sql, desc } from "drizzle-orm"; +import { eq, InferSelectModel, sql } from "drizzle-orm"; import { Logo } from "@cap/ui"; import { @@ -367,6 +367,7 @@ async function AuthorizedContent({ const videoId = video.id; const userId = user?.id; const commentId = searchParams.comment as string | undefined; + const replyId = searchParams.reply as string | undefined; // Fetch spaces data for the sharing dialog let spacesData = null; @@ -597,6 +598,21 @@ async function AuthorizedContent({ : Promise.resolve([]); const commentsPromise = (async () => { + + let toplLevelCommentId: string | undefined; + + if (replyId) { + const [parentComment] = await db() + .select({ parentCommentId: comments.parentCommentId }) + .from(comments) + .where(eq(comments.id, replyId)) + .limit(1); + toplLevelCommentId = parentComment?.parentCommentId; + } + + const commentToBringToTheTop = toplLevelCommentId ?? commentId; + + const allComments = await db() .select({ id: comments.id, @@ -614,9 +630,9 @@ async function AuthorizedContent({ .leftJoin(users, eq(comments.authorId, users.id)) .where(eq(comments.videoId, videoId)) .orderBy( - commentId - ? sql`CASE WHEN ${comments.id} = ${commentId} THEN 0 ELSE 1 END, ${comments.createdAt} DESC` - : desc(comments.createdAt) + commentToBringToTheTop + ? sql`CASE WHEN ${comments.id} = ${commentToBringToTheTop} THEN 0 ELSE 1 END, ${comments.createdAt}` + : comments.createdAt ); return allComments; diff --git a/apps/web/lib/Notification.ts b/apps/web/lib/Notification.ts index b0b933f408..b3a0d5a696 100644 --- a/apps/web/lib/Notification.ts +++ b/apps/web/lib/Notification.ts @@ -1,7 +1,7 @@ // Ideally all the Notification-related types would be in @cap/web-domain // but @cap/web-api-contract is the closest we have right now -import { notifications, videos, users } from "@cap/database/schema"; +import { notifications, videos, users, comments } from "@cap/database/schema"; import { db } from "@cap/database"; import { and, eq, sql } from "drizzle-orm"; import { nanoId } from "@cap/database/helpers"; @@ -24,7 +24,7 @@ type CreateNotificationInput = D extends NotificationSpecificData ? D["author"] extends never ? D - : Omit & { authorId: string } + : Omit & { authorId: string } & { parentCommentId?: string } : never; export async function createNotification( @@ -48,7 +48,69 @@ export async function createNotification( throw new Error("Video or owner not found"); } + const { type, ...data } = notification; + + // Handle replies: notify the parent comment's author + if (type === "reply" && notification.parentCommentId) { + const [parentComment] = await db() + .select({ authorId: comments.authorId }) + .from(comments) + .where(eq(comments.id, notification.parentCommentId)) + .limit(1); + + const recipientId = parentComment?.authorId; + if (!recipientId) return; + if (recipientId === videoResult.ownerId) return; + + const [recipientUser] = await db() + .select({ + preferences: users.preferences, + activeOrganizationId: users.activeOrganizationId, + }) + .from(users) + .where(eq(users.id, recipientId)) + .limit(1); + + if (!recipientUser) { + console.warn(`Reply recipient user ${recipientId} not found`); + return; + } + + const recipientPrefs = recipientUser.preferences as + | UserPreferences + | undefined; + if (recipientPrefs?.notifications?.pauseReplies) return; + + const [existingReply] = await db() + .select({ id: notifications.id }) + .from(notifications) + .where( + and( + eq(notifications.type, "reply"), + eq(notifications.recipientId, recipientId), + sql`JSON_EXTRACT(${notifications.data}, '$.comment.id') = ${notification.comment.id}` + ) + ) + .limit(1); + + if (existingReply) return; + + const notificationId = nanoId(); + + await db().insert(notifications).values({ + id: notificationId, + orgId: recipientUser.activeOrganizationId, + recipientId, + type, + data, + }); + + revalidatePath("/dashboard"); + return { success: true, notificationId }; + } + // Skip notification if the video owner is the current user + // (this only applies to non-reply types) if (videoResult.ownerId === notification.authorId) { return; } @@ -59,10 +121,9 @@ export async function createNotification( const notificationPrefs = preferences.notifications; const shouldSkipNotification = - (notification.type === "comment" && notificationPrefs.pauseComments) || - (notification.type === "view" && notificationPrefs.pauseViews) || - (notification.type === "reply" && notificationPrefs.pauseReplies) || - (notification.type === "reaction" && notificationPrefs.pauseReactions); + (type === "comment" && notificationPrefs.pauseComments) || + (type === "view" && notificationPrefs.pauseViews) || + (type === "reaction" && notificationPrefs.pauseReactions); if (shouldSkipNotification) { return; @@ -71,7 +132,7 @@ export async function createNotification( // Check for existing notification to prevent duplicates let hasExistingNotification = false; - if (notification.type === "view") { + if (type === "view") { const [existingNotification] = await db() .select({ id: notifications.id }) .from(notifications) @@ -86,18 +147,13 @@ export async function createNotification( .limit(1); hasExistingNotification = !!existingNotification; - } else if ( - notification.type === "comment" || - notification.type === "reaction" || - notification.type === "reply" - ) { - // Check for existing comment notification + } else if (type === "comment" || type === "reaction") { const [existingNotification] = await db() .select({ id: notifications.id }) .from(notifications) .where( and( - eq(notifications.type, notification.type), + eq(notifications.type, type), eq(notifications.recipientId, videoResult.ownerId), sql`JSON_EXTRACT(${notifications.data}, '$.comment.id') = ${notification.comment.id}` ) @@ -112,7 +168,6 @@ export async function createNotification( } const notificationId = nanoId(); - const now = new Date(); if (!videoResult.activeOrganizationId) { console.warn( @@ -121,8 +176,6 @@ export async function createNotification( return; } - const { type, ...data } = notification; - await db().insert(notifications).values({ id: notificationId, orgId: videoResult.activeOrganizationId,