Compare commits

..
Author SHA1 Message Date
Philipinho 0501b334b5 feat(ee): space control to hide comments from viewers role 2026-08-13 01:28:02 +01:00
34 changed files with 521 additions and 531 deletions
@@ -508,6 +508,11 @@
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Hide comments from viewers": "Hide comments from viewers",
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -1289,11 +1294,5 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "Compare",
"Compare versions": "Compare versions",
"Select version from {{date}}": "Select version from {{date}}",
"Version actions for {{date}}": "Version actions for {{date}}",
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
"Exit compare": "Exit compare"
"{{count}} selected_other": "{{count}} selected"
}
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const canViewComments = useCanViewComments();
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
@@ -23,12 +25,18 @@ export default function Aside() {
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
useEffect(() => {
if (isAsideOpen && tab === "comments" && !canViewComments) {
setAsideState({ tab: "", isAsideOpen: false });
}
}, [isAsideOpen, tab, canViewComments, setAsideState]);
let title: string;
let component: ReactNode;
switch (tab) {
case "comments":
component = <CommentListWithTabs />;
component = canViewComments ? <CommentListWithTabs /> : null;
title = "Comments";
break;
case "toc":
+1
View File
@@ -19,6 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
@@ -0,0 +1,62 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceHideCommentsToggleProps = {
space: ISpace;
};
export default function SpaceHideCommentsToggle({
space,
}: SpaceHideCommentsToggleProps) {
const { t } = useTranslation();
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const allowViewerCommentsEnabled =
space.settings?.comments?.allowViewerComments === true;
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
const tooltipLabel = !hasHideComments
? upgradeLabel
: t("Turn off 'Allow viewers to comment' first");
const [checked, setChecked] = useState(
space.settings?.comments?.hideCommentsFromViewers === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
hideCommentsFromViewers: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hide comments from viewers")}</Text>
<Text size="sm" c="dimmed">
{t("Viewers cannot see or add comments on pages in this space.")}
</Text>
</div>
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle hide comments from viewers")}
/>
</Tooltip>
</Group>
);
}
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const hideCommentsEnabled =
space.settings?.comments?.hideCommentsFromViewers === true;
const isDisabled = !hasViewerComments || hideCommentsEnabled;
const tooltipLabel = !hasViewerComments
? upgradeLabel
: t("Turn off 'Hide comments from viewers' first");
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
</Text>
</div>
<Tooltip
label={upgradeLabel}
label={tooltipLabel}
disabled={!isDisabled}
refProp="rootRef"
>
@@ -0,0 +1,15 @@
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { extractPageSlugId } from "@/lib";
export function useCanViewComments(): boolean {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
return (
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
);
}
+27 -13
View File
@@ -82,6 +82,8 @@ import {
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
import clsx from "clsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageEditorProps {
pageId: string;
@@ -196,6 +198,7 @@ function CollabPageEditor({
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canViewComments = useCanViewComments();
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -372,6 +375,7 @@ function CollabPageEditor({
};
useEffect(() => {
if (!canViewComments) return;
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
return () => {
document.removeEventListener(
@@ -379,7 +383,7 @@ function CollabPageEditor({
handleActiveCommentEvent,
);
};
}, []);
}, [canViewComments]);
useEffect(() => {
setActiveCommentId(null);
@@ -430,7 +434,13 @@ function CollabPageEditor({
}
return (
<div className="editor-container" style={{ position: "relative" }}>
<div
className={clsx(
"editor-container",
!canViewComments && "comments-hidden",
)}
style={{ position: "relative" }}
>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@@ -480,17 +490,21 @@ function StaticPageEditor({
content: any;
ariaLabel: string;
}) {
const canViewComments = useCanViewComments();
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
<div className={clsx(!canViewComments && "comments-hidden")}>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
</div>
);
}
@@ -315,3 +315,8 @@
height: 100%;
}
}
.comments-hidden .ProseMirror .comment-mark {
background: none;
border-bottom: none;
}
@@ -6,13 +6,4 @@ export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(
null as DiffCounts | null,
);
export type ComparePair = { newerId: string; olderId: string };
export const compareModeAtom = atom<boolean>(false);
export const compareSelectionAtom = atom<string[]>([]);
export const comparePairAtom = atom<ComparePair | null>(
null as ComparePair | null,
);
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -1,7 +1,7 @@
.history {
display: flex;
align-items: center;
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
@@ -12,28 +12,6 @@
}
}
.historyButton {
flex: 1;
min-width: 0;
color: inherit;
}
.compareCheckbox {
padding-left: var(--mantine-spacing-xs);
}
.itemMenu {
opacity: 0;
margin-right: var(--mantine-spacing-xs);
}
.history:hover .itemMenu,
.history:focus-within .itemMenu,
.history.active .itemMenu,
.itemMenu[aria-expanded="true"] {
opacity: 1;
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
@@ -99,8 +77,3 @@
flex: 1;
padding: rem(16px) rem(40px);
}
.compareBanner {
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -170,6 +170,7 @@ export function HistoryEditor({
}
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
@@ -1,21 +1,10 @@
import {
Text,
Group,
UnstyledButton,
Avatar,
Tooltip,
ActionIcon,
Checkbox,
Menu,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
const MAX_VISIBLE_AVATARS = 5;
@@ -26,13 +15,6 @@ interface HistoryItemProps {
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean;
compareMode: boolean;
isChecked: boolean;
isCheckboxDisabled: boolean;
canCompare: boolean;
onToggleCompare: (id: string) => void;
onStartCompare: (id: string) => void;
onRestore?: (id: string, index: number) => void;
}
const HistoryItem = memo(function HistoryItem({
@@ -42,24 +24,10 @@ const HistoryItem = memo(function HistoryItem({
onHover,
onHoverEnd,
isActive,
compareMode,
isChecked,
isCheckboxDisabled,
canCompare,
onToggleCompare,
onStartCompare,
onRestore,
}: HistoryItemProps) {
const { t } = useTranslation();
const date = formattedDate(new Date(historyItem.createdAt));
const handleClick = useCallback(() => {
if (compareMode) {
onToggleCompare(historyItem.id);
} else {
onSelect(historyItem.id, index);
}
}, [compareMode, onToggleCompare, onSelect, historyItem.id, index]);
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
@@ -69,115 +37,63 @@ const HistoryItem = memo(function HistoryItem({
const hasContributors = contributors && contributors.length > 0;
return (
<div
className={clsx(classes.history, { [classes.active]: isActive })}
<UnstyledButton
p="xs"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })}
>
{compareMode && (
<Checkbox
size="xs"
className={classes.compareCheckbox}
checked={isChecked}
disabled={isCheckboxDisabled}
onChange={() => onToggleCompare(historyItem.id)}
aria-label={t("Select version from {{date}}", { date })}
/>
)}
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<UnstyledButton
p="xs"
onClick={handleClick}
className={classes.historyButton}
>
<Text size="sm">{date}</Text>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors
.slice(0, MAX_VISIBLE_AVATARS)
.map((contributor) => (
<Tooltip
key={contributor.id}
label={contributor.name}
withArrow
>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
<Group gap={6} wrap="nowrap" mt={4}>
{hasContributors ? (
<>
<Tooltip.Group openDelay={300} closeDelay={100}>
<Avatar.Group spacing={8}>
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
<Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors
.slice(MAX_VISIBLE_AVATARS)
.map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{contributors[0].name}
</Text>
)}
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
{contributors[0].name}
</Text>
</>
)}
</Group>
</UnstyledButton>
{!compareMode && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
className={classes.itemMenu}
aria-label={t("Version actions for {{date}}", { date })}
onClick={(e) => e.stopPropagation()}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
disabled={!canCompare}
onClick={() => onStartCompare(historyItem.id)}
>
{t("Compare")}
</Menu.Item>
{onRestore && (
<Menu.Item onClick={() => onRestore(historyItem.id, index)}>
{t("Restore")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</div>
</>
) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group>
</UnstyledButton>
);
});
@@ -6,12 +6,8 @@ import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair";
import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
@@ -36,9 +32,6 @@ function HistoryList({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const [compareMode, setCompareMode] = useAtom(compareModeAtom);
const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom);
const setComparePair = useSetAtom(comparePairAtom);
const {
data: pageHistoryData,
@@ -86,58 +79,10 @@ function HistoryList({ pageId }: Props) {
const handleSelect = useCallback(
(id: string, index: number) => {
setComparePair(null);
setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair],
);
const handleToggleCompare = useCallback(
(id: string) => {
setCompareSelection((prev) => {
if (prev.includes(id)) return prev.filter((item) => item !== id);
if (prev.length >= 2) return prev;
return [...prev, id];
});
},
[setCompareSelection],
);
const handleStartCompare = useCallback(
(id: string) => {
setComparePair(null);
setCompareMode(true);
setCompareSelection([id]);
},
[setComparePair, setCompareMode, setCompareSelection],
);
const handleCancelCompare = useCallback(() => {
setCompareMode(false);
setCompareSelection([]);
}, [setCompareMode, setCompareSelection]);
const handleConfirmCompare = useCallback(() => {
const pair = resolveComparePair(historyItems, compareSelection);
if (!pair) return;
setComparePair(pair);
setCompareMode(false);
setCompareSelection([]);
}, [
historyItems,
compareSelection,
setComparePair,
setCompareMode,
setCompareSelection,
]);
const handleRestoreItem = useCallback(
(id: string, index: number) => {
handleSelect(id, index);
confirmRestore(id);
},
[handleSelect, confirmRestore],
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
@@ -193,16 +138,6 @@ function HistoryList({ pageId }: Props) {
onHover={handleHover}
onHoverEnd={clearPrefetchTimeout}
isActive={historyItem.id === activeHistoryId}
compareMode={compareMode}
isChecked={compareSelection.includes(historyItem.id)}
isCheckboxDisabled={
!compareSelection.includes(historyItem.id) &&
compareSelection.length >= 2
}
canCompare={historyItems.length >= 2}
onToggleCompare={handleToggleCompare}
onStartCompare={handleStartCompare}
onRestore={canRestore ? handleRestoreItem : undefined}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
@@ -213,44 +148,22 @@ function HistoryList({ pageId }: Props) {
)}
</ScrollArea>
{compareMode ? (
{canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={handleCancelCompare}
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button
size="compact-md"
disabled={compareSelection.length !== 2}
onClick={handleConfirmCompare}
>
{t("Compare")}
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group>
</>
) : (
canRestore && (
<>
<Divider />
<Group p="xs" wrap="nowrap">
<Button
variant="default"
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={() => confirmRestore()}>
{t("Restore")}
</Button>
</Group>
</>
)
)}
</div>
);
@@ -1,6 +1,5 @@
import {
ActionIcon,
CloseButton,
Group,
Paper,
ScrollArea,
@@ -13,20 +12,17 @@ import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
comparePairAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useMemo, useRef } from "react";
import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
interface Props {
pageId: string;
@@ -40,28 +36,6 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [comparePair, setComparePair] = useAtom(comparePairAtom);
const { data: pageHistoryData } = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const compareLabel = useMemo(() => {
if (!comparePair) return null;
const newerItem = historyItems.find(
(item) => item.id === comparePair.newerId,
);
const olderItem = historyItems.find(
(item) => item.id === comparePair.olderId,
);
if (!newerItem || !olderItem) return null;
return t("Comparing {{newer}} and {{older}}", {
newer: formattedDate(new Date(newerItem.createdAt)),
older: formattedDate(new Date(olderItem.createdAt)),
});
}, [comparePair, historyItems, t]);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -76,25 +50,6 @@ export default function HistoryModalBody({ pageId }: Props) {
</nav>
<div style={{ position: "relative", flex: 1 }}>
{comparePair && (
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={4}
className={classes.compareBanner}
>
<Text size="sm" fw={500} lineClamp={1}>
{compareLabel ?? t("Compare versions")}
</Text>
<CloseButton
size="sm"
aria-label={t("Exit compare")}
onClick={() => setComparePair(null)}
/>
</Group>
)}
<ScrollArea
h={650}
w="100%"
@@ -102,18 +57,11 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{comparePair ? (
<HistoryView
historyId={comparePair.newerId}
prevHistoryId={comparePair.olderId}
/>
) : (
activeHistoryId && <HistoryView />
)}
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{(comparePair || (activeHistoryId && activeHistoryPrevId)) && (
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
@@ -166,7 +166,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={() => confirmRestore()}>{t("Restore")}</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
@@ -7,29 +7,21 @@ import {
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface Props {
historyId?: string;
prevHistoryId?: string;
}
function HistoryView({ historyId, prevHistoryId }: Props) {
function HistoryView() {
const { t } = useTranslation();
const activeId = useAtomValue(activeHistoryIdAtom);
const activePrevId = useAtomValue(activeHistoryPrevIdAtom);
const resolvedId = historyId ?? activeId;
const resolvedPrevId = prevHistoryId ?? activePrevId;
const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(resolvedId);
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(resolvedPrevId);
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>;
@@ -3,45 +3,22 @@ import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
compareModeAtom,
comparePairAtom,
compareSelectionAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection, diff counts, and compare state.
* Compare state also resets on unmount so reopening the modal starts clean.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const [, setCompareMode] = useAtom(compareModeAtom);
const [, setCompareSelection] = useAtom(compareSelectionAtom);
const [, setComparePair] = useAtom(comparePairAtom);
useEffect(() => {
const resetCompare = () => {
setCompareMode(false);
setCompareSelection([]);
setComparePair(null);
};
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
resetCompare();
return resetCompare;
}, [
pageId,
setActiveHistoryId,
setActiveHistoryPrevId,
setDiffCounts,
setCompareMode,
setCompareSelection,
setComparePair,
]);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -1,4 +1,4 @@
import { useAtomValue, useSetAtom } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
@@ -9,8 +9,7 @@ import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { fetchPageHistory } from "@/features/page-history/queries/page-history-query";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
@@ -26,6 +25,8 @@ export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
@@ -39,66 +40,47 @@ export function useHistoryRestore() {
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(
async (historyId: string) => {
let historyData: IPageHistory;
try {
historyData = await fetchPageHistory(historyId);
} catch {
notifications.show({
message: t("Error fetching page data."),
color: "red",
});
return;
}
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditorTitle
.chain()
.clearContent()
.setContent(historyData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
mainEditor
.chain()
.clearContent()
.setContent(historyData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
},
[mainEditor, mainEditorTitle, setHistoryModalOpen, t],
);
const confirmRestore = useCallback(
(historyId?: string) => {
const targetId = historyId ?? activeHistoryId;
if (!targetId) return;
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: () => handleRestore(targetId),
});
},
[t, handleRestore, activeHistoryId],
);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -23,14 +23,6 @@ export function prefetchPageHistory(historyId: string) {
});
}
export function fetchPageHistory(historyId: string): Promise<IPageHistory> {
return queryClient.fetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery(
pageId: string,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveComparePair } from "./resolve-compare-pair";
// list is newest-first, matching usePageHistoryListQuery order
const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }];
describe("resolveComparePair", () => {
it("orders newer before older regardless of selection order", () => {
expect(resolveComparePair(items, ["v1", "v3"])).toEqual({
newerId: "v3",
olderId: "v1",
});
expect(resolveComparePair(items, ["v3", "v1"])).toEqual({
newerId: "v3",
olderId: "v1",
});
});
it("returns null unless exactly two versions are selected", () => {
expect(resolveComparePair(items, [])).toBeNull();
expect(resolveComparePair(items, ["v1"])).toBeNull();
expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull();
});
it("returns null when a selected id is not in the list", () => {
expect(resolveComparePair(items, ["v1", "missing"])).toBeNull();
});
it("returns null when the same id is selected twice", () => {
expect(resolveComparePair(items, ["v2", "v2"])).toBeNull();
});
});
@@ -1,18 +0,0 @@
import { ComparePair } from "@/features/page-history/atoms/history-atoms";
/**
* Resolves which of the two selected versions is newer using their position
* in the history list (list is newest-first: lower index = newer).
*/
export function resolveComparePair(
historyItems: { id: string }[],
selection: string[],
): ComparePair | null {
if (selection.length !== 2) return null;
const indexA = historyItems.findIndex((item) => item.id === selection[0]);
const indexB = historyItems.findIndex((item) => item.id === selection[1]);
if (indexA === -1 || indexB === -1 || indexA === indexB) return null;
return indexA < indexB
? { newerId: selection[0], olderId: selection[1] }
: { newerId: selection[1], olderId: selection[0] };
}
@@ -58,6 +58,7 @@ import {
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -65,6 +66,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
const canViewComments = useCanViewComments();
const tocTriggerProps = useAsideTriggerProps("toc");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
@@ -105,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
{canViewComments && (
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!page?.isBase && (
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
<Divider my="lg" />
<SpaceHideCommentsToggle space={space} />
</div>
);
}
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
export interface ISpaceSettings {
@@ -36,6 +37,7 @@ export interface ISpace {
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
interface IMembership {
+1
View File
@@ -18,6 +18,7 @@ export const Feature = {
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
@@ -89,20 +89,29 @@ export class CommentController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(input.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
async findOne(
@Body() input: CommentIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -113,7 +122,11 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return comment;
}
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
import { PageAccessService } from '../../page/page-access/page-access.service';
@Injectable()
export class CommentNotificationService {
@@ -25,6 +26,7 @@ export class CommentNotificationService {
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly pageAccessService: PageAccessService,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
@@ -48,7 +50,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
@@ -72,7 +74,16 @@ export class CommentNotificationService {
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
let accessibleUserIds = usersWithPageAccess;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
accessibleUserIds =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
accessibleUserIds,
);
}
const usersWithAccess = new Set(accessibleUserIds);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -145,7 +156,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@@ -166,6 +177,16 @@ export class CommentNotificationService {
);
if (hasPageAccess.length === 0) return;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
const editCapable =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
[commentCreatorId],
);
if (editCapable.length === 0) return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -225,7 +246,7 @@ export class CommentNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'settings'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -236,6 +257,11 @@ export class CommentNotificationService {
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
pageUrl,
spaceSettings: (space.settings ?? null) as Record<string, any> | null,
};
}
}
@@ -7,6 +7,7 @@ import {
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageAccessService {
@@ -14,6 +15,7 @@ export class PageAccessService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
/**
@@ -118,8 +120,68 @@ export class PageAccessService {
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
if (
!settings?.comments?.allowViewerComments ||
settings?.comments?.hideCommentsFromViewers
) {
throw new ForbiddenException();
}
}
async validateCanViewComments(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
const { canEdit } = await this.validateCanViewWithPermissions(page, user);
if (canEdit) {
return;
}
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (settings?.comments?.hideCommentsFromViewers) {
throw new ForbiddenException();
}
}
/**
* Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
*/
async filterUserIdsWithPageEditAccess(
spaceId: string,
pageId: string,
userIds: string[],
): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const spaceHasRestrictedPages =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
const hasRestriction =
spaceHasRestrictedPages &&
(await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
if (!hasRestriction) {
const editCapableIds =
await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
userIds,
spaceId,
);
return userIds.filter((id) => editCapableIds.has(id));
}
const results = await Promise.all(
userIds.map(async (userId) => {
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
userId,
pageId,
);
return canEdit ? userId : null;
}),
);
return results.filter((id): id is string => id !== null);
}
}
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
@IsOptional()
@IsBoolean()
hideCommentsFromViewers: boolean;
}
@@ -30,6 +30,35 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
export function validateExclusiveCommentSettings(
dto: Partial<
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
>,
settingsBefore: Record<string, any>,
): void {
if (
dto.allowViewerComments === undefined &&
dto.hideCommentsFromViewers === undefined
) {
return;
}
const allowViewerComments =
dto.allowViewerComments ??
settingsBefore.comments?.allowViewerComments ??
false;
const hideCommentsFromViewers =
dto.hideCommentsFromViewers ??
settingsBefore.comments?.hideCommentsFromViewers ??
false;
if (allowViewerComments && hideCommentsFromViewers) {
throw new BadRequestException(
"'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
);
}
}
@Injectable()
export class SpaceService {
constructor(
@@ -141,7 +170,8 @@ export class SpaceService {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
@@ -168,6 +198,17 @@ export class SpaceService {
) {
throw new ForbiddenException('This feature requires a valid license');
}
if (
updateSpaceDto.hideCommentsFromViewers === true &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.HIDE_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
const spaceBefore = await this.spaceRepo.findById(
@@ -176,6 +217,8 @@ export class SpaceService {
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
const before: Record<string, any> = {};
const after: Record<string, any> = {};
@@ -218,6 +261,23 @@ export class SpaceService {
);
}
if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
if (prev !== updateSpaceDto.hideCommentsFromViewers) {
before.hideCommentsFromViewers = prev;
after.hideCommentsFromViewers =
updateSpaceDto.hideCommentsFromViewers;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'hideCommentsFromViewers',
updateSpaceDto.hideCommentsFromViewers,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -20,6 +20,7 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable()
export class SpaceMemberRepo {
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
return new Set(rows.map((r) => r.userId));
}
async getUserIdsWithSpaceEditAccess(
userIds: string[],
spaceId: string,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const rows = await this.db
.selectFrom('spaceMembers')
.select('userId')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('groupUsers.userId', 'in', userIds)
.where('spaceMembers.spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
)
.execute();
return new Set(rows.map((r) => r.userId));
}
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('spaceMembers')
@@ -149,6 +149,17 @@ export class SpaceRepo {
.executeTakeFirst();
}
async getSpaceSettings(
spaceId: string,
): Promise<Record<string, any> | null> {
const row = await this.db
.selectFrom('spaces')
.select('settings')
.where('id', '=', spaceId)
.executeTakeFirst();
return (row?.settings as Record<string, any> | undefined) ?? null;
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
+39 -8
View File
@@ -3,6 +3,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Server, Socket } from 'socket.io';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { PageAccessService } from '../core/page/page-access/page-access.service';
import {
TREE_EVENTS,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
@@ -17,6 +19,8 @@ export class WsService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceRepo: SpaceRepo,
private readonly pageAccessService: PageAccessService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
@@ -67,9 +71,24 @@ export class WsService {
spaceId: string,
pageId: string,
data: any,
opts?: { bypassVisibilityCheck?: boolean },
): Promise<void> {
const room = getSpaceRoomName(spaceId);
if (
!opts?.bypassVisibilityCheck &&
(await this.spaceHidesCommentsFromViewers(spaceId))
) {
await this.broadcastToUsersMatching(room, null, data, (candidateIds) =>
this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
candidateIds,
),
);
return;
}
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
@@ -118,6 +137,17 @@ export class WsService {
excludeSocketId: string | null,
pageId: string,
data: any,
): Promise<void> {
await this.broadcastToUsersMatching(room, excludeSocketId, data, (ids) =>
this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, ids),
);
}
private async broadcastToUsersMatching(
room: string,
excludeSocketId: string | null,
data: any,
filterUserIds: (candidateUserIds: string[]) => Promise<string[]>,
): Promise<void> {
const sockets = await this.server.in(room).fetchSockets();
@@ -144,15 +174,9 @@ export class WsService {
const candidateUserIds = Array.from(userSocketMap.keys());
if (candidateUserIds.length === 0) return;
const authorizedUserIds =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
candidateUserIds,
);
const authorizedSet = new Set(authorizedUserIds);
const allowedSet = new Set(await filterUserIds(candidateUserIds));
for (const [userId, userSockets] of userSocketMap) {
if (authorizedSet.has(userId)) {
if (allowedSet.has(userId)) {
for (const socket of userSockets) {
socket.emit('message', data);
}
@@ -176,6 +200,13 @@ export class WsService {
return hasRestrictions;
}
private async spaceHidesCommentsFromViewers(
spaceId: string,
): Promise<boolean> {
const settings = await this.spaceRepo.getSpaceSettings(spaceId);
return settings?.comments?.hideCommentsFromViewers === true;
}
private extractPageId(data: any): string | null {
switch (data.operation) {
case 'addTreeNode':