1bcc96685e
Outside the editor the UI did background work on every tree event, socket reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was already good — the cost was in the subscriptions and duplicates around it. Client-only; behavior 1:1. - Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation, use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo). space-tree-node-menu / mention-list read the tree imperatively (store.get) in their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain + field equality) instead of the whole-tree subscription. - Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now socket.off() their named handlers on cleanup (were accumulating listeners on every reconnect → duplicated invalidations/tree-walks). Mirrors use-notification-socket. - Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm); structural events keep the blanket invalidate. - usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers that read only title/permissions/id, so they stop re-rendering every ~3s while typing / on every collab page.updated (page.tsx keeps the full query for content). - page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav). - Removed refetchOnMount:true where socket/mutation invalidation already keeps the cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries with NO other freshness path (trash-list, created-by, recent-changes) — the global default is refetchOnMount:false, so those overrides are load-bearing. - Small: resize mousemove/up attached only while dragging; per-row emoji-picker keydown gated on `opened`; AiChatWindow queries enabled only when the window is open. Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites), build ok. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
8.6 KiB
TypeScript
271 lines
8.6 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Anchor,
|
|
Button,
|
|
Group,
|
|
Indicator,
|
|
Popover,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
useCreateShareMutation,
|
|
useDeleteShareMutation,
|
|
useShareForPageQuery,
|
|
useUpdateShareMutation,
|
|
} from "@/features/share/queries/share-query.ts";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { extractPageSlugId, getPageIcon } from "@/lib";
|
|
import { useTranslation } from "react-i18next";
|
|
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
|
import CopyTextButton from "@/components/common/copy.tsx";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import classes from "@/features/share/components/share.module.css";
|
|
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
|
import { useAtom } from "jotai";
|
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
|
|
|
interface ShareModalProps {
|
|
readOnly: boolean;
|
|
}
|
|
export default function ShareModal({ readOnly }: ShareModalProps) {
|
|
const { t } = useTranslation();
|
|
const { pageSlug } = useParams();
|
|
const pageSlugId = extractPageSlugId(pageSlug);
|
|
const { data: page } = usePageMetaQuery({ pageId: pageSlugId });
|
|
const pageId = page?.id;
|
|
const { data: share } = useShareForPageQuery(pageId);
|
|
const { spaceSlug } = useParams();
|
|
const [workspace] = useAtom(workspaceAtom);
|
|
const { data: space } = useSpaceQuery(spaceSlug);
|
|
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
|
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
|
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
|
const createShareMutation = useCreateShareMutation();
|
|
const updateShareMutation = useUpdateShareMutation();
|
|
const deleteShareMutation = useDeleteShareMutation();
|
|
// pageIsShared means that the share exists and its level equals zero.
|
|
const pageIsShared = share && share.level === 0;
|
|
// if level is greater than zero, then it is a descendant page from a shared page
|
|
const isDescendantShared = share && share.level > 0;
|
|
|
|
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
|
|
|
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
|
useEffect(() => {
|
|
if (share) {
|
|
setIsPagePublic(true);
|
|
} else {
|
|
setIsPagePublic(false);
|
|
}
|
|
}, [share, pageId]);
|
|
|
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.currentTarget.checked;
|
|
setIsPagePublic(value);
|
|
|
|
try {
|
|
if (value) {
|
|
await createShareMutation.mutateAsync({
|
|
pageId: pageId,
|
|
// Opt-in: enabling a share must NOT silently expose the whole
|
|
// sub-tree (#216). Sub-pages are shared only when the user turns on
|
|
// the dedicated "Include sub-pages" toggle.
|
|
includeSubPages: false,
|
|
searchIndexing: false,
|
|
});
|
|
} else if (share && share.id) {
|
|
await deleteShareMutation.mutateAsync(share.id);
|
|
}
|
|
} catch {
|
|
setIsPagePublic(!value);
|
|
}
|
|
};
|
|
|
|
const handleSubPagesChange = async (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const value = event.currentTarget.checked;
|
|
try {
|
|
await updateShareMutation.mutateAsync({
|
|
shareId: share.id,
|
|
includeSubPages: value,
|
|
});
|
|
} catch {
|
|
// query invalidation will revert the UI
|
|
}
|
|
};
|
|
|
|
const handleIndexSearchChange = async (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const value = event.currentTarget.checked;
|
|
try {
|
|
await updateShareMutation.mutateAsync({
|
|
shareId: share.id,
|
|
searchIndexing: value,
|
|
});
|
|
} catch {
|
|
// query invalidation will revert the UI
|
|
}
|
|
};
|
|
|
|
const shareLink = useMemo(
|
|
() => (
|
|
<Group my="sm" gap={4} wrap="nowrap">
|
|
<TextInput
|
|
variant="filled"
|
|
value={publicLink}
|
|
readOnly
|
|
rightSection={<CopyTextButton text={publicLink} />}
|
|
style={{ width: "100%" }}
|
|
/>
|
|
<ActionIcon
|
|
component="a"
|
|
variant="default"
|
|
target="_blank"
|
|
href={publicLink}
|
|
size="sm"
|
|
>
|
|
<IconExternalLink size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
),
|
|
[publicLink],
|
|
);
|
|
|
|
return (
|
|
<Popover width={350} position="bottom" withArrow shadow="md">
|
|
<Popover.Target>
|
|
<Button
|
|
size="compact-sm"
|
|
leftSection={
|
|
<Indicator
|
|
color="green"
|
|
offset={5}
|
|
disabled={!isPagePublic}
|
|
withBorder
|
|
>
|
|
<IconWorld size={20} stroke={1.5} />
|
|
</Indicator>
|
|
}
|
|
color="dark"
|
|
variant="subtle"
|
|
>
|
|
{t("Share")}
|
|
</Button>
|
|
</Popover.Target>
|
|
<Popover.Dropdown style={{ userSelect: "none" }}>
|
|
{sharingDisabled ? (
|
|
<>
|
|
<Group justify="center" mb="sm">
|
|
<IconLock size={20} stroke={1.5} />
|
|
</Group>
|
|
<Text size="sm" ta="center" fw={500} mb="xs">
|
|
{t("Public sharing is disabled")}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{workspaceDisabled
|
|
? t("Public sharing has been disabled at the workspace level.")
|
|
: t("Public sharing has been disabled for this space.")}
|
|
</Text>
|
|
</>
|
|
) : isDescendantShared ? (
|
|
<>
|
|
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
|
<Anchor
|
|
size="sm"
|
|
underline="never"
|
|
style={{
|
|
cursor: "pointer",
|
|
color: "var(--mantine-color-text)",
|
|
}}
|
|
component={Link}
|
|
to={buildPageUrl(
|
|
spaceSlug,
|
|
share.sharedPage.slugId,
|
|
share.sharedPage.title,
|
|
)}
|
|
>
|
|
<Group gap="4" wrap="nowrap" my="sm">
|
|
{getPageIcon(share.sharedPage.icon)}
|
|
<div className={classes.shareLinkText}>
|
|
<Text fz="sm" fw={500} lineClamp={1}>
|
|
{share.sharedPage.title || t("Untitled")}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
</Anchor>
|
|
|
|
{shareLink}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
<div>
|
|
<Text size="sm">
|
|
{isPagePublic ? t("Shared to web") : t("Share to web")}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{isPagePublic
|
|
? t("Anyone with the link can view this page")
|
|
: t("Make this page publicly accessible")}
|
|
</Text>
|
|
</div>
|
|
<Switch
|
|
onChange={handleChange}
|
|
defaultChecked={isPagePublic}
|
|
disabled={readOnly}
|
|
size="xs"
|
|
/>
|
|
</Group>
|
|
|
|
{pageIsShared && (
|
|
<>
|
|
{shareLink}
|
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
<div>
|
|
<Text size="sm">{t("Include sub-pages")}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{t("Make sub-pages public too")}
|
|
</Text>
|
|
</div>
|
|
|
|
<Switch
|
|
onChange={handleSubPagesChange}
|
|
checked={share.includeSubPages}
|
|
size="xs"
|
|
disabled={readOnly}
|
|
/>
|
|
</Group>
|
|
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
|
<div>
|
|
<Text size="sm">{t("Search engine indexing")}</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{t("Allow search engines to index page")}
|
|
</Text>
|
|
</div>
|
|
<Switch
|
|
onChange={handleIndexSearchChange}
|
|
checked={share.searchIndexing}
|
|
size="xs"
|
|
disabled={readOnly}
|
|
/>
|
|
</Group>
|
|
{pageId && (
|
|
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
}
|