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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions apps/roam/src/components/Export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import {
type NodeUidWithType,
} from "~/utils/publishNodesToGroups";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
import { isSyncEnabled } from "~/components/settings/utils/accessors";
import { isNodeSharingEnabled } from "~/components/settings/utils/accessors";

const ExportProgress = ({ id }: { id: string }) => {
const [progress, setProgress] = useState(0);
Expand Down Expand Up @@ -218,12 +218,12 @@ const ExportDialog: ExportDialogComponent = ({
useState<(typeof SEND_TO_DESTINATIONS)[number]>("page");
const isSendToGraph = activeSendToDestination === "graph";
const [livePages, setLivePages] = useState<Result[]>([]);
const syncEnabled = useMemo(() => isSyncEnabled(), []);
const sharingEnabled = useMemo(() => isNodeSharingEnabled(), []);
const [selectedTabId, setSelectedTabId] = useState("sendto");
useEffect(() => {
if (initialPanel === "publish" && !syncEnabled) return;
if (initialPanel === "publish" && !sharingEnabled) return;
if (initialPanel) setSelectedTabId(INITIAL_PANEL_TO_TAB_ID[initialPanel]);
}, [initialPanel, syncEnabled]);
}, [initialPanel, sharingEnabled]);
const [includeDiscourseContext, setIncludeDiscourseContext] = useState(false);
const [gitHubAccessToken, setGitHubAccessToken] = useState<string | null>(
getSetting<string | null>("oauth-github", null),
Expand All @@ -240,7 +240,7 @@ const ExportDialog: ExportDialogComponent = ({

const publishableNodes = useMemo(
() =>
syncEnabled
sharingEnabled
? results
.map((r) => {
const node = findDiscourseNode({ uid: r.uid });
Expand All @@ -250,7 +250,7 @@ const ExportDialog: ExportDialogComponent = ({
})
.filter((n): n is NodeUidWithType => n !== null)
: [],
[results, syncEnabled],
[results, sharingEnabled],
);
const nonDiscourseCount = results.length - publishableNodes.length;

Expand Down Expand Up @@ -808,7 +808,7 @@ const ExportDialog: ExportDialogComponent = ({
};
useEffect(() => {
if (
!syncEnabled ||
!sharingEnabled ||
!isOpen ||
selectedTabId !== "publish" ||
groupsLoaded ||
Expand All @@ -829,7 +829,7 @@ const ExportDialog: ExportDialogComponent = ({
setGroupsLoaded(true);
}
})();
}, [syncEnabled, isOpen, selectedTabId, groupsLoaded, groupsLoading]);
}, [sharingEnabled, isOpen, selectedTabId, groupsLoaded, groupsLoading]);

const handlePublish = async () => {
setPublishError("");
Expand All @@ -840,7 +840,7 @@ const ExportDialog: ExportDialogComponent = ({
if (!client || !context) throw new Error("Could not connect to sync.");
const {
publishedNodeUids,
skippedUnsyncedUids,
failedUpsertUids,
okGroupIds,
failedGroupIds,
} = await publishNodeUidsWithTypeToGroups({
Expand All @@ -849,10 +849,14 @@ const ExportDialog: ExportDialogComponent = ({
groupIds: selectedGroupIds,
nodeUids: publishableNodes,
});
const selectedNodeUids = new Set(publishableNodes.map((n) => n.uid));
const failedNodeCount = failedUpsertUids.filter((uid) =>
selectedNodeUids.has(uid),
).length;
posthog.capture("Export Dialog: Publish", {
groupCount: okGroupIds.length,
publishedNodeCount: publishedNodeUids.length,
skippedUnsyncedCount: skippedUnsyncedUids.length,
failedUpsertCount: failedUpsertUids.length,
nonDiscourseCount,
failedGroupCount: failedGroupIds.length,
});
Expand All @@ -866,10 +870,8 @@ const ExportDialog: ExportDialogComponent = ({
}.`,
]
: ["No nodes were published."];
if (skippedUnsyncedUids.length)
messages.push(
`${skippedUnsyncedUids.length} not synced yet — try again shortly.`,
);
if (failedNodeCount)
messages.push(`${failedNodeCount} failed to publish.`);
if (nonDiscourseCount)
messages.push(`${nonDiscourseCount} skipped (not discourse nodes).`);
if (failedGroupIds.length)
Expand All @@ -881,7 +883,9 @@ const ExportDialog: ExportDialogComponent = ({
renderToast({
content: messages.join(" "),
intent:
failedGroupIds.length || !hasPublishedNodes ? "warning" : "success",
failedGroupIds.length || failedNodeCount || !hasPublishedNodes
? "warning"
: "success",
id: "query-builder-publish-success",
});
if (hasPublishedNodes) onClose();
Expand Down Expand Up @@ -1263,7 +1267,7 @@ const ExportDialog: ExportDialogComponent = ({
>
<Tab id="sendto" title="Send To" panel={SendToPanel} />
<Tab id="export" title="Export" panel={ExportPanel} />
{syncEnabled && (
{sharingEnabled && (
<Tab id="publish" title="Publish" panel={PublishPanel} />
)}
</Tabs>
Expand Down
63 changes: 63 additions & 0 deletions apps/roam/src/components/RefreshImportedNodeTitleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Button } from "@blueprintjs/core";
import posthog from "posthog-js";
import React, { useState } from "react";
import renderToast from "roamjs-components/components/Toast";
import { handleTitleAdditions } from "~/utils/handleTitleAdditions";
import { refreshImportedNode } from "~/utils/refreshImportedNode";

const REFRESH_TITLE_BUTTON_ATTRIBUTE =
"data-roamjs-refresh-imported-node-title-button";

const RefreshImportedNodeTitleButton = ({
uid,
}: {
uid: string;
}): JSX.Element => {
const [refreshing, setRefreshing] = useState(false);

const refresh = async (): Promise<void> => {
setRefreshing(true);
try {
const result = await refreshImportedNode({ pageUid: uid, force: true });
const failed = result.status === "failed";
renderToast({
id: failed
? "refresh-imported-node-failed"
: "refresh-imported-node-success",
intent: failed ? "danger" : "success",
content: result.message,
});
} finally {
setRefreshing(false);
}
};

return (
<Button
text="Refresh"
icon="refresh"
minimal
outlined
loading={refreshing}
onClick={() => {
posthog.capture("Refresh Imported Node: Page Title Button Triggered", {
pageUid: uid,
});
void refresh();
}}
/>
);
};

export const renderRefreshImportedNodeTitleButton = ({
h1,
uid,
}: {
h1: HTMLHeadingElement;
uid: string;
}): void => {
if (h1.getAttribute(REFRESH_TITLE_BUTTON_ATTRIBUTE) === uid) return;

h1.setAttribute(REFRESH_TITLE_BUTTON_ATTRIBUTE, uid);
handleTitleAdditions(h1, <RefreshImportedNodeTitleButton uid={uid} />);
};
6 changes: 6 additions & 0 deletions apps/roam/src/components/settings/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ const FeatureFlagsTab = (): React.ReactElement => {
onAfterChange={(checked) => setAdvancedNodeSearchValue(checked)}
/>

<FeatureFlagPanel
title="Node sharing"
description="This enables a user to share nodes to other discourse spaces and keeps shared nodes synced in the background. Reload the graph after toggling."
featureKey="Enable node sharing"
/>

<Alert
isOpen={isConsentAlertOpen}
onConfirm={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import getSubTree from "roamjs-components/util/getSubTree";
import { DiscourseNode } from "~/utils/getDiscourseNodes";
import extractRef from "roamjs-components/util/extractRef";
import { getAllDiscourseNodesSince } from "~/utils/getAllDiscourseNodesSince";
import { getImportedNodeUids } from "~/utils/importedSourceIdentity";
import { upsertNodesToSupabaseAsContentWithEmbeddings } from "~/utils/syncDgNodesToSupabase";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
import {
Expand Down Expand Up @@ -56,8 +57,11 @@ const DiscourseNodeSuggestiveRules = ({

const context = await getSupabaseContext();
if (context && blockNodesSince) {
const importedNodeUids = await getImportedNodeUids();
await upsertNodesToSupabaseAsContentWithEmbeddings(
blockNodesSince,
blockNodesSince.filter(
(node) => !importedNodeUids.has(node.source_local_id),
),
supabaseClient,
context,
);
Expand Down
3 changes: 3 additions & 0 deletions apps/roam/src/components/settings/utils/accessors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,9 @@ export const readAllLegacyDiscourseNodeSettings = (
export const isSyncEnabled = (): boolean =>
getFeatureFlag("Suggestive mode overlay enabled");

export const isNodeSharingEnabled = (): boolean =>
getFeatureFlag("Enable node sharing");

export const setFeatureFlag = (
key: keyof FeatureFlags,
value: boolean,
Expand Down
1 change: 1 addition & 0 deletions apps/roam/src/components/settings/utils/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {

export const FEATURE_FLAG_KEYS = {
enableLeftSidebar: "Enable left sidebar",
enableNodeSharing: "Enable node sharing",
suggestiveModeOverlayEnabled: "Suggestive mode overlay enabled",
useNewSettingsStore: "Use new settings store",
} as const satisfies Record<string, keyof FeatureFlags>;
Expand Down
2 changes: 2 additions & 0 deletions apps/roam/src/components/settings/utils/zodSchema.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,15 @@ const discourseNodeSettings: DiscourseNodeSettings = {
const featureFlags: FeatureFlags = {
"Advanced node search enabled": true,
"Enable left sidebar": true,
"Enable node sharing": true,
"Suggestive mode overlay enabled": true,
"Use new settings store": false,
};

const defaultFeatureFlags: FeatureFlags = {
"Advanced node search enabled": false,
"Enable left sidebar": false,
"Enable node sharing": false,
"Suggestive mode overlay enabled": false,
"Use new settings store": false,
};
Expand Down
1 change: 1 addition & 0 deletions apps/roam/src/components/settings/utils/zodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const DiscourseRelationSchema = z.object({
export const FeatureFlagsSchema = z.object({
"Advanced node search enabled": z.boolean().default(false),
"Enable left sidebar": z.boolean().default(false),
"Enable node sharing": z.boolean().default(false),
"Suggestive mode overlay enabled": z.boolean().default(false),
"Use new settings store": z.boolean().default(false),
});
Expand Down
3 changes: 2 additions & 1 deletion apps/roam/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { initPostHog } from "./utils/posthog";
import { initSchema } from "./components/settings/utils/init";
import {
bulkReadSettings,
isNodeSharingEnabled,
isSyncEnabled,
} from "./components/settings/utils/accessors";
import { PERSONAL_KEYS } from "./components/settings/utils/settingKeys";
Expand Down Expand Up @@ -134,7 +135,7 @@ export default runExtension(async (onloadArgs) => {
document.addEventListener("input", discourseNodeSearchTriggerListener);
document.addEventListener("selectionchange", nodeCreationPopoverListener);

if (isSyncEnabled()) {
if (isSyncEnabled() || isNodeSharingEnabled()) {
initializeSupabaseSync();
}

Expand Down
26 changes: 26 additions & 0 deletions apps/roam/src/utils/__tests__/materializeSharedNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,32 @@ describe("materializeSharedNode", () => {
expect(mockedWriteImportedSourceIdentity).not.toHaveBeenCalled();
});

it("force-updates an imported page whose source has not changed", async () => {
const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
mockedGetPageTitleByPageUid.mockReturnValue(sharedNode.title);
mockedReadImportedSourceIdentity.mockReturnValue({
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});

await expect(
materializeSharedNode({ client, sharedNode, force: true }),
).resolves.toEqual({
success: true,
action: "updated",
pageUid: EXISTING_PAGE_UID,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});
expect(blockFromMarkdown).toHaveBeenCalled();
expect(mockedWriteImportedSourceIdentity).toHaveBeenCalledWith({
pageUid: EXISTING_PAGE_UID,
sourceModifiedAt: sharedNode.lastModified,
sourceNodeRid: sharedNode.rid,
});
});

it("updates an imported page whose source changed since the import", async () => {
const { client } = clientWithFullContent({ text: FULL_MARKDOWN });
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(EXISTING_PAGE_UID);
Expand Down
49 changes: 49 additions & 0 deletions apps/roam/src/utils/__tests__/nodeSharingFeatureFlag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("~/utils/internalError", () => ({ default: vi.fn() }));
vi.mock("~/utils/extensionSettings", () => ({ getSetting: vi.fn() }));
vi.mock("~/utils/parseQuery", () => ({ roamNodeToCondition: vi.fn() }));

import {
isNodeSharingEnabled,
isSyncEnabled,
} from "~/components/settings/utils/accessors";

const seedWindow = (featureFlags: Record<string, boolean>) => {
(globalThis as { window: unknown }).window = {
roamAlphaAPI: {
user: { uid: () => "user-1" },
pull: () => ({
":block/children": [
{
":block/string": "Feature Flags",
":block/props": {
"Use new settings store": true,
...featureFlags,
},
},
],
}),
},
};
};

describe("node sharing feature flag", () => {
it("defaults to disabled alongside suggestive mode", () => {
seedWindow({});
expect(isNodeSharingEnabled()).toBe(false);
expect(isSyncEnabled()).toBe(false);
});

it("enables node sharing without suggestive mode", () => {
seedWindow({ "Enable node sharing": true });
expect(isNodeSharingEnabled()).toBe(true);
expect(isSyncEnabled()).toBe(false);
});

it("does not enable node sharing when only suggestive mode is on", () => {
seedWindow({ "Suggestive mode overlay enabled": true });
expect(isNodeSharingEnabled()).toBe(false);
expect(isSyncEnabled()).toBe(true);
});
});
Loading