Skip to content

Commit 0e0c85f

Browse files
ENG-1283: Allow node sharing with groups in Obsidian (#1123)
* ENG-1283: Add group picker for publishing nodes in Obsidian. Let users choose which sharing groups to publish to from the discourse context sidebar and command palette, including publish-to-all support. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix review feedback and simplify group publish orchestration. Use sequential publish to avoid relations.json races, read fresh frontmatter in the command picker, consolidate group loading via getMyGroups, and add dark mode styles to the publish dropdown. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix react-hooks lint in PublishGroupDropdown. Memoize publishedToGroups so useCallback dependencies stay stable across renders. Co-authored-by: Cursor <cursoragent@cursor.com> * Use group_membership for group lookups consistently. Restore getAvailableGroupIds to query group_membership like main, and derive getMyGroups from the same table with an embedded my_groups join for display names. Co-authored-by: Cursor <cursoragent@cursor.com> * fixes to sync between the suggest modal and the dropdown --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8c4fa43 commit 0e0c85f

7 files changed

Lines changed: 613 additions & 87 deletions

File tree

apps/obsidian/src/components/DiscourseContextView.tsx

Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
TFile,
44
WorkspaceLeaf,
55
Notice,
6-
FrontMatterCache,
76
setIcon,
87
setTooltip,
98
} from "obsidian";
@@ -19,9 +18,9 @@ import {
1918
getUserNameById,
2019
} from "~/utils/typeUtils";
2120
import { refreshImportedFile } from "~/utils/importNodes";
22-
import { publishNode } from "~/utils/publishNode";
21+
import { PublishGroupDropdown } from "~/components/PublishGroupDropdown";
2322
import { createBaseForNodeType } from "~/utils/baseForNodeType";
24-
import { useState, useEffect } from "react";
23+
import { useState } from "react";
2524

2625
type DiscourseContextProps = {
2726
activeFile: TFile | null;
@@ -45,28 +44,6 @@ export const InfoTooltip = ({ content }: InfoTooltipProps) => (
4544
const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
4645
const plugin = usePlugin();
4746
const [isRefreshing, setIsRefreshing] = useState(false);
48-
const [isPublishing, setIsPublishing] = useState(false);
49-
const [isPublished, setIsPublished] = useState(false);
50-
51-
useEffect(() => {
52-
if (!activeFile || !plugin) {
53-
setIsPublished(false);
54-
return;
55-
}
56-
const fileMetadata = plugin.app.metadataCache.getFileCache(activeFile);
57-
const frontmatter = fileMetadata?.frontmatter;
58-
if (!frontmatter) {
59-
setIsPublished(false);
60-
return;
61-
}
62-
const isImported = !!frontmatter.importedFromRid;
63-
const publishedToGroups = frontmatter.publishedToGroups as unknown;
64-
const published =
65-
!isImported &&
66-
Array.isArray(publishedToGroups) &&
67-
publishedToGroups.length > 0;
68-
setIsPublished(published);
69-
}, [activeFile, plugin]);
7047

7148
const extractContentFromTitle = (format: string, title: string): string => {
7249
if (!format) return "";
@@ -99,29 +76,6 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
9976
}
10077
};
10178

102-
const handlePublish = async (frontmatter: FrontMatterCache) => {
103-
if (!activeFile || isPublishing) return;
104-
105-
if (!frontmatter.nodeInstanceId) {
106-
new Notice("Please sync the node first", 5000);
107-
return;
108-
}
109-
110-
setIsPublishing(true);
111-
try {
112-
await publishNode({ plugin, file: activeFile, frontmatter });
113-
new Notice("Published successfully", 3000);
114-
setIsPublished(true);
115-
} catch (error) {
116-
const errorMessage =
117-
error instanceof Error ? error.message : String(error);
118-
new Notice(`Publish failed: ${errorMessage}`, 5000);
119-
console.error("Publish failed:", error);
120-
} finally {
121-
setIsPublishing(false);
122-
}
123-
};
124-
12579
const renderContent = () => {
12680
if (!activeFile) {
12781
return <div>No file is open</div>;
@@ -212,28 +166,7 @@ const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
212166
</button>
213167
)}
214168
{canPublish && (
215-
<button
216-
onClick={() => {
217-
void handlePublish(frontmatter);
218-
}}
219-
disabled={isPublishing}
220-
className={`ml-auto rounded px-2 py-1 text-xs ${
221-
isPublished
222-
? "border border-green-600 bg-green-200 text-green-800 dark:bg-green-900/60 dark:text-green-300"
223-
: "border border-gray-400 bg-gray-100 font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
224-
}`}
225-
title={
226-
isPublished
227-
? "Re-publish to lab space"
228-
: "Publish to lab space"
229-
}
230-
>
231-
{isPublishing
232-
? "Publishing..."
233-
: isPublished
234-
? "✅ Published"
235-
: "Publish"}
236-
</button>
169+
<PublishGroupDropdown plugin={plugin} file={activeFile} />
237170
)}
238171
</div>
239172

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import { type TFile } from "obsidian";
3+
import type DiscourseGraphPlugin from "~/index";
4+
import {
5+
getPublishedToGroups,
6+
getPublishToAllTitle,
7+
getUnpublishedGroups,
8+
loadMyGroups,
9+
notifyPublishError,
10+
publishToAllGroupsWithNotice,
11+
publishToSelectedGroupWithNotice,
12+
withPublishedState,
13+
} from "~/utils/publishGroupSelection";
14+
import type { MyGroup } from "~/utils/importNodes";
15+
16+
type PublishGroupDropdownProps = {
17+
plugin: DiscourseGraphPlugin;
18+
file: TFile;
19+
};
20+
21+
export const PublishGroupDropdown = ({
22+
plugin,
23+
file,
24+
}: PublishGroupDropdownProps) => {
25+
const containerRef = useRef<HTMLDivElement>(null);
26+
const [groups, setGroups] = useState<MyGroup[]>([]);
27+
const [isOpen, setIsOpen] = useState(false);
28+
const [isLoading, setIsLoading] = useState(false);
29+
const [isPublishing, setIsPublishing] = useState(false);
30+
const [loadError, setLoadError] = useState<string | null>(null);
31+
const [, setMetadataVersion] = useState(0);
32+
33+
const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter;
34+
const publishedToGroups = useMemo(
35+
() => (frontmatter ? getPublishedToGroups(frontmatter) : []),
36+
[frontmatter],
37+
);
38+
const groupsWithPublishedState = withPublishedState(
39+
groups,
40+
publishedToGroups,
41+
);
42+
const unpublishedGroups = getUnpublishedGroups(groupsWithPublishedState);
43+
44+
useEffect(() => {
45+
const ref = plugin.app.metadataCache.on("changed", (changedFile) => {
46+
if (changedFile.path === file.path) {
47+
setMetadataVersion((version) => version + 1);
48+
}
49+
});
50+
51+
return () => {
52+
plugin.app.metadataCache.offref(ref);
53+
};
54+
}, [plugin.app.metadataCache, file.path]);
55+
56+
useEffect(() => {
57+
if (!isOpen) return;
58+
59+
let cancelled = false;
60+
61+
const loadGroups = async () => {
62+
setIsLoading(true);
63+
setLoadError(null);
64+
try {
65+
const myGroups = await loadMyGroups(plugin);
66+
if (!cancelled) {
67+
setGroups(myGroups);
68+
}
69+
} catch (error) {
70+
if (!cancelled) {
71+
setLoadError(error instanceof Error ? error.message : String(error));
72+
setGroups([]);
73+
}
74+
} finally {
75+
if (!cancelled) {
76+
setIsLoading(false);
77+
}
78+
}
79+
};
80+
81+
void loadGroups();
82+
83+
return () => {
84+
cancelled = true;
85+
};
86+
}, [plugin, isOpen]);
87+
88+
useEffect(() => {
89+
if (!isOpen) return;
90+
91+
const handlePointerDown = (event: PointerEvent) => {
92+
if (
93+
containerRef.current &&
94+
!containerRef.current.contains(event.target as Node)
95+
) {
96+
setIsOpen(false);
97+
}
98+
};
99+
100+
document.addEventListener("pointerdown", handlePointerDown);
101+
return () => document.removeEventListener("pointerdown", handlePointerDown);
102+
}, [isOpen]);
103+
104+
const runPublishAction = useCallback(
105+
async (action: () => Promise<void>, onSuccess?: () => void) => {
106+
if (isPublishing) return;
107+
108+
setIsPublishing(true);
109+
try {
110+
await action();
111+
onSuccess?.();
112+
} catch (error) {
113+
notifyPublishError(error);
114+
} finally {
115+
setIsPublishing(false);
116+
}
117+
},
118+
[isPublishing],
119+
);
120+
121+
const handlePublishToGroup = useCallback(
122+
(groupId: string) => {
123+
if (publishedToGroups.includes(groupId)) return;
124+
125+
void runPublishAction(async () => {
126+
await publishToSelectedGroupWithNotice({ plugin, file, groupId });
127+
setIsOpen(false);
128+
});
129+
},
130+
[plugin, file, publishedToGroups, runPublishAction],
131+
);
132+
133+
const handlePublishToAllGroups = useCallback(() => {
134+
if (isLoading || unpublishedGroups.length === 0) return;
135+
136+
void runPublishAction(async () => {
137+
await publishToAllGroupsWithNotice({ plugin, file });
138+
setIsOpen(false);
139+
});
140+
}, [plugin, file, isLoading, unpublishedGroups.length, runPublishAction]);
141+
142+
if (!frontmatter) {
143+
return null;
144+
}
145+
146+
const publishedCount = publishedToGroups.length;
147+
const triggerLabel =
148+
publishedCount > 0 ? `Published (${publishedCount})` : "Publish";
149+
150+
return (
151+
<div ref={containerRef} className="relative ml-auto shrink-0">
152+
<button
153+
type="button"
154+
onClick={() => setIsOpen((open) => !open)}
155+
disabled={isLoading && isOpen}
156+
className={`rounded border px-2 py-1 text-xs ${
157+
publishedCount > 0
158+
? "border-green-600 bg-green-200 text-green-800 dark:bg-green-900/60 dark:text-green-100"
159+
: "border border-gray-400 bg-gray-100 font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700"
160+
}`}
161+
title="Publish to a group"
162+
>
163+
{isPublishing ? "Publishing..." : triggerLabel}
164+
</button>
165+
166+
{isOpen && (
167+
<div className="absolute right-0 z-50 mt-1 min-w-[12rem] rounded border border-gray-200 bg-white py-1 shadow-md dark:border-gray-600 dark:bg-gray-900">
168+
<div
169+
role="button"
170+
tabIndex={0}
171+
onClick={() => handlePublishToAllGroups()}
172+
onKeyDown={(event) => {
173+
if (event.key === "Enter" || event.key === " ") {
174+
event.preventDefault();
175+
handlePublishToAllGroups();
176+
}
177+
}}
178+
className={`border-b border-gray-200 px-3 py-1.5 text-xs font-medium dark:border-gray-600 ${
179+
isLoading || isPublishing || unpublishedGroups.length === 0
180+
? "cursor-default text-gray-400 dark:text-gray-500"
181+
: "cursor-pointer text-gray-900 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800"
182+
}`}
183+
title={getPublishToAllTitle(unpublishedGroups.length)}
184+
>
185+
Publish to all groups
186+
</div>
187+
188+
{isLoading && (
189+
<div className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">
190+
Loading groups...
191+
</div>
192+
)}
193+
194+
{loadError && (
195+
<div className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">
196+
{loadError}
197+
</div>
198+
)}
199+
200+
{!isLoading &&
201+
!loadError &&
202+
groupsWithPublishedState.length === 0 && (
203+
<div className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">
204+
You are not a member of any groups.
205+
</div>
206+
)}
207+
208+
{!isLoading &&
209+
!loadError &&
210+
groupsWithPublishedState.map((group) => (
211+
<button
212+
key={group.id}
213+
type="button"
214+
disabled={isPublishing || group.isPublished}
215+
onClick={() => handlePublishToGroup(group.id)}
216+
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium ${
217+
group.isPublished
218+
? "cursor-default opacity-80"
219+
: "hover:bg-gray-100 dark:hover:bg-gray-800"
220+
}`}
221+
title={
222+
group.isPublished
223+
? "Already published to this group"
224+
: `Publish to ${group.name}`
225+
}
226+
>
227+
<span className="inline-flex w-4 shrink-0 justify-center">
228+
{group.isPublished ? "✓" : ""}
229+
</span>
230+
<span className="truncate">{group.name}</span>
231+
</button>
232+
))}
233+
</div>
234+
)}
235+
</div>
236+
);
237+
};

0 commit comments

Comments
 (0)