(
/>
{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,