diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 4949ce301..4f760c473 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -27,6 +27,7 @@ import ApplicationProvider from '../ApplicationProvider' import CommandProvider from '../CommandProvider' import PanesSystemComponent from '../Panes/PanesSystemComponent' import DotOrgNotice from './DotOrgNotice' +import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider' type Props = { application: WebApplication @@ -177,62 +178,61 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio application={application} featuresController={viewControllerManager.featuresController} > -
- - - - - <> -
- - - +
+ - - - {renderChallenges()} - - <> - - - - - - - - - - - {application.routeService.isDotOrg && } -
+ > + + + <> +
+ + + + + {renderChallenges()} + <> + + + + + + + + + + + {application.routeService.isDotOrg && } +
+ diff --git a/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx index 2cd54a786..d7a0e2eae 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx @@ -20,6 +20,7 @@ import { ElementIds } from '@/Constants/ElementIDs' import Menu from '../Menu/Menu' import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults' import { useApplication } from '../ApplicationProvider' +import { DecryptedItem } from '@standardnotes/snjs' type Props = { linkingController: LinkingController @@ -27,6 +28,7 @@ type Props = { focusedId: string | undefined setFocusedId: (id: string) => void hoverLabel?: string + item: DecryptedItem } const ItemLinkAutocompleteInput = ({ @@ -35,12 +37,16 @@ const ItemLinkAutocompleteInput = ({ focusedId, setFocusedId, hoverLabel, + item, }: Props) => { const application = useApplication() - const { tags, linkItemToSelectedItem, createAndAddNewTag, isEntitledToNoteLinking, activeItem } = linkingController + + const { getLinkedTagsForItem, linkItems, createAndAddNewTag, isEntitledToNoteLinking } = linkingController + + const tagsLinkedToItem = getLinkedTagsForItem(item) || [] const [searchQuery, setSearchQuery] = useState('') - const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, activeItem) + const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, item) const [dropdownVisible, setDropdownVisible] = useState(false) const [dropdownMaxHeight, setDropdownMaxHeight] = useState('auto') @@ -122,7 +128,7 @@ const ItemLinkAutocompleteInput = ({ 0 ? 'w-80' : 'mr-10 w-70'}`, + `${tagsLinkedToItem.length > 0 ? 'w-80' : 'mr-10 w-70'}`, 'bg-transparent text-sm text-text focus:border-b-2 focus:border-solid focus:border-info lg:text-xs', 'no-border h-7 focus:shadow-none focus:outline-none', )} @@ -141,7 +147,7 @@ const ItemLinkAutocompleteInput = ({ {areSearchResultsVisible && ( 0 ? 'w-80' : 'mr-10 w-70', + tagsLinkedToItem.length > 0 ? 'w-80' : 'mr-10 w-70', 'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main', )} style={{ @@ -159,7 +165,8 @@ const ItemLinkAutocompleteInput = ({ > { + const application = useApplication() + const activeItem = application.itemControllerGroup.activeItemViewController?.item + const { toggleAppPane } = useResponsiveAppPane() const commandService = useCommandService() - const { - allItemLinks, - notesLinkingToActiveItem, - filesLinkingToActiveItem, - unlinkItemFromSelectedItem: unlinkItem, - activateItem, - } = linkingController + const { unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController + + const { notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem, notesLinkingToItem, filesLinkingToItem } = + useItemLinks(activeItem) + + const allItemsLinkedToItem: ItemLink[] = useMemo( + () => new Array().concat(notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem), + [filesLinkedToItem, notesLinkedToItem, tagsLinkedToItem], + ) useEffect(() => { return commandService.addCommandHandler({ @@ -47,11 +54,11 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => { ) const [focusedId, setFocusedId] = useState() - const focusableIds = allItemLinks + const focusableIds = allItemsLinkedToItem .map((link) => link.id) .concat( - notesLinkingToActiveItem.map((link) => link.id), - filesLinkingToActiveItem.map((link) => link.id), + notesLinkingToItem.map((link) => link.id), + filesLinkingToItem.map((link) => link.id), [ElementIds.ItemLinkAutocompleteInput], ) @@ -84,9 +91,9 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => { ) const isItemBidirectionallyLinked = (link: ItemLink) => { - const existsInAllItemLinks = !!allItemLinks.find((item) => link.item.uuid === item.item.uuid) - const existsInNotesLinkingToItem = !!notesLinkingToActiveItem.find((item) => link.item.uuid === item.item.uuid) - const existsInFilesLinkingToItem = !!filesLinkingToActiveItem.find((item) => link.item.uuid === item.item.uuid) + const existsInAllItemLinks = !!allItemsLinkedToItem.find((item) => link.item.uuid === item.item.uuid) + const existsInNotesLinkingToItem = !!notesLinkingToItem.find((item) => link.item.uuid === item.item.uuid) + const existsInFilesLinkingToItem = !!filesLinkingToItem.find((item) => link.item.uuid === item.item.uuid) return ( existsInAllItemLinks && @@ -94,16 +101,20 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => { ) } + if (!activeItem) { + return null + } + return ( ) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx index ef0d4bcaa..34d197e0e 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx @@ -10,22 +10,24 @@ import { useCallback } from 'react' type Props = { createAndAddNewTag: LinkingController['createAndAddNewTag'] - linkItemToSelectedItem: LinkingController['linkItemToSelectedItem'] + linkItems: LinkingController['linkItems'] results: LinkableItem[] searchQuery: string shouldShowCreateTag: boolean onClickCallback?: () => void isEntitledToNoteLinking: boolean + item: LinkableItem } const LinkedItemSearchResults = ({ createAndAddNewTag, - linkItemToSelectedItem, + linkItems, results, searchQuery, shouldShowCreateTag, onClickCallback, isEntitledToNoteLinking, + item, }: Props) => { const onClickAddNew = useCallback( (searchQuery: string) => { @@ -44,7 +46,7 @@ const LinkedItemSearchResults = ({ key={result.uuid} className="flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop" onClick={() => { - void linkItemToSelectedItem(result) + void linkItems(item, result) onClickCallback?.() }} > diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx index c1d2d96e5..ef6cfcbb2 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx @@ -3,6 +3,7 @@ import { FilesController } from '@/Controllers/FilesController' import { LinkingController } from '@/Controllers/LinkingController' import { observer } from 'mobx-react-lite' import { useRef, useCallback } from 'react' +import { useApplication } from '../ApplicationProvider' import RoundIconButton from '../Button/RoundIconButton' import Popover from '../Popover/Popover' import StyledTooltip from '../StyledTooltip/StyledTooltip' @@ -16,6 +17,9 @@ type Props = { } const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing, featuresController }: Props) => { + const application = useApplication() + const activeItem = application.itemControllerGroup.activeItemViewController?.item + const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController const buttonRef = useRef(null) @@ -27,6 +31,10 @@ const LinkedItemsButton = ({ linkingController, filesController, onClickPreproce setIsLinkingPanelOpen(willMenuOpen) }, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen]) + if (!activeItem) { + return null + } + return ( <> @@ -34,6 +42,7 @@ const LinkedItemsButton = ({ linkingController, filesController, onClickPreproce { const { - tags, - linkedFiles, - filesLinkingToActiveItem, - notesLinkedToItem, - notesLinkingToActiveItem, - allItemLinks: allLinkedItems, - linkItemToSelectedItem, + getLinkedTagsForItem, + getFilesLinksForItem, + getLinkedNotesForItem, + getNotesLinkingToItem, + linkItems, unlinkItemFromSelectedItem, activateItem, createAndAddNewTag, isEntitledToNoteLinking, - activeItem, } = linkingController + const tagsLinkedToItem = getLinkedTagsForItem(item) || [] + const { filesLinkedToItem, filesLinkingToItem } = getFilesLinksForItem(item) + const notesLinkedToItem = getLinkedNotesForItem(item) || [] + const notesLinkingToItem = getNotesLinkingToItem(item) || [] + const { entitledToFiles } = featuresController const application = useApplication() const searchInputRef = useRef(null) const [searchQuery, setSearchQuery] = useState('') const isSearching = !!searchQuery.length - const { linkedResults, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults( - searchQuery, - application, - activeItem, - ) + const { linkedResults, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, item) useEffect(() => { if (isOpen && searchInputRef.current) { @@ -64,7 +65,7 @@ const LinkedItemsPanel = ({ } void filesController.selectAndUploadNewFiles((file) => { - void linkItemToSelectedItem(file) + void linkItems(item, file) }) } @@ -73,7 +74,7 @@ const LinkedItemsPanel = ({
Unlinked )} @@ -137,11 +139,11 @@ const LinkedItemsPanel = ({ ) : ( <> - {!!tags.length && ( + {!!tagsLinkedToItem.length && (
Linked Tags
- {tags.map((link) => ( + {tagsLinkedToItem.map((link) => ( Upload and link file(s) - {linkedFiles.map((link) => ( + {filesLinkedToItem.map((link) => (
- {!!filesLinkingToActiveItem.length && ( + {!!filesLinkingToItem.length && (
Files Linking To Current File
- {filesLinkingToActiveItem.map((link) => ( + {filesLinkingToItem.map((link) => (
)} - {!!notesLinkingToActiveItem.length && ( + {!!notesLinkingToItem.length && (
Notes Linking To This Note
- {notesLinkingToActiveItem.map((link) => ( + {notesLinkingToItem.map((link) => ( { @@ -659,8 +655,6 @@ export class ItemListController extends AbstractViewController implements Intern const controller = await this.createNewNoteController(useTitle, createdAt, autofocusBehavior) - this.linkingController.reloadAllLinks() - this.selectionController.scrollToItem(controller.item) } diff --git a/packages/web/src/javascripts/Controllers/LinkingController.tsx b/packages/web/src/javascripts/Controllers/LinkingController.tsx index 24552cb46..b76f92987 100644 --- a/packages/web/src/javascripts/Controllers/LinkingController.tsx +++ b/packages/web/src/javascripts/Controllers/LinkingController.tsx @@ -4,7 +4,6 @@ import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewCon import { AppPaneId } from '@/Components/Panes/AppPaneMetadata' import { PrefDefaults } from '@/Constants/PrefDefaults' import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem' -import { ItemLink } from '@/Utils/Items/Search/ItemLink' import { LinkableItem } from '@/Utils/Items/Search/LinkableItem' import { ApplicationEvent, @@ -28,11 +27,6 @@ import { SelectedItemsController } from './SelectedItemsController' import { SubscriptionController } from './Subscription/SubscriptionController' export class LinkingController extends AbstractViewController { - tags: ItemLink[] = [] - linkedFiles: ItemLink[] = [] - filesLinkingToActiveItem: ItemLink[] = [] - notesLinkedToItem: ItemLink[] = [] - notesLinkingToActiveItem: ItemLink[] = [] shouldLinkToParentFolders: boolean isLinkingPanelOpen = false private itemListController!: ItemListController @@ -48,22 +42,11 @@ export class LinkingController extends AbstractViewController { super(application, eventBus) makeObservable(this, { - tags: observable, - linkedFiles: observable, - filesLinkingToActiveItem: observable, - notesLinkedToItem: observable, - notesLinkingToActiveItem: observable, isLinkingPanelOpen: observable, - allItemLinks: computed, isEntitledToNoteLinking: computed, - selectedItemTitle: computed, setIsLinkingPanelOpen: action, - reloadLinkedFiles: action, - reloadLinkedTags: action, - reloadLinkedNotes: action, - reloadNotesLinkingToItem: action, }) this.shouldLinkToParentFolders = application.getPreference( @@ -89,19 +72,6 @@ export class LinkingController extends AbstractViewController { this.itemListController = itemListController this.filesController = filesController this.subscriptionController = subscriptionController - - this.disposers.push( - this.application.streamItems(ContentType.File, () => { - this.reloadLinkedFiles() - }), - this.application.streamItems(ContentType.Tag, () => { - this.reloadLinkedTags() - }), - this.application.streamItems(ContentType.Note, () => { - this.reloadLinkedNotes() - this.reloadNotesLinkingToItem() - }), - ) } get isEntitledToNoteLinking() { @@ -112,87 +82,61 @@ export class LinkingController extends AbstractViewController { this.isLinkingPanelOpen = open } - get allItemLinks() { - return [...this.tags, ...this.linkedFiles, ...this.notesLinkedToItem] - } - get activeItem() { return this.application.itemControllerGroup.activeItemViewController?.item } - get selectedItemTitle() { - return this.selectionController.firstSelectedItem - ? this.selectionController.firstSelectedItem.title - : this.activeItem - ? this.activeItem.title - : '' - } - - reloadAllLinks() { - this.reloadLinkedFiles() - this.reloadLinkedTags() - this.reloadLinkedNotes() - this.reloadNotesLinkingToItem() - } - - reloadLinkedFiles() { - if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { - this.linkedFiles = [] - this.filesLinkingToActiveItem = [] - return + getFilesLinksForItem = (item: LinkableItem | undefined) => { + if (!item || this.application.items.isTemplateItem(item)) { + return { + filesLinkedToItem: [], + filesLinkingToItem: [], + } } - const referencesOfActiveItem = naturalSort( - this.application.items.referencesForItem(this.activeItem).filter(isFile), - 'title', - ) + const referencesOfItem = naturalSort(this.application.items.referencesForItem(item).filter(isFile), 'title') - const referencingActiveItem = naturalSort( - this.application.items.itemsReferencingItem(this.activeItem).filter(isFile), - 'title', - ) + const referencingItem = naturalSort(this.application.items.itemsReferencingItem(item).filter(isFile), 'title') - if (this.activeItem.content_type === ContentType.File) { - this.linkedFiles = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked')) - this.filesLinkingToActiveItem = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked-by')) + if (item.content_type === ContentType.File) { + return { + filesLinkedToItem: referencesOfItem.map((item) => createLinkFromItem(item, 'linked')), + filesLinkingToItem: referencingItem.map((item) => createLinkFromItem(item, 'linked-by')), + } } else { - this.linkedFiles = referencingActiveItem.map((item) => createLinkFromItem(item, 'linked')) - this.filesLinkingToActiveItem = referencesOfActiveItem.map((item) => createLinkFromItem(item, 'linked-by')) + return { + filesLinkedToItem: referencingItem.map((item) => createLinkFromItem(item, 'linked')), + filesLinkingToItem: referencesOfItem.map((item) => createLinkFromItem(item, 'linked-by')), + } } } - reloadLinkedTags() { - if (!this.activeItem) { + getLinkedTagsForItem = (item: LinkableItem | undefined) => { + if (!item) { return } - this.tags = this.application.items - .getSortedTagsForItem(this.activeItem) - .map((item) => createLinkFromItem(item, 'linked')) + return this.application.items.getSortedTagsForItem(item).map((tag) => createLinkFromItem(tag, 'linked')) } - reloadLinkedNotes() { - if (!this.activeItem || this.application.items.isTemplateItem(this.activeItem)) { - this.notesLinkedToItem = [] - return + getLinkedNotesForItem = (item: LinkableItem | undefined) => { + if (!item || this.application.items.isTemplateItem(item)) { + return [] } - this.notesLinkedToItem = naturalSort( - this.application.items.referencesForItem(this.activeItem).filter(isNote), - 'title', - ).map((item) => createLinkFromItem(item, 'linked')) + return naturalSort(this.application.items.referencesForItem(item).filter(isNote), 'title').map((item) => + createLinkFromItem(item, 'linked'), + ) } - reloadNotesLinkingToItem() { - if (!this.activeItem) { - this.notesLinkingToActiveItem = [] - return + getNotesLinkingToItem = (item: LinkableItem | undefined) => { + if (!item) { + return [] } - this.notesLinkingToActiveItem = naturalSort( - this.application.items.itemsReferencingItem(this.activeItem).filter(isNote), - 'title', - ).map((item) => createLinkFromItem(item, 'linked-by')) + return naturalSort(this.application.items.itemsReferencingItem(item).filter(isNote), 'title').map((item) => + createLinkFromItem(item, 'linked-by'), + ) } activateItem = async (item: LinkableItem): Promise => { @@ -230,7 +174,6 @@ export class LinkingController extends AbstractViewController { await this.application.items.unlinkItems(selectedItem, itemToUnlink) void this.application.sync.sync() - this.reloadAllLinks() } ensureActiveItemIsInserted = async () => { @@ -240,11 +183,22 @@ export class LinkingController extends AbstractViewController { } } - linkItems = async (item: SNNote | FileItem, itemToLink: LinkableItem) => { + linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => { if (item instanceof SNNote) { + if (itemToLink instanceof SNNote && !this.isEntitledToNoteLinking) { + void this.publishCrossControllerEventSync(CrossControllerEvent.DisplayPremiumModal, { + featureName: 'Note linking', + }) + return + } + + if (item.uuid === this.activeItem?.uuid) { + await this.ensureActiveItemIsInserted() + } + if (itemToLink instanceof FileItem) { await this.application.items.associateFileWithNote(itemToLink, item) - } else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) { + } else if (itemToLink instanceof SNNote) { await this.application.items.linkNoteToNote(item, itemToLink) } else if (itemToLink instanceof SNTag) { await this.addTagToItem(itemToLink, item) @@ -266,7 +220,6 @@ export class LinkingController extends AbstractViewController { } void this.application.sync.sync() - this.reloadAllLinks() } linkItemToSelectedItem = async (itemToLink: LinkableItem): Promise => { @@ -307,7 +260,6 @@ export class LinkingController extends AbstractViewController { await this.application.items.addTagToFile(item, tag, this.shouldLinkToParentFolders) } - this.reloadLinkedTags() this.application.sync.sync().catch(console.error) } } diff --git a/packages/web/src/javascripts/Hooks/useItemLinks.ts b/packages/web/src/javascripts/Hooks/useItemLinks.ts new file mode 100644 index 000000000..b4c9a4498 --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useItemLinks.ts @@ -0,0 +1,33 @@ +import { useApplication } from '@/Components/ApplicationProvider' +import { useLinkingController } from '@/Controllers/LinkingControllerProvider' +import { ContentType, DecryptedItem } from '@standardnotes/snjs' +import { useEffect, useState } from 'react' + +export const useItemLinks = (item: DecryptedItem | undefined) => { + const application = useApplication() + const linkingController = useLinkingController() + const { getLinkedNotesForItem, getNotesLinkingToItem, getFilesLinksForItem, getLinkedTagsForItem } = linkingController + + const [, refresh] = useState(Date.now()) + + const notesLinkedToItem = getLinkedNotesForItem(item) || [] + const notesLinkingToItem = getNotesLinkingToItem(item) || [] + const { filesLinkedToItem, filesLinkingToItem } = getFilesLinksForItem(item) + const tagsLinkedToItem = getLinkedTagsForItem(item) || [] + + useEffect( + () => + application.streamItems([ContentType.Note, ContentType.File, ContentType.Tag], () => { + refresh(Date.now()) + }), + [application], + ) + + return { + notesLinkedToItem, + notesLinkingToItem, + filesLinkedToItem, + filesLinkingToItem, + tagsLinkedToItem, + } +}