Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/actions/videos/new-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const NotificationItem = ({
<Link
href={link}
className={clsx(
"flex gap-3 p-4 transition-colors cursor-pointer border-gray-3 hover:bg-gray-2",
"flex gap-3 p-4 transition-colors cursor-pointer min-h-fit border-gray-3 hover:bg-gray-2",
className
)}
>
Expand Down Expand Up @@ -62,12 +62,12 @@ export const NotificationItem = ({
</span>
</div>

{notification.type === "comment" ||
(notification.type === "reply" && (
<p className="mb-2 text-[13px] italic leading-4 text-gray-11 line-clamp-2">
{(notification.type === "comment" ||
notification.type === "reply") && (
<p className="mb-2 text-[13px] h-fit italic leading-4 text-gray-11 line-clamp-2">
{notification.comment.content}
</p>
))}
)}
<p className="text-xs text-gray-10">
{moment(notification.createdAt).fromNow()}
</p>
Expand All @@ -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}`;
Expand Down
10 changes: 5 additions & 5 deletions apps/web/app/(org)/dashboard/_components/Notifications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ const Notifications = forwardRef<HTMLDivElement, NotificationsProps>(
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.98 }}
initial={{ opacity: 0, y: 4, scale: 0.98, display: "none" }}
animate={{ opacity: 1, y: 0, scale: 1, display: "flex" }}
exit={{ opacity: 0, y: 4, scale: 0.98, display: "none" }}
transition={{ ease: "easeOut", duration: 0.2 }}
onClick={(e) => 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}
Expand All @@ -97,7 +97,7 @@ const Notifications = forwardRef<HTMLDivElement, NotificationsProps>(
/>
<div
ref={scrollRef}
className="isolate flex-1 h-full custom-scroll border-x border-gray-3 divide-y divide-gray-3 flex flex-col"
className="flex isolate flex-col flex-1 h-full divide-y custom-scroll border-x border-gray-3 divide-gray-3"
>
{notifications.isPending ? (
<NotificationsSkeleton />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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"}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ 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<string | null>(null);

const commentsContainerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (commentParams) return;
if (commentParams || replyParams) return;
if (commentsContainerRef.current) {
commentsContainerRef.current.scrollTop =
commentsContainerRef.current.scrollHeight;
Expand Down
24 changes: 20 additions & 4 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
87 changes: 70 additions & 17 deletions apps/web/lib/Notification.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,7 +24,7 @@ type CreateNotificationInput<D = NotificationSpecificData> =
D extends NotificationSpecificData
? D["author"] extends never
? D
: Omit<D, "author"> & { authorId: string }
: Omit<D, "author"> & { authorId: string } & { parentCommentId?: string }
: never;

export async function createNotification(
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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}`
)
Expand All @@ -112,7 +168,6 @@ export async function createNotification(
}

const notificationId = nanoId();
const now = new Date();

if (!videoResult.activeOrganizationId) {
console.warn(
Expand All @@ -121,8 +176,6 @@ export async function createNotification(
return;
}

const { type, ...data } = notification;

await db().insert(notifications).values({
id: notificationId,
orgId: videoResult.activeOrganizationId,
Expand Down
Loading