feat: comments deep link (#2550)

* feat: comments deeplinks
* cleanup
* feat: add support to in-app notification
This commit is contained in:
Philip Okugbe 2026-10-09 01:07:01 +01:00 committed by GitHub
parent d3a0c9bfdf
commit 42e07d8019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 259 additions and 39 deletions

View file

@ -3,6 +3,7 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
export const commentPanelTabAtom = atom<'open' | 'resolved'>('open');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);

View file

@ -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(() => {

View file

@ -150,16 +150,19 @@ function CommentListItem({
/>
)}
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
)}
<CommentMenu
commentId={comment.id}
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
canManage={
currentUser?.user?.id === comment.creatorId ||
userSpaceRole === "admin"
}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
</div>
</Group>

View file

@ -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() {
}}
>
<Tabs
defaultValue="open"
value={activeTab}
onChange={(value) =>
setActiveTab(value === "resolved" ? "resolved" : "open")
}
variant="default"
style={{
flex: "1 1 auto",

View file

@ -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({
</Menu.Item>
)}
{isParentComment &&
canManage &&
(canResolve ? (
<Menu.Item
onClick={onResolveComment}
@ -86,11 +102,19 @@ function CommentMenu({
</Tooltip>
))}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
leftSection={<IconLink size={14} />}
onClick={handleCopyLink}
>
{t("Delete comment")}
{t("Copy link")}
</Menu.Item>
{canManage && (
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
>
{t("Delete comment")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);

View file

@ -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);

View file

@ -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<string | null>(null);
const timersRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
// 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<HTMLElement>(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,
]);
}

View file

@ -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]);

View file

@ -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.");

View file

@ -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()}`;
}
}

View file

@ -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);

View file

@ -48,7 +48,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, commentUrl } = context;
const notifiedUserIds = new Set<string>();
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 };
}
}

@ -1 +1 @@
Subproject commit d6260785de583ddb85109802c92499eb9c21b8f0
Subproject commit cc28defeec44c8f71e686cbec6e4298bb447e0e7

View file

@ -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 (
<MailBody>
@ -23,7 +23,7 @@ export const CommentCreateEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};

View file

@ -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 (
<MailBody>
@ -23,7 +23,7 @@ export const CommentMentionEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};

View file

@ -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 (
<MailBody>
@ -23,7 +23,7 @@ export const CommentResolvedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};