From 9db5a8df3678ee43012208a9b112d5f3836cebb9 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:22:24 -0700 Subject: [PATCH] Desktop, Cli, Mobile: Fixes #16153: Support unpublishing notes published by other users (#16246) --- .gitignore | 1 + .ignore.eslint | 1 + packages/app-desktop/gui/ShareNoteDialog.tsx | 34 +++++---- .../screens/ShareNoteDialog.test.tsx | 70 +++++++++++++++++-- .../components/screens/ShareNoteDialog.tsx | 45 ++++++++---- .../shared/ShareNoteDialog/useIsPublished.ts | 22 ++++++ packages/lib/services/share/ShareService.ts | 43 +++++++++++- 7 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 packages/lib/components/shared/ShareNoteDialog/useIsPublished.ts diff --git a/.gitignore b/.gitignore index 74bd27a07e..ccae77c94e 100644 --- a/.gitignore +++ b/.gitignore @@ -1390,6 +1390,7 @@ packages/lib/components/shared/SamlShared.js packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js packages/lib/components/shared/ShareNoteDialog/types.js packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js +packages/lib/components/shared/ShareNoteDialog/useIsPublished.js packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js packages/lib/components/shared/SsoScreenShared.js diff --git a/.ignore.eslint b/.ignore.eslint index 05fcab3eda..cf6ab96857 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -1415,6 +1415,7 @@ packages/lib/components/shared/SamlShared.js packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js packages/lib/components/shared/ShareNoteDialog/types.js packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js +packages/lib/components/shared/ShareNoteDialog/useIsPublished.js packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js packages/lib/components/shared/SsoScreenShared.js diff --git a/packages/app-desktop/gui/ShareNoteDialog.tsx b/packages/app-desktop/gui/ShareNoteDialog.tsx index 2da56bc1a3..fc61cbfe04 100644 --- a/packages/app-desktop/gui/ShareNoteDialog.tsx +++ b/packages/app-desktop/gui/ShareNoteDialog.tsx @@ -17,6 +17,7 @@ import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/on import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage'; import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage'; import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types'; +import useIsPublished from '@joplin/lib/components/shared/ShareNoteDialog/useIsPublished'; import { clipboard } from 'electron'; interface Props { @@ -66,22 +67,10 @@ export function ShareNoteDialog(props: Props) { recursiveShare, }); - const renderNote = (note: NoteEntity) => { - const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : ( - ; }; +interface NoteItemProps { + note: NoteEntity; + shares: StateShare[]; + onUnpublishStart: ()=> void; + styles: Styles; +} + +const NoteItem: React.FC = ({ note, shares, onUnpublishStart, styles }) => { + const published = useIsPublished(note, shares); + + const unshareButton = published ? ( + + ) : null; + + return ( + + {note.title}{unshareButton} + + ); +}; + const ShareNoteDialogContent: React.FC = ({ themeId, noteId, shares, }) => { @@ -120,22 +143,18 @@ const ShareNoteDialogContent: React.FC = ({ const styles = useStyles(themeId); - const renderNote = (note: NoteEntity) => { - const unshareButton = shares.find(s => s.note_id === note.id) ? ( - - ) : null; - - return ( - - {note.title}{unshareButton} - - ); - }; - const renderNoteList = (notes: NoteEntity[]) => { const noteComps = []; for (const note of notes) { - noteComps.push(renderNote(note)); + noteComps.push( + , + ); } return {noteComps}; }; diff --git a/packages/lib/components/shared/ShareNoteDialog/useIsPublished.ts b/packages/lib/components/shared/ShareNoteDialog/useIsPublished.ts new file mode 100644 index 0000000000..8917858bf6 --- /dev/null +++ b/packages/lib/components/shared/ShareNoteDialog/useIsPublished.ts @@ -0,0 +1,22 @@ +import { FolderEntity, NoteEntity } from '../../../services/database/types'; +import { StateShare } from '../../../services/share/reducer'; +import useAsyncEffect from '../../../hooks/useAsyncEffect'; +import ShareService from '../../../services/share/ShareService'; +import shim from '../../../shim'; +const { useState } = shim.react(); + +const useIsPublished = (item: NoteEntity|FolderEntity, shares: StateShare[]) => { + const [isPublished, setIsPublished] = useState(false); + + useAsyncEffect(async (event) => { + const shared = await ShareService.instance().isPublished(item, shares); + + if (!event.cancelled) { + setIsPublished(shared); + } + }, [shares, item]); + + return isPublished; +}; + +export default useIsPublished; diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index a11702541e..79e6b5e30f 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -6,7 +6,7 @@ import Folder from '../../models/Folder'; import MasterKey from '../../models/MasterKey'; import Note from '../../models/Note'; import Setting from '../../models/Setting'; -import { FolderEntity } from '../database/types'; +import { FolderEntity, NoteEntity } from '../database/types'; import EncryptionService from '../e2ee/EncryptionService'; import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword, supportsPpkAlgorithm } from '../e2ee/ppk/ppk'; import { MasterKeyEntity } from '../e2ee/types'; @@ -325,8 +325,8 @@ export default class ShareService { const note = await Note.load(noteId); if (!note) throw new Error(`No such note: ${noteId}`); - const shares = await this.refreshShares(); - const noteShares = shares.filter(s => s.note_id === noteId); + const noteShares = (await this.loadSharesByItem(noteId)) + .filter(s => s.type === ShareType.Note); const promises: Promise[] = []; @@ -415,6 +415,43 @@ export default class ShareService { await this.api().exec('DELETE', `api/shares/${shareId}`); } + public async isPublished(item: NoteEntity|FolderEntity, shares: StateShare[]) { + // Try to determine whether the item is published without doing a network request: + const isPublishedItemShare = (s: StateShare) => s.type === ShareType.Note || s.type === ShareType.PublishedFolder; + if (shares.some(s => ( + isPublishedItemShare(s) && + (s.folder_id === item.id || s.note_id === item.id) + ))) { + return true; + } + + // In some cases, an item can have is_shared = 1, but no share in `shares`. In this case, + // either the item has been unpublished remotely and not yet synced, or the item was published + // by another user. Send a network request to determine whether the item is actually published: + if (item.is_shared) { + const shares = (await this.loadSharesByItem(item.id)) + .filter(isPublishedItemShare); + return shares.length > 0; + } + + return false; + } + + private async loadSharesByItem(itemId: string) { + const shares = await this.api().exec('GET', 'api/shares', { item: itemId }); + if (!Array.isArray(shares?.items)) throw new Error(`Invalid response: Shares list is not an array. Was ${typeof shares?.items}.`); + + const items: StateShare[] = shares.items.filter( + // For compatibility with older server versions that don't support search + (i: StateShare) => ( + (i.type === ShareType.Note && i.note_id === itemId) + || (i.type === ShareType.PublishedFolder && i.folder_id === itemId) + || (i.type === ShareType.Folder && i.folder_id === itemId) + ), + ); + return items; + } + private async loadShares() { return this.api().exec('GET', 'api/shares'); }