From 42e07d8019fdb6a1aadf6e96d131ad60d497073f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:07:01 +0100 Subject: [PATCH] feat: comments deep link (#2550) * feat: comments deeplinks * cleanup * feat: add support to in-app notification --- .../features/comment/atoms/comment-atom.ts | 1 + .../comment/components/comment-dialog.tsx | 6 +- .../comment/components/comment-list-item.tsx | 23 +-- .../components/comment-list-with-tabs.tsx | 7 +- .../comment/components/comment-menu.tsx | 30 +++- .../comment/components/comment.module.css | 13 ++ .../comment/hooks/use-comment-deep-link.ts | 153 ++++++++++++++++++ .../src/features/editor/page-editor.tsx | 6 +- .../components/notification-item.tsx | 5 +- apps/client/src/lib/api-client.ts | 8 +- apps/client/src/pages/page/page.tsx | 13 +- .../services/comment.notification.ts | 13 +- apps/server/src/ee | 2 +- .../emails/comment-created-email.tsx | 6 +- .../emails/comment-mention-email.tsx | 6 +- .../emails/comment-resolved-email.tsx | 6 +- 16 files changed, 259 insertions(+), 39 deletions(-) create mode 100644 apps/client/src/features/comment/hooks/use-comment-deep-link.ts diff --git a/apps/client/src/features/comment/atoms/comment-atom.ts b/apps/client/src/features/comment/atoms/comment-atom.ts index 374e7f7f..13694ad5 100644 --- a/apps/client/src/features/comment/atoms/comment-atom.ts +++ b/apps/client/src/features/comment/atoms/comment-atom.ts @@ -3,6 +3,7 @@ import { atom } from 'jotai'; export const showCommentPopupAtom = atom(false); export const activeCommentIdAtom = atom(''); export const draftCommentIdAtom = atom(''); +export const commentPanelTabAtom = atom<'open' | 'resolved'>('open'); // Read-only comment state export const showReadOnlyCommentPopupAtom = atom(false); diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index 6eadc86a..8587850e 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { Dialog, Group, Stack, Text } from "@mantine/core"; import { useClickOutside } from "@mantine/hooks"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { activeCommentIdAtom, + commentPanelTabAtom, draftCommentIdAtom, showCommentPopupAtom, showReadOnlyCommentPopupAtom, @@ -32,6 +33,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) { const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); + const setCommentPanelTab = useSetAtom(commentPanelTabAtom); const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom); const [currentUser] = useAtom(currentUserAtom); const [, setAsideState] = useAtom(asideStateAtom); @@ -90,6 +92,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) { }); } setActiveCommentId(createdComment.id); + setCommentPanelTab("open"); setAsideState({ tab: "comments", isAsideOpen: true }); setTimeout(() => { @@ -121,6 +124,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) { }); setActiveCommentId(createdComment.id); + setCommentPanelTab("open"); setAsideState({ tab: "comments", isAsideOpen: true }); setTimeout(() => { diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 7cedcd84..484ef063 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -150,16 +150,19 @@ function CommentListItem({ /> )} - {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( - - )} + diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index a3f348b8..cc34564a 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -29,11 +29,13 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts" import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; import { useAtom } from "jotai"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { commentPanelTabAtom } from "@/features/comment/atoms/comment-atom"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; function CommentListWithTabs() { const { t } = useTranslation(); const { pageSlug } = useParams(); + const [activeTab, setActiveTab] = useAtom(commentPanelTabAtom); const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: comments, @@ -185,7 +187,10 @@ function CommentListWithTabs() { }} > + setActiveTab(value === "resolved" ? "resolved" : "open") + } variant="default" style={{ flex: "1 1 auto", diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index 690815e6..eac2c361 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -2,36 +2,51 @@ import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { IconDots, IconEdit, + IconLink, IconTrash, IconCircleCheck, IconCircleCheckFilled, } from "@tabler/icons-react"; import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { getAppUrl } from "@/lib/config"; type CommentMenuProps = { + commentId: string; onEditComment: () => void; onDeleteComment: () => void; onResolveComment?: () => void; canEdit?: boolean; + canManage?: boolean; isResolved?: boolean; isParentComment?: boolean; }; function CommentMenu({ + commentId, onEditComment, onDeleteComment, onResolveComment, canEdit = true, + canManage = false, isResolved = false, isParentComment = false, }: CommentMenuProps) { const { t } = useTranslation(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION); const upgradeLabel = useUpgradeLabel(); + const clipboard = useClipboard({ timeout: 500 }); + + const handleCopyLink = () => { + const link = `${getAppUrl()}${window.location.pathname}?commentId=${commentId}`; + clipboard.copy(link); + notifications.show({ message: t("Link copied") }); + }; //@ts-ignore const openDeleteModal = () => @@ -65,6 +80,7 @@ function CommentMenu({ )} {isParentComment && + canManage && (canResolve ? ( ))} } - onClick={openDeleteModal} + leftSection={} + onClick={handleCopyLink} > - {t("Delete comment")} + {t("Copy link")} + {canManage && ( + } + onClick={openDeleteModal} + > + {t("Delete comment")} + + )} ); diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index dfa61b79..1f58287a 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -6,6 +6,19 @@ border: 2px solid #8d7249; } +.deepLinkHighlight { + animation: comment-deep-link-flash 3s ease-out; +} + +@keyframes comment-deep-link-flash { + 0% { + background-color: var(--mantine-color-yellow-light); + } + 100% { + background-color: transparent; + } +} + .textSelection { margin-top: 4px; border-left: 2px solid var(--mantine-color-gray-6); diff --git a/apps/client/src/features/comment/hooks/use-comment-deep-link.ts b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts new file mode 100644 index 00000000..4d630241 --- /dev/null +++ b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useSetAtom } from "jotai"; +import { useCommentsQuery } from "@/features/comment/queries/comment-query"; +import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; +import { + activeCommentIdAtom, + commentPanelTabAtom, +} from "@/features/comment/atoms/comment-atom"; +import classes from "@/features/comment/components/comment.module.css"; + +const COMMENT_ID_PARAM = "commentId"; +const HIGHLIGHT_DURATION = 3000; +const RETRY_INTERVAL = 100; +const MAX_RETRIES = 40; // ~4s for the panel/editor to mount and render + +/** + * Reveals a comment referenced by a `?commentId=` query param (e.g. from a + * notification email): opens the comments panel on the right tab, then scrolls + * to and briefly highlights the comment in the panel and, for inline comments, + * its mark in the document. + */ +export function useCommentDeepLink(pageId: string | undefined) { + const [searchParams, setSearchParams] = useSearchParams(); + const commentId = searchParams.get(COMMENT_ID_PARAM); + + const setAsideState = useSetAtom(asideStateAtom); + const setActiveCommentId = useSetAtom(activeCommentIdAtom); + const setCommentPanelTab = useSetAtom(commentPanelTabAtom); + + // Only fetch comments when a deep link is present so normal page loads + // don't pay for an eager comments request. + const { data: comments, isLoading } = useCommentsQuery({ + pageId: (commentId ? pageId : undefined) as string, + }); + + const handledRef = useRef(null); + const timersRef = useRef>>([]); + + // Cancel pending retries on unmount only, so clearing the URL param (which + // re-runs the activation effect) cannot abort them. + useEffect( + () => () => { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + }, + [], + ); + + // Reset per page so a deep-link tab override never outlives its page. + useEffect(() => { + setCommentPanelTab("open"); + }, [pageId, setCommentPanelTab]); + + useEffect(() => { + if (!commentId) { + handledRef.current = null; + return; + } + if (!pageId) return; + if (isLoading || !comments?.items) return; + if (handledRef.current === commentId) return; + handledRef.current = commentId; + + // Normalize the URL once handled, whatever the outcome. + const nextParams = new URLSearchParams(searchParams); + nextParams.delete(COMMENT_ID_PARAM); + setSearchParams(nextParams, { replace: true }); + + const target = comments.items.find((c) => c.id === commentId); + if (!target) return; // unknown, deleted, or inaccessible comment + + // Replies have no mark or panel container of their own; surface the thread. + const threadId = target.parentCommentId ?? target.id; + const thread = comments.items.find((c) => c.id === threadId); + + setCommentPanelTab(thread?.resolvedAt ? "resolved" : "open"); + setActiveCommentId(threadId); + setAsideState({ tab: "comments", isAsideOpen: true }); + + // Act once the element exists AND its position has settled, so the scroll + // lands correctly even while the page is still laying out (static -> live + // editor swap, images, fonts). Falls back to the last attempt if layout + // never fully settles within the window. + const onElementReady = ( + selector: string, + onFound: (el: HTMLElement) => void, + ) => { + let attempts = 0; + let lastTop: number | null = null; + let stableTicks = 0; + const tick = () => { + let el: HTMLElement | null = null; + try { + el = document.querySelector(selector); + } catch { + return; + } + attempts += 1; + const lastAttempt = attempts >= MAX_RETRIES; + if (el) { + const top = Math.round(el.getBoundingClientRect().top + window.scrollY); + stableTicks = + lastTop !== null && Math.abs(top - lastTop) <= 1 ? stableTicks + 1 : 0; + lastTop = top; + if (stableTicks >= 2 || lastAttempt) { + onFound(el); + return; + } + } + if (!lastAttempt) { + timersRef.current.push(setTimeout(tick, RETRY_INTERVAL)); + } + }; + tick(); + }; + + onElementReady(`div[data-comment-id="${threadId}"]`, (el) => { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add(classes.deepLinkHighlight); + timersRef.current.push( + setTimeout( + () => el.classList.remove(classes.deepLinkHighlight), + HIGHLIGHT_DURATION, + ), + ); + }); + + // Inline marks render only for unresolved selection comments. + if (thread?.selection && !thread.resolvedAt) { + onElementReady(`.comment-mark[data-comment-id="${threadId}"]`, (el) => { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("comment-highlight"); + timersRef.current.push( + setTimeout( + () => el.classList.remove("comment-highlight"), + HIGHLIGHT_DURATION, + ), + ); + }); + } + }, [ + commentId, + pageId, + isLoading, + comments, + searchParams, + setSearchParams, + setAsideState, + setActiveCommentId, + setCommentPanelTab, + ]); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 8e6e1036..7c6ace15 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -29,7 +29,7 @@ import { collabExtensions, mainExtensions, } from "@/features/editor/extensions/extensions"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentPageEditModeAtom, @@ -41,6 +41,7 @@ import { import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, + commentPanelTabAtom, showCommentPopupAtom, showReadOnlyCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; @@ -188,6 +189,7 @@ function CollabPageEditor({ const [, setEditor] = useAtom(pageEditorAtom); const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); + const setCommentPanelTab = useSetAtom(commentPanelTabAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom); @@ -386,6 +388,7 @@ function CollabPageEditor({ } setActiveCommentId(commentId); + setCommentPanelTab("open"); setAsideState({ tab: "comments", isAsideOpen: true }); //wait if aside is closed @@ -409,7 +412,6 @@ function CollabPageEditor({ useEffect(() => { setActiveCommentId(null); setShowCommentPopup(false); - setAsideState({ tab: "", isAsideOpen: false }); setLightboxRequest(null); }, [pageId]); diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx index 12b9f242..3dbd6b60 100644 --- a/apps/client/src/features/notification/components/notification-item.tsx +++ b/apps/client/src/features/notification/components/notification-item.tsx @@ -74,13 +74,16 @@ export function NotificationItem({ } }; + const commentParam = notification.commentId + ? `?commentId=${notification.commentId}` + : ""; const pageUrl = notification.page && notification.space ? buildPageUrl( notification.space.slug, notification.page.slugId, notification.page.title, - ) + ) + commentParam : undefined; const isSiemDestination = notification.type.startsWith("siem_destination."); diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index cde9317a..805cb7fa 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -86,12 +86,12 @@ function redirectToLogin() { // the oauth consent page redirects to login itself, preserving its query string "/oauth/consent", ]; - if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { - const redirectTo = window.location.pathname; - if (redirectTo === APP_ROUTE.HOME) { + const { pathname, search, hash } = window.location; + if (!exemptPaths.some((path) => pathname.startsWith(path))) { + if (pathname === APP_ROUTE.HOME) { window.location.href = APP_ROUTE.AUTH.LOGIN; } else { - const params = new URLSearchParams({ redirect: redirectTo }); + const params = new URLSearchParams({ redirect: pathname + search + hash }); window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`; } } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index ea109488..b809c9b0 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -6,8 +6,11 @@ import HistoryModal from "@/features/page-history/components/history-modal"; import PageHeader from "@/features/page/components/header/page-header.tsx"; import { extractPageSlugId } from "@/lib"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { useCommentDeepLink } from "@/features/comment/hooks/use-comment-deep-link"; import { useTranslation } from "react-i18next"; -import React from "react"; +import React, { useEffect } from "react"; +import { useSetAtom } from "jotai"; +import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { EmptyState } from "@/components/ui/empty-state.tsx"; import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react"; import { Button } from "@mantine/core"; @@ -57,6 +60,14 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { error, } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); + const setAsideState = useSetAtom(asideStateAtom); + + // Keep above useCommentDeepLink so a deep link can reopen the aside. + useEffect(() => { + setAsideState({ tab: "", isAsideOpen: false }); + }, [page?.id, setAsideState]); + + useCommentDeepLink(page?.id); const hasBases = useHasFeature(Feature.BASES); const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false); diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts index c79c2895..74109a17 100644 --- a/apps/server/src/core/notification/services/comment.notification.ts +++ b/apps/server/src/core/notification/services/comment.notification.ts @@ -48,7 +48,7 @@ export class CommentNotificationService { ); if (!context) return; - const { actor, pageTitle, pageUrl } = context; + const { actor, pageTitle, commentUrl } = context; const notifiedUserIds = new Set(); notifiedUserIds.add(actorId); @@ -92,7 +92,7 @@ export class CommentNotificationService { userId, notification.id, `${actor.name} mentioned you in a comment`, - CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }), + CommentMentionEmail({ actorName: actor.name, pageTitle, commentUrl }), NotificationType.COMMENT_USER_MENTION, ); @@ -118,7 +118,7 @@ export class CommentNotificationService { recipientId, notification.id, `${actor.name} commented on ${pageTitle}`, - CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }), + CommentCreateEmail({ actorName: actor.name, pageTitle, commentUrl }), NotificationType.COMMENT_CREATED, ); } @@ -145,7 +145,7 @@ export class CommentNotificationService { ); if (!context) return; - const { actor, pageTitle, pageUrl } = context; + const { actor, pageTitle, commentUrl } = context; const roles = await this.spaceMemberRepo.getUserSpaceRoles( commentCreatorId, @@ -183,7 +183,7 @@ export class CommentNotificationService { commentCreatorId, notification.id, subject, - CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }), + CommentResolvedEmail({ actorName: actor.name, pageTitle, commentUrl }), NotificationType.COMMENT_RESOLVED, ); } @@ -235,7 +235,8 @@ export class CommentNotificationService { } const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`; + const commentUrl = `${pageUrl}?commentId=${commentId}`; - return { actor, pageTitle: getPageTitle(page.title), pageUrl }; + return { actor, pageTitle: getPageTitle(page.title), commentUrl }; } } diff --git a/apps/server/src/ee b/apps/server/src/ee index d6260785..cc28defe 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit d6260785de583ddb85109802c92499eb9c21b8f0 +Subproject commit cc28defeec44c8f71e686cbec6e4298bb447e0e7 diff --git a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx index ddf70963..de5cadaa 100644 --- a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx @@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; pageTitle: string; - pageUrl: string; + commentUrl: string; } export const CommentCreateEmail = ({ actorName, pageTitle, - pageUrl, + commentUrl, }: Props) => { return ( @@ -23,7 +23,7 @@ export const CommentCreateEmail = ({ {pageTitle}. - View + View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx index 72a1d4a7..09ec1193 100644 --- a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx @@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; pageTitle: string; - pageUrl: string; + commentUrl: string; } export const CommentMentionEmail = ({ actorName, pageTitle, - pageUrl, + commentUrl, }: Props) => { return ( @@ -23,7 +23,7 @@ export const CommentMentionEmail = ({ {pageTitle}. - View + View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx index 303375cd..5f09b794 100644 --- a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx @@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; pageTitle: string; - pageUrl: string; + commentUrl: string; } export const CommentResolvedEmail = ({ actorName, pageTitle, - pageUrl, + commentUrl, }: Props) => { return ( @@ -23,7 +23,7 @@ export const CommentResolvedEmail = ({ {pageTitle}. - View + View ); };