diff --git a/app/assets/javascripts/components/ApplicationView.tsx b/app/assets/javascripts/components/ApplicationView.tsx index 883f7db2f..62efe7ed8 100644 --- a/app/assets/javascripts/components/ApplicationView.tsx +++ b/app/assets/javascripts/components/ApplicationView.tsx @@ -23,6 +23,7 @@ import { NotesContextMenu } from '@/components/NotesContextMenu'; import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper'; import { render } from 'preact'; import { PermissionsModal } from './PermissionsModal'; +import { RevisionHistoryModalWrapper } from './RevisionHistoryModal/RevisionHistoryModalWrapper'; import { PremiumModalProvider } from './Premium'; import { ConfirmSignoutContainer } from './ConfirmSignoutModal'; @@ -239,6 +240,11 @@ export class ApplicationView extends PureComponent { appState={this.appState} application={this.application} /> + + )} diff --git a/app/assets/javascripts/components/Button.tsx b/app/assets/javascripts/components/Button.tsx index 53e4a79b7..516d416fe 100644 --- a/app/assets/javascripts/components/Button.tsx +++ b/app/assets/javascripts/components/Button.tsx @@ -2,7 +2,8 @@ import { JSXInternal } from 'preact/src/jsx'; import TargetedEvent = JSXInternal.TargetedEvent; import TargetedMouseEvent = JSXInternal.TargetedMouseEvent; -import { FunctionComponent } from 'preact'; +import { ComponentChildren, FunctionComponent, Ref } from 'preact'; +import { forwardRef } from 'preact/compat'; const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`; @@ -14,30 +15,46 @@ const buttonClasses: { [type in ButtonType]: string } = { danger: `${baseClass} bg-default color-danger border-solid border-main border-1 focus:bg-contrast hover:bg-contrast`, }; -export const Button: FunctionComponent<{ +type ButtonProps = { + children?: ComponentChildren; className?: string; type: ButtonType; - label: string; + label?: string; onClick: ( event: | TargetedEvent | TargetedMouseEvent ) => void; disabled?: boolean; -}> = ({ type, label, className = '', onClick, disabled = false }) => { - const buttonClass = buttonClasses[type]; - const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer'; - - return ( - - ); }; + +export const Button: FunctionComponent = forwardRef( + ( + { + type, + label, + className = '', + onClick, + disabled = false, + children, + }: ButtonProps, + ref: Ref + ) => { + const buttonClass = buttonClasses[type]; + const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer'; + + return ( + + ); + } +); diff --git a/app/assets/javascripts/components/HistoryMenu.tsx b/app/assets/javascripts/components/HistoryMenu.tsx deleted file mode 100644 index 252bce832..000000000 --- a/app/assets/javascripts/components/HistoryMenu.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { WebApplication } from '@/ui_models/application'; -import { NoteHistoryEntry, PayloadContent, SNNote } from '@standardnotes/snjs'; -import { RevisionListEntry } from '@standardnotes/snjs'; -import { alertDialog, confirmDialog } from '@/services/alertService'; -import { PureComponent } from './Abstract/PureComponent'; -import { MenuRow } from './MenuRow'; -import { render } from 'preact'; -import { RevisionPreviewModal } from './RevisionPreviewModal'; - -type HistoryState = { - sessionHistory?: NoteHistoryEntry[]; - remoteHistory?: RevisionListEntry[]; - fetchingRemoteHistory: boolean; - autoOptimize: boolean; - diskEnabled: boolean; - showRemoteOptions?: boolean; - showSessionOptions?: boolean; -}; - -type Props = { - application: WebApplication; - item: SNNote; -}; - -export class HistoryMenu extends PureComponent { - constructor(props: Props) { - super(props, props.application); - - this.state = { - fetchingRemoteHistory: false, - autoOptimize: this.props.application.historyManager.autoOptimize, - diskEnabled: this.props.application.historyManager.isDiskEnabled(), - sessionHistory: - this.props.application.historyManager.sessionHistoryForItem( - this.props.item - ) as NoteHistoryEntry[], - }; - } - - reloadState() { - this.setState({ - fetchingRemoteHistory: this.state.fetchingRemoteHistory, - autoOptimize: this.props.application.historyManager.autoOptimize, - diskEnabled: this.props.application.historyManager.isDiskEnabled(), - sessionHistory: - this.props.application.historyManager.sessionHistoryForItem( - this.props.item - ) as NoteHistoryEntry[], - }); - } - - componentDidMount(): void { - super.componentDidMount(); - this.fetchRemoteHistory(); - } - - fetchRemoteHistory = async () => { - this.setState({ fetchingRemoteHistory: true }); - try { - const remoteHistory = - await this.props.application.historyManager.remoteHistoryForItem( - this.props.item - ); - this.setState({ remoteHistory }); - } finally { - this.setState({ fetchingRemoteHistory: false }); - } - }; - - private presentRevisionPreviewModal = ( - uuid: string, - content: PayloadContent, - title: string - ) => { - render( - , - document.body.appendChild(document.createElement('div')) - ); - }; - - openSessionRevision = (revision: NoteHistoryEntry) => { - this.presentRevisionPreviewModal( - revision.payload.uuid, - revision.payload.content, - revision.previewTitle() - ); - }; - - openRemoteRevision = async (revision: RevisionListEntry) => { - this.setState({ fetchingRemoteHistory: true }); - - const remoteRevision = - await this.props.application.historyManager.fetchRemoteRevision( - this.props.item.uuid, - revision - ); - - this.setState({ fetchingRemoteHistory: false }); - - if (!remoteRevision) { - alertDialog({ - text: 'The remote revision could not be loaded. Please try again later.', - }); - return; - } - - this.presentRevisionPreviewModal( - remoteRevision.payload.uuid, - remoteRevision.payload.content, - this.previewRemoteHistoryTitle(revision) - ); - }; - - classForSessionRevision = (revision: NoteHistoryEntry) => { - const vector = revision.operationVector(); - if (vector === 0) { - return 'default'; - } else if (vector === 1) { - return 'success'; - } else if (vector === -1) { - return 'danger'; - } - }; - - clearItemSessionHistory = async () => { - if ( - await confirmDialog({ - text: 'Are you sure you want to delete the local session history for this note?', - confirmButtonStyle: 'danger', - }) - ) { - this.props.application.historyManager.clearHistoryForItem( - this.props.item - ); - this.reloadState(); - } - }; - - clearAllSessionHistory = async () => { - if ( - await confirmDialog({ - text: 'Are you sure you want to delete the local session history for all notes?', - confirmButtonStyle: 'danger', - }) - ) { - await this.props.application.historyManager.clearAllHistory(); - this.reloadState(); - } - }; - - toggleSessionHistoryDiskSaving = async () => { - if (!this.state.diskEnabled) { - if ( - await confirmDialog({ - text: - 'Are you sure you want to save history to disk? This will decrease general ' + - 'performance, especially as you type. You are advised to disable this feature ' + - 'if you experience any lagging.', - confirmButtonStyle: 'danger', - }) - ) { - this.props.application.historyManager.toggleDiskSaving(); - } - } else { - this.props.application.historyManager.toggleDiskSaving(); - } - this.reloadState(); - }; - - toggleSessionHistoryAutoOptimize = () => { - this.props.application.historyManager.toggleAutoOptimize(); - this.reloadState(); - }; - - previewRemoteHistoryTitle(revision: RevisionListEntry) { - return new Date(revision.created_at).toLocaleString(); - } - - toggleShowRemoteOptions = ($event: Event) => { - $event.stopPropagation(); - this.setState({ - showRemoteOptions: !this.state.showRemoteOptions, - }); - }; - - toggleShowSessionOptions = ($event: Event) => { - $event.stopPropagation(); - this.setState({ - showSessionOptions: !this.state.showSessionOptions, - }); - }; - - render() { - return ( -
-
-
-
- Session -
- {this.state.sessionHistory?.length || 'No'} revisions -
-
- - Options - -
- {this.state.showSessionOptions && ( -
- - - -
- Automatically cleans up small revisions to conserve space. -
-
- -
- Saving to disk is not recommended. Decreases performance and - increases app loading time and memory footprint. -
-
-
- )} - {this.state.sessionHistory?.map((revision, index) => { - return ( - this.openSessionRevision(revision)} - label={revision.previewTitle()} - > -
- {revision.previewSubTitle()} -
-
- ); - })} -
-
- Remote -
- {this.state.remoteHistory?.length || 'No'} revisions -
-
- - Options - -
- - {this.state.showRemoteOptions && ( - -
Fetch history from server.
-
- )} - {this.state.remoteHistory?.map((revision, index) => { - return ( - this.openRemoteRevision(revision)} - label={this.previewRemoteHistoryTitle(revision)} - /> - ); - })} -
-
- ); - } -} diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index e7257322e..f2db39f7b 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -26,6 +26,7 @@ import { EyeOffIcon, HashtagIcon, HelpIcon, + HistoryIcon, InfoIcon, KeyboardIcon, LinkOffIcon, @@ -110,6 +111,7 @@ const ICONS = { eye: EyeIcon, hashtag: HashtagIcon, help: HelpIcon, + history: HistoryIcon, info: InfoIcon, keyboard: KeyboardIcon, listed: ListedIcon, diff --git a/app/assets/javascripts/components/NoteView/NoteView.tsx b/app/assets/javascripts/components/NoteView/NoteView.tsx index d89b807e2..c1b35faa6 100644 --- a/app/assets/javascripts/components/NoteView/NoteView.tsx +++ b/app/assets/javascripts/components/NoteView/NoteView.tsx @@ -34,7 +34,6 @@ import { PinNoteButton } from '../PinNoteButton'; import { NotesOptionsPanel } from '../NotesOptionsPanel'; import { NoteTagsContainer } from '../NoteTagsContainer'; import { ActionsMenu } from '../ActionsMenu'; -import { HistoryMenu } from '../HistoryMenu'; import { ComponentView } from '../ComponentView'; import { PanelSide, PanelResizer, PanelResizeType } from '../PanelResizer'; import { ElementIds } from '@/element_ids'; @@ -108,7 +107,6 @@ type State = { noteStatus?: NoteStatus; saveError?: any; showActionsMenu: boolean; - showHistoryMenu: boolean; showLockedIcon: boolean; showProtectedWarning: boolean; spellcheck: boolean; @@ -175,7 +173,6 @@ export class NoteView extends PureComponent { noteStatus: undefined, noteLocked: this.controller.note.locked, showActionsMenu: false, - showHistoryMenu: false, showLockedIcon: true, showProtectedWarning: false, spellcheck: true, @@ -520,10 +517,10 @@ export class NoteView extends PureComponent { }; closeAllMenus = (exclude?: string) => { - if (!(this.state.showActionsMenu || this.state.showHistoryMenu)) { + if (!this.state.showActionsMenu) { return; } - const allMenus = ['showActionsMenu', 'showHistoryMenu']; + const allMenus = ['showActionsMenu']; const menuState: any = {}; for (const candidate of allMenus) { if (candidate !== exclude) { @@ -1115,21 +1112,6 @@ export class NoteView extends PureComponent { /> )} -
this.toggleMenu('showHistoryMenu')} - > -
History
- {this.state.showHistoryMenu && ( - - )} -
diff --git a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx index 0033bd995..d896ea112 100644 --- a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx +++ b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx @@ -351,8 +351,25 @@ export const NotesOptions = observer( ); } + const openRevisionHistoryModal = () => { + appState.notes.setShowRevisionHistoryModal(true); + }; + return ( <> + {notes.length === 1 && ( + <> + +
+ + )} + ); + }; + + const fetchAndSetRemoteRevision = useCallback( + async (revisionListEntry: RevisionListEntry) => { + setShowContentLockedScreen(false); + + if (application.hasMinimumRole(revisionListEntry.required_role)) { + setIsFetchingSelectedRevision(true); + setSelectedRevision(undefined); + setSelectedRemoteEntry(undefined); + + try { + const remoteRevision = + await application.historyManager.fetchRemoteRevision( + note.uuid, + revisionListEntry + ); + setSelectedRevision(remoteRevision); + setSelectedRemoteEntry(revisionListEntry); + } catch (err) { + console.error(err); + } finally { + setIsFetchingSelectedRevision(false); + } + } else { + setShowContentLockedScreen(true); + setSelectedRevision(undefined); + } + }, + [ + application, + note.uuid, + setIsFetchingSelectedRevision, + setSelectedRemoteEntry, + setSelectedRevision, + setShowContentLockedScreen, + ] + ); + + return ( +
+
+ + + {isFetchingLegacyHistory && ( +
+
+
+ )} + {legacyHistory && legacyHistoryLength > 0 && ( + + )} +
+
+ {selectedTab === RevisionListTabType.Session && ( + + )} + {selectedTab === RevisionListTabType.Remote && ( + + )} + {selectedTab === RevisionListTabType.Legacy && ( + + )} +
+
+ ); + } +); diff --git a/app/assets/javascripts/components/RevisionHistoryModal/HistoryListItem.tsx b/app/assets/javascripts/components/RevisionHistoryModal/HistoryListItem.tsx new file mode 100644 index 000000000..32a3c343b --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/HistoryListItem.tsx @@ -0,0 +1,31 @@ +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants'; +import { FunctionComponent } from 'preact'; + +type HistoryListItemProps = { + isSelected: boolean; + onClick: () => void; +}; + +export const HistoryListItem: FunctionComponent = ({ + children, + isSelected, + onClick, +}) => { + return ( + + ); +}; diff --git a/app/assets/javascripts/components/RevisionHistoryModal/LegacyHistoryList.tsx b/app/assets/javascripts/components/RevisionHistoryModal/LegacyHistoryList.tsx new file mode 100644 index 000000000..3cc6ca2d9 --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/LegacyHistoryList.tsx @@ -0,0 +1,111 @@ +import { HistoryEntry, RevisionListEntry } from '@standardnotes/snjs'; +import { Fragment, FunctionComponent } from 'preact'; +import { + StateUpdater, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; +import { useListKeyboardNavigation } from '../utils'; +import { RevisionListTabType } from './HistoryListContainer'; +import { HistoryListItem } from './HistoryListItem'; +import { + LegacyHistoryEntry, + ListGroup, + previewHistoryEntryTitle, +} from './utils'; + +type Props = { + selectedTab: RevisionListTabType; + legacyHistory: ListGroup[] | undefined; + setSelectedRevision: StateUpdater< + HistoryEntry | LegacyHistoryEntry | undefined + >; + setSelectedRemoteEntry: StateUpdater; +}; + +export const LegacyHistoryList: FunctionComponent = ({ + legacyHistory, + selectedTab, + setSelectedRevision, + setSelectedRemoteEntry, +}) => { + const legacyHistoryListRef = useRef(null); + + useListKeyboardNavigation(legacyHistoryListRef); + + const legacyHistoryLength = useMemo( + () => legacyHistory?.map((group) => group.entries).flat().length, + [legacyHistory] + ); + + const [selectedItemCreatedAt, setSelectedItemCreatedAt] = useState(); + + const firstEntry = useMemo(() => { + return legacyHistory?.find((group) => group.entries?.length)?.entries?.[0]; + }, [legacyHistory]); + + const selectFirstEntry = useCallback(() => { + if (firstEntry) { + setSelectedItemCreatedAt(firstEntry.payload?.created_at); + setSelectedRevision(firstEntry); + } + }, [firstEntry, setSelectedRevision]); + + useEffect(() => { + if (firstEntry && !selectedItemCreatedAt) { + selectFirstEntry(); + } else if (!firstEntry) { + setSelectedRevision(undefined); + } + }, [ + firstEntry, + selectFirstEntry, + selectedItemCreatedAt, + setSelectedRevision, + ]); + + useEffect(() => { + if (selectedTab === RevisionListTabType.Session) { + selectFirstEntry(); + legacyHistoryListRef.current?.focus(); + } + }, [selectFirstEntry, selectedTab]); + + return ( +
+ {legacyHistory?.map((group) => + group.entries && group.entries.length ? ( + +
+ {group.title} +
+ {group.entries.map((entry, index) => ( + { + setSelectedItemCreatedAt(entry.payload.created_at); + setSelectedRevision(entry); + setSelectedRemoteEntry(undefined); + }} + > + {previewHistoryEntryTitle(entry)} + + ))} +
+ ) : null + )} + {!legacyHistoryLength && ( +
No legacy history found
+ )} +
+ ); +}; diff --git a/app/assets/javascripts/components/RevisionHistoryModal/RemoteHistoryList.tsx b/app/assets/javascripts/components/RevisionHistoryModal/RemoteHistoryList.tsx new file mode 100644 index 000000000..8a962f431 --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/RemoteHistoryList.tsx @@ -0,0 +1,125 @@ +import { WebApplication } from '@/ui_models/application'; +import { RevisionListEntry } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { Fragment, FunctionComponent } from 'preact'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; +import { Icon } from '../Icon'; +import { useListKeyboardNavigation } from '../utils'; +import { RevisionListTabType } from './HistoryListContainer'; +import { HistoryListItem } from './HistoryListItem'; +import { previewHistoryEntryTitle, RemoteRevisionListGroup } from './utils'; + +type RemoteHistoryListProps = { + application: WebApplication; + remoteHistory: RemoteRevisionListGroup[] | undefined; + isFetchingRemoteHistory: boolean; + fetchAndSetRemoteRevision: ( + revisionListEntry: RevisionListEntry + ) => Promise; + selectedTab: RevisionListTabType; +}; + +export const RemoteHistoryList: FunctionComponent = + observer( + ({ + application, + remoteHistory, + isFetchingRemoteHistory, + fetchAndSetRemoteRevision, + selectedTab, + }) => { + const remoteHistoryListRef = useRef(null); + + useListKeyboardNavigation(remoteHistoryListRef); + + const remoteHistoryLength = useMemo( + () => remoteHistory?.map((group) => group.entries).flat().length, + [remoteHistory] + ); + + const [selectedEntryUuid, setSelectedEntryUuid] = useState(''); + + const firstEntry = useMemo(() => { + return remoteHistory?.find((group) => group.entries?.length) + ?.entries?.[0]; + }, [remoteHistory]); + + const selectFirstEntry = useCallback(() => { + if (firstEntry) { + setSelectedEntryUuid(firstEntry.uuid); + fetchAndSetRemoteRevision(firstEntry); + } + }, [fetchAndSetRemoteRevision, firstEntry]); + + useEffect(() => { + if (firstEntry && !selectedEntryUuid.length) { + selectFirstEntry(); + } + }, [ + fetchAndSetRemoteRevision, + firstEntry, + remoteHistory, + selectFirstEntry, + selectedEntryUuid.length, + ]); + + useEffect(() => { + if (selectedTab === RevisionListTabType.Remote) { + selectFirstEntry(); + remoteHistoryListRef.current?.focus(); + } + }, [selectFirstEntry, selectedTab]); + + return ( +
+ {isFetchingRemoteHistory && ( +
+ )} + {remoteHistory?.map((group) => + group.entries && group.entries.length ? ( + +
+ {group.title} +
+ {group.entries.map((entry) => ( + { + setSelectedEntryUuid(entry.uuid); + fetchAndSetRemoteRevision(entry); + }} + > +
+
{previewHistoryEntryTitle(entry)}
+ {!application.hasMinimumRole(entry.required_role) && ( + + )} +
+
+ ))} +
+ ) : null + )} + {!remoteHistoryLength && !isFetchingRemoteHistory && ( +
+ No remote history found +
+ )} +
+ ); + } + ); diff --git a/app/assets/javascripts/components/RevisionHistoryModal/RevisionContentLocked.tsx b/app/assets/javascripts/components/RevisionHistoryModal/RevisionContentLocked.tsx new file mode 100644 index 000000000..3229b2fe3 --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/RevisionContentLocked.tsx @@ -0,0 +1,58 @@ +import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import HistoryLockedIllustration from '../../../svg/il-history-locked.svg'; +import { Button } from '../Button'; + +const getPlanHistoryDuration = (planName: string | undefined) => { + switch (planName) { + case 'Core': + return '30 days'; + case 'Plus': + return '365 days'; + default: + return 'the current session'; + } +}; + +const getPremiumContentCopy = (planName: string | undefined) => { + return `Version history is limited to ${getPlanHistoryDuration( + planName + )} in the ${planName} plan`; +}; + +export const RevisionContentLocked: FunctionComponent<{ + appState: AppState; +}> = observer(({ appState }) => { + const { + userSubscriptionName, + isUserSubscriptionExpired, + isUserSubscriptionCanceled, + } = appState.subscription; + + return ( +
+
+ +
Can't access this version
+
+ {getPremiumContentCopy( + !isUserSubscriptionCanceled && !isUserSubscriptionExpired + ? userSubscriptionName + : 'free' + )} + . Learn more about our other plans to upgrade your history capacity. +
+
+
+ ); +}); diff --git a/app/assets/javascripts/components/RevisionHistoryModal/RevisionHistoryModalWrapper.tsx b/app/assets/javascripts/components/RevisionHistoryModal/RevisionHistoryModalWrapper.tsx new file mode 100644 index 000000000..cfaed8a62 --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/RevisionHistoryModalWrapper.tsx @@ -0,0 +1,348 @@ +import { confirmDialog } from '@/services/alertService'; +import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings'; +import { WebApplication } from '@/ui_models/application'; +import { AppState } from '@/ui_models/app_state'; +import { getPlatformString } from '@/utils'; +import { + AlertDialogContent, + AlertDialogDescription, + AlertDialogLabel, + AlertDialogOverlay, +} from '@reach/alert-dialog'; +import VisuallyHidden from '@reach/visually-hidden'; +import { + ButtonType, + ContentType, + HistoryEntry, + PayloadContent, + PayloadSource, + RevisionListEntry, + SNNote, +} from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; +import { Button } from '../Button'; +import { HistoryListContainer } from './HistoryListContainer'; +import { RevisionContentLocked } from './RevisionContentLocked'; +import { SelectedRevisionContent } from './SelectedRevisionContent'; +import { + LegacyHistoryEntry, + RemoteRevisionListGroup, + sortRevisionListIntoGroups, +} from './utils'; + +type RevisionHistoryModalProps = { + application: WebApplication; + appState: AppState; +}; + +const ABSOLUTE_CENTER_CLASSNAME = + 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'; + +type RevisionContentPlaceholderProps = { + isFetchingSelectedRevision: boolean; + selectedRevision: HistoryEntry | LegacyHistoryEntry | undefined; + showContentLockedScreen: boolean; +}; + +const RevisionContentPlaceholder: FunctionComponent< + RevisionContentPlaceholderProps +> = ({ + isFetchingSelectedRevision, + selectedRevision, + showContentLockedScreen, +}) => ( +
+ {isFetchingSelectedRevision && ( +
+ )} + {!isFetchingSelectedRevision && !selectedRevision ? ( +
+ No revision selected +
+ ) : null} +
+); + +export const RevisionHistoryModal: FunctionComponent = + observer(({ application, appState }) => { + const closeButtonRef = useRef(null); + + const dismissModal = () => { + appState.notes.setShowRevisionHistoryModal(false); + }; + + const note = Object.values(appState.notes.selectedNotes)[0]; + const editorForCurrentNote = useMemo(() => { + return application.componentManager.editorForNote(note); + }, [application.componentManager, note]); + + const [isFetchingSelectedRevision, setIsFetchingSelectedRevision] = + useState(false); + const [selectedRevision, setSelectedRevision] = useState< + HistoryEntry | LegacyHistoryEntry + >(); + const [selectedRemoteEntry, setSelectedRemoteEntry] = + useState(); + const [isDeletingRevision, setIsDeletingRevision] = useState(false); + const [templateNoteForRevision, setTemplateNoteForRevision] = + useState(); + const [showContentLockedScreen, setShowContentLockedScreen] = + useState(false); + + const [remoteHistory, setRemoteHistory] = + useState(); + const [isFetchingRemoteHistory, setIsFetchingRemoteHistory] = + useState(false); + + const fetchRemoteHistory = useCallback(async () => { + if (note) { + setRemoteHistory(undefined); + setIsFetchingRemoteHistory(true); + try { + const initialRemoteHistory = + await application.historyManager.remoteHistoryForItem(note); + + const remoteHistoryAsGroups = + sortRevisionListIntoGroups(initialRemoteHistory); + + setRemoteHistory(remoteHistoryAsGroups); + } catch (err) { + console.error(err); + } finally { + setIsFetchingRemoteHistory(false); + } + } + }, [application.historyManager, note]); + + useEffect(() => { + if (!remoteHistory?.length) { + fetchRemoteHistory(); + } + }, [fetchRemoteHistory, remoteHistory?.length]); + + const restore = () => { + if (selectedRevision) { + const originalNote = application.findItem( + selectedRevision.payload.uuid + ) as SNNote; + + if (originalNote.locked) { + application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT); + return; + } + + confirmDialog({ + text: "Are you sure you want to replace the current note's contents with what you see in this preview?", + confirmButtonStyle: 'danger', + }).then((confirmed) => { + if (confirmed) { + application.changeAndSaveItem( + selectedRevision.payload.uuid, + (mutator) => { + mutator.unsafe_setCustomContent( + selectedRevision.payload.content + ); + }, + true, + PayloadSource.RemoteActionRetrieved + ); + dismissModal(); + } + }); + } + }; + + const restoreAsCopy = async () => { + if (selectedRevision) { + const originalNote = application.findItem( + selectedRevision.payload.uuid + ) as SNNote; + + const duplicatedItem = await application.duplicateItem(originalNote, { + ...(selectedRevision.payload.content as PayloadContent), + title: selectedRevision.payload.content.title + ? selectedRevision.payload.content.title + ' (copy)' + : undefined, + }); + + appState.notes.selectNote(duplicatedItem.uuid); + + dismissModal(); + } + }; + + useEffect(() => { + const fetchTemplateNote = async () => { + if (selectedRevision) { + const newTemplateNote = (await application.createTemplateItem( + ContentType.Note, + selectedRevision.payload.content + )) as SNNote; + + setTemplateNoteForRevision(newTemplateNote); + } + }; + + fetchTemplateNote(); + }, [application, selectedRevision]); + + const deleteSelectedRevision = () => { + if (!selectedRemoteEntry) { + return; + } + + application.alertService + .confirm( + 'Are you sure you want to delete this revision?', + 'Delete revision?', + 'Delete revision', + ButtonType.Danger, + 'Cancel' + ) + .then((shouldDelete) => { + if (shouldDelete) { + setIsDeletingRevision(true); + + application.historyManager + .deleteRemoteRevision(note.uuid, selectedRemoteEntry) + .then((res) => { + if (res.error?.message) { + throw new Error(res.error.message); + } + + fetchRemoteHistory(); + setIsDeletingRevision(false); + }) + .catch(console.error); + } + }) + .catch(console.error); + }; + + return ( + + + + Note revision history + + +
+ +
+ + {showContentLockedScreen && !selectedRevision && ( + + )} + {selectedRevision && templateNoteForRevision && ( + + )} +
+
+
+
+
+ {selectedRevision && ( +
+ {selectedRemoteEntry && ( + + )} +
+ )} +
+
+
+
+ ); + }); + +export const RevisionHistoryModalWrapper: FunctionComponent = + observer(({ application, appState }) => { + if (!appState.notes.showRevisionHistoryModal) { + return null; + } + + return ( + + ); + }); diff --git a/app/assets/javascripts/components/RevisionHistoryModal/SelectedRevisionContent.tsx b/app/assets/javascripts/components/RevisionHistoryModal/SelectedRevisionContent.tsx new file mode 100644 index 000000000..42e8de68b --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/SelectedRevisionContent.tsx @@ -0,0 +1,92 @@ +import { WebApplication } from '@/ui_models/application'; +import { AppState } from '@/ui_models/app_state'; +import { HistoryEntry, SNComponent, SNNote } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useEffect, useMemo } from 'preact/hooks'; +import { ComponentView } from '../ComponentView'; +import { LegacyHistoryEntry } from './utils'; + +const ABSOLUTE_CENTER_CLASSNAME = + 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'; + +type SelectedRevisionContentProps = { + application: WebApplication; + appState: AppState; + selectedRevision: HistoryEntry | LegacyHistoryEntry; + editorForCurrentNote: SNComponent | undefined; + templateNoteForRevision: SNNote; +}; + +export const SelectedRevisionContent: FunctionComponent = + observer( + ({ + application, + appState, + selectedRevision, + editorForCurrentNote, + templateNoteForRevision, + }) => { + const componentViewer = useMemo(() => { + if (!editorForCurrentNote) { + return undefined; + } + + const componentViewer = + application.componentManager.createComponentViewer( + editorForCurrentNote + ); + componentViewer.setReadonly(true); + componentViewer.lockReadonly = true; + componentViewer.overrideContextItem = templateNoteForRevision; + return componentViewer; + }, [ + application.componentManager, + editorForCurrentNote, + templateNoteForRevision, + ]); + + useEffect(() => { + return () => { + if (componentViewer) { + application.componentManager.destroyComponentViewer( + componentViewer + ); + } + }; + }, [application.componentManager, componentViewer]); + + return ( +
+
+
+ {selectedRevision.payload.content.title} +
+
+ {!componentViewer && ( +
+ {selectedRevision.payload.content.text.length ? ( +

+ {selectedRevision.payload.content.text} +

+ ) : ( +
+ Empty note. +
+ )} +
+ )} + {componentViewer && ( +
+ +
+ )} +
+ ); + } + ); diff --git a/app/assets/javascripts/components/RevisionHistoryModal/SessionHistoryList.tsx b/app/assets/javascripts/components/RevisionHistoryModal/SessionHistoryList.tsx new file mode 100644 index 000000000..35e8bb3cb --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/SessionHistoryList.tsx @@ -0,0 +1,111 @@ +import { + HistoryEntry, + NoteHistoryEntry, + RevisionListEntry, +} from '@standardnotes/snjs'; +import { Fragment, FunctionComponent } from 'preact'; +import { + StateUpdater, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; +import { useListKeyboardNavigation } from '../utils'; +import { RevisionListTabType } from './HistoryListContainer'; +import { HistoryListItem } from './HistoryListItem'; +import { LegacyHistoryEntry, ListGroup } from './utils'; + +type Props = { + selectedTab: RevisionListTabType; + sessionHistory: ListGroup[]; + setSelectedRevision: StateUpdater< + HistoryEntry | LegacyHistoryEntry | undefined + >; + setSelectedRemoteEntry: StateUpdater; +}; + +export const SessionHistoryList: FunctionComponent = ({ + sessionHistory, + selectedTab, + setSelectedRevision, + setSelectedRemoteEntry, +}) => { + const sessionHistoryListRef = useRef(null); + + useListKeyboardNavigation(sessionHistoryListRef); + + const sessionHistoryLength = useMemo( + () => sessionHistory.map((group) => group.entries).flat().length, + [sessionHistory] + ); + + const [selectedItemCreatedAt, setSelectedItemCreatedAt] = useState(); + + const firstEntry = useMemo(() => { + return sessionHistory?.find((group) => group.entries?.length)?.entries?.[0]; + }, [sessionHistory]); + + const selectFirstEntry = useCallback(() => { + if (firstEntry) { + setSelectedItemCreatedAt(firstEntry.payload.created_at); + setSelectedRevision(firstEntry); + } + }, [firstEntry, setSelectedRevision]); + + useEffect(() => { + if (firstEntry && !selectedItemCreatedAt) { + selectFirstEntry(); + } else if (!firstEntry) { + setSelectedRevision(undefined); + } + }, [ + firstEntry, + selectFirstEntry, + selectedItemCreatedAt, + setSelectedRevision, + ]); + + useEffect(() => { + if (selectedTab === RevisionListTabType.Session) { + selectFirstEntry(); + sessionHistoryListRef.current?.focus(); + } + }, [selectFirstEntry, selectedTab]); + + return ( +
+ {sessionHistory?.map((group) => + group.entries && group.entries.length ? ( + +
+ {group.title} +
+ {group.entries.map((entry, index) => ( + { + setSelectedItemCreatedAt(entry.payload.created_at); + setSelectedRevision(entry); + setSelectedRemoteEntry(undefined); + }} + > + {entry.previewTitle()} + + ))} +
+ ) : null + )} + {!sessionHistoryLength && ( +
No session history found
+ )} +
+ ); +}; diff --git a/app/assets/javascripts/components/RevisionHistoryModal/utils.ts b/app/assets/javascripts/components/RevisionHistoryModal/utils.ts new file mode 100644 index 000000000..c1996723d --- /dev/null +++ b/app/assets/javascripts/components/RevisionHistoryModal/utils.ts @@ -0,0 +1,112 @@ +import { DAYS_IN_A_WEEK, DAYS_IN_A_YEAR } from '@/views/constants'; +import { + HistoryEntry, + NoteHistoryEntry, + RevisionListEntry, +} from '@standardnotes/snjs/dist/@types'; +import { calculateDifferenceBetweenDatesInDays } from '../utils'; + +export type LegacyHistoryEntry = { + payload: HistoryEntry['payload']; + created_at: string; +}; + +type RevisionEntry = RevisionListEntry | NoteHistoryEntry | LegacyHistoryEntry; + +export type ListGroup = { + title: string; + entries: EntryType[] | undefined; +}; + +export type RemoteRevisionListGroup = ListGroup; +export type SessionRevisionListGroup = ListGroup; + +export const formatDateAsMonthYearString = (date: Date) => + date.toLocaleDateString(undefined, { + month: 'long', + year: 'numeric', + }); + +export const getGroupIndexForEntry = ( + entry: RevisionEntry, + groups: ListGroup[] +) => { + const todayAsDate = new Date(); + const entryDate = new Date( + (entry as RevisionListEntry).created_at ?? + (entry as NoteHistoryEntry).payload.created_at + ); + + const differenceBetweenDatesInDays = calculateDifferenceBetweenDatesInDays( + todayAsDate, + entryDate + ); + + if (differenceBetweenDatesInDays === 0) { + return groups.findIndex((group) => group.title === GROUP_TITLE_TODAY); + } + + if ( + differenceBetweenDatesInDays > 0 && + differenceBetweenDatesInDays < DAYS_IN_A_WEEK + ) { + return groups.findIndex((group) => group.title === GROUP_TITLE_WEEK); + } + + if (differenceBetweenDatesInDays > DAYS_IN_A_YEAR) { + return groups.findIndex((group) => group.title === GROUP_TITLE_YEAR); + } + + const formattedEntryMonthYear = formatDateAsMonthYearString(entryDate); + + return groups.findIndex((group) => group.title === formattedEntryMonthYear); +}; + +const GROUP_TITLE_TODAY = 'Today'; +const GROUP_TITLE_WEEK = 'This Week'; +const GROUP_TITLE_YEAR = 'More Than A Year Ago'; + +export const sortRevisionListIntoGroups = ( + revisionList: EntryType[] | undefined +) => { + const sortedGroups: ListGroup[] = [ + { + title: GROUP_TITLE_TODAY, + entries: [], + }, + { + title: GROUP_TITLE_WEEK, + entries: [], + }, + { + title: GROUP_TITLE_YEAR, + entries: [], + }, + ]; + + revisionList?.forEach((entry) => { + const groupIndex = getGroupIndexForEntry(entry, sortedGroups); + + if (groupIndex > -1) { + sortedGroups[groupIndex]?.entries?.push(entry); + } else { + sortedGroups.push({ + title: formatDateAsMonthYearString( + new Date( + (entry as RevisionListEntry).created_at ?? + (entry as NoteHistoryEntry).payload.created_at + ) + ), + entries: [entry], + }); + } + }); + + return sortedGroups; +}; + +export const previewHistoryEntryTitle = ( + revision: RevisionListEntry | LegacyHistoryEntry +) => { + return new Date(revision.created_at).toLocaleString(); +}; diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts index e09eb1a53..b4e5afc78 100644 --- a/app/assets/javascripts/components/utils.ts +++ b/app/assets/javascripts/components/utils.ts @@ -1,6 +1,15 @@ -import { FunctionComponent, h, render } from 'preact'; -import { unmountComponentAtNode } from 'preact/compat'; -import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks'; +import { KeyboardKey } from '@/services/ioService'; +import { + FOCUSABLE_BUT_NOT_TABBABLE, + MILLISECONDS_IN_A_DAY, +} from '@/views/constants'; +import { + StateUpdater, + useCallback, + useState, + useEffect, + Ref, +} from 'preact/hooks'; /** * @returns a callback that will close a dropdown if none of its children has @@ -57,3 +66,105 @@ export function useCloseOnClickOutside( }; }, [closeOnClickOutside]); } + +export const calculateDifferenceBetweenDatesInDays = ( + firstDate: Date, + secondDate: Date +) => { + const firstDateAsUTCMilliseconds = Date.UTC( + firstDate.getFullYear(), + firstDate.getMonth(), + firstDate.getDate() + ); + + const secondDateAsUTCMilliseconds = Date.UTC( + secondDate.getFullYear(), + secondDate.getMonth(), + secondDate.getDate() + ); + + return Math.round( + (firstDateAsUTCMilliseconds - secondDateAsUTCMilliseconds) / + MILLISECONDS_IN_A_DAY + ); +}; + +export const useListKeyboardNavigation = ( + container: Ref +) => { + const [listItems, setListItems] = useState>(); + const [focusedItemIndex, setFocusedItemIndex] = useState(0); + + const focusItemWithIndex = useCallback( + (index: number) => { + setFocusedItemIndex(index); + listItems?.[index]?.focus(); + }, + [listItems] + ); + + useEffect(() => { + if (container.current) { + container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE; + setListItems(container.current.querySelectorAll('button')); + } + }, [container]); + + const keyDownHandler = useCallback( + (e: KeyboardEvent) => { + if (e.key === KeyboardKey.Up || e.key === KeyboardKey.Down) { + e.preventDefault(); + } else { + return; + } + + if (!listItems?.length) { + setListItems(container.current?.querySelectorAll('button')); + } + + if (listItems) { + if (e.key === KeyboardKey.Up) { + let previousIndex = focusedItemIndex - 1; + if (previousIndex < 0) { + previousIndex = listItems.length - 1; + } + focusItemWithIndex(previousIndex); + } + + if (e.key === KeyboardKey.Down) { + let nextIndex = focusedItemIndex + 1; + if (nextIndex > listItems.length - 1) { + nextIndex = 0; + } + focusItemWithIndex(nextIndex); + } + } + }, + [container, focusItemWithIndex, focusedItemIndex, listItems] + ); + + const FIRST_ITEM_FOCUS_TIMEOUT = 20; + + const containerFocusHandler = useCallback(() => { + if (listItems) { + const selectedItemIndex = Array.from(listItems).findIndex( + (item) => item.dataset.selected + ); + const indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : 0; + setTimeout(() => { + focusItemWithIndex(indexToFocus); + }, FIRST_ITEM_FOCUS_TIMEOUT); + } + }, [focusItemWithIndex, listItems]); + + useEffect(() => { + const containerElement = container.current; + containerElement?.addEventListener('focus', containerFocusHandler); + containerElement?.addEventListener('keydown', keyDownHandler); + + return () => { + containerElement?.removeEventListener('focus', containerFocusHandler); + containerElement?.removeEventListener('keydown', keyDownHandler); + }; + }, [container, containerFocusHandler, keyDownHandler]); +}; diff --git a/app/assets/javascripts/ui_models/app_state/notes_state.ts b/app/assets/javascripts/ui_models/app_state/notes_state.ts index b0093e5eb..7748bd30b 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts @@ -32,6 +32,7 @@ export class NotesState { contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }; contextMenuMaxHeight: number | 'auto' = 'auto'; showProtectedWarning = false; + showRevisionHistoryModal = false; constructor( private application: WebApplication, @@ -44,6 +45,7 @@ export class NotesState { contextMenuOpen: observable, contextMenuPosition: observable, showProtectedWarning: observable, + showRevisionHistoryModal: observable, selectedNotesCount: computed, trashedNotesCount: computed, @@ -53,6 +55,7 @@ export class NotesState { setContextMenuPosition: action, setContextMenuMaxHeight: action, setShowProtectedWarning: action, + setShowRevisionHistoryModal: action, unselectNotes: action, }); @@ -457,4 +460,8 @@ export class NotesState { private get io() { return this.application.io; } + + setShowRevisionHistoryModal(show: boolean): void { + this.showRevisionHistoryModal = show; + } } diff --git a/app/assets/javascripts/views/constants.ts b/app/assets/javascripts/views/constants.ts index c2f6a61d5..19a24688d 100644 --- a/app/assets/javascripts/views/constants.ts +++ b/app/assets/javascripts/views/constants.ts @@ -10,4 +10,7 @@ export const MAX_MENU_SIZE_MULTIPLIER = 30; export const FOCUSABLE_BUT_NOT_TABBABLE = -1; export const NOTES_LIST_SCROLL_THRESHOLD = 200; +export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24; +export const DAYS_IN_A_WEEK = 7; +export const DAYS_IN_A_YEAR = 365; export const BYTES_IN_ONE_MEGABYTE = 1000000; diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 521bf1432..8c9eeef34 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -1,8 +1,5 @@ /* Components and utilities that are good candidates for extraction to StyleKit. */ -:root { -} - .bg-grey-super-light { background-color: var(--sn-stylekit-grey-super-light); } @@ -11,6 +8,10 @@ height: 90vh; } +.h-3 { + height: 0.75rem; +} + .h-26 { width: 6.5rem; } @@ -260,6 +261,10 @@ margin-left: 1rem; } +.mr-2\.5 { + margin-right: 0.625rem; +} + .mr-3 { margin-right: 0.75rem; } @@ -291,10 +296,18 @@ margin-top: 0; } +.mt-2\.5 { + margin-top: 0.625rem; +} + .mb-2 { margin-bottom: 0.5rem; } +.max-w-40\% { + max-width: 40%; +} + .max-w-1\/2 { max-width: 50%; } @@ -326,6 +339,10 @@ margin-bottom: 2rem; } +.w-3 { + width: 0.75rem; +} + .w-26 { width: 6.5rem; } @@ -382,6 +399,10 @@ min-width: 7.5rem; } +.min-w-60 { + min-width: 15rem; +} + .min-w-68 { min-width: 17rem; } @@ -426,6 +447,14 @@ border-color: var(--sn-stylekit-neutral-contrast-color); } +.sn-component .border-r-1px { + border-right-width: 1px; +} + +.sn-component .border-t-1px { + border-top-width: 1px; +} + .border-2 { border-width: 0.5rem; } @@ -474,6 +503,10 @@ padding-top: 1.5rem; } +.pb-0 { + padding-bottom: 0; +} + .sn-component .pb-1 { padding-bottom: 0.25rem; } @@ -522,6 +555,11 @@ padding-bottom: 0.125rem; } +.sn-component .py-1\.35 { + padding-top: 0.3375rem; + padding-bottom: 0.3375rem; +} + .sn-component .py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; @@ -653,6 +691,10 @@ bottom: -1.25rem; } +.z-index-1 { + z-index: 1; +} + .-z-index-1 { z-index: -1; } @@ -755,6 +797,10 @@ color: var(--sn-stylekit-foreground-color) !important; } +.sn-component .bg-info-backdrop { + background-color: var(--sn-stylekit-info-backdrop-color); +} + .focus\:bg-info-backdrop:focus { background-color: var(--sn-stylekit-info-backdrop-color); } @@ -867,14 +913,31 @@ pointer-events: none; } -.hide-if-last-child:last-child { +.flex-shrink-0 { + flex-shrink: 0; +} + +.last\:hidden:last-child { display: none; } +.shadow-bottom { + box-shadow: currentcolor 0px -1px 0px 0px inset, currentcolor 0px 1px 0px 0px; +} + +.focus\:shadow-inner:focus { + box-shadow: var(--sn-stylekit-info-color) 1px 1px 0px 0px inset, + var(--sn-stylekit-info-color) -1px -1px 0px 0px inset; +} + .bg-note-size-warning { background-color: rgba(235, 173, 0, 0.08); } +.overflow-x-hidden { + overflow-x: hidden; +} + .sn-component .border-y-1px { border-top-width: 1px; border-bottom-width: 1px; diff --git a/app/assets/svg/il-history-locked.svg b/app/assets/svg/il-history-locked.svg new file mode 100644 index 000000000..d9a69b188 --- /dev/null +++ b/app/assets/svg/il-history-locked.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index b451e7208..9d247fbc5 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "pug-loader": "^2.4.0", "sass-loader": "^12.2.0", "serve-static": "^1.14.1", - "@standardnotes/stylekit": "5.5.0", + "@standardnotes/stylekit": "5.6.0", "svg-jest": "^1.0.1", "ts-jest": "^27.0.7", "ts-loader": "^9.2.6", @@ -83,8 +83,8 @@ "@reach/listbox": "^0.16.2", "@reach/tooltip": "^0.16.2", "@standardnotes/components": "1.7.2", - "@standardnotes/features": "1.32.2", - "@standardnotes/snjs": "2.59.6", + "@standardnotes/features": "1.32.3", + "@standardnotes/snjs": "2.59.7", "@standardnotes/settings": "^1.11.3", "@standardnotes/sncrypto-web": "1.6.2", "mobx": "^6.3.5", diff --git a/yarn.lock b/yarn.lock index 0299bf311..487c89fe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2822,39 +2822,39 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@standardnotes/auth@^3.16.0": - version "3.16.0" - resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.16.0.tgz#6a69c1dc4fdfb4e98920c777e6eff892087d2397" - integrity sha512-0JDM6u3SDb2EkDNf+HN04HvXvkVe9gCKmWIeak+2vyfyS6nsbNBQf/uxAu7Jz3Jugi3Q7j57dhHeOSU21bFR3A== +"@standardnotes/auth@^3.16.1": + version "3.16.1" + resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.16.1.tgz#c73b3c70dfde1905998a6a27d91241ff5c7926dc" + integrity sha512-MSYfb80AVeERrZPiN15XG9e/ECv6UrVJ0R5h9jVIEJzxPKfmpVJVr2wH2ts3B6etjD3VYtUS7UNBXC/fYI4kuw== dependencies: - "@standardnotes/common" "^1.10.0" + "@standardnotes/common" "^1.11.0" jsonwebtoken "^8.5.1" -"@standardnotes/common@^1.10.0": - version "1.10.0" - resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.10.0.tgz#526b40b473cfbd8bb7291eb895ae174879e21047" - integrity sha512-7k8UZbA5d6BODjQYqV/i6xbkO6qEJULFLuLYVAS+Ery5CPekmUoEoh+UuA+iEITj2RNW6BV6sa/13jmHG5ADtA== +"@standardnotes/common@^1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.11.0.tgz#5ed4be817a1f448e6eeb700d141dbfd40193aabe" + integrity sha512-8TKx7bCwIazhGD3wkWTV4rmwbERsyisPbVDn6UIm1lNktWjKDF5OL1D8omalpR5wdM5qmXX5njI1zll2cxlW7A== "@standardnotes/components@1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.7.2.tgz#33c2748955e1ebf2cd7af3b2bc9f60a0bc53d0c6" integrity sha512-ckCC5Ez/Ni6PZZenCBrNC1sELxir5F3Q0UVm8nDWSQmf9G8+JGQ44BO0lEoZW8X3OPZpdYSde5Z5ITyOOBTXrQ== -"@standardnotes/domain-events@^2.23.3": - version "2.23.3" - resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.23.3.tgz#5fe89f95eaf68bcbaf636a2ace027dacdc03f82a" - integrity sha512-ZDgRUeu3wxbAiLWEq9W9LV+wt2OpKxvpqz7Lr1t9L1gMaBOatHKVNcdLjNs+PD9WgfANVRKswW3veKyY2j+QFQ== +"@standardnotes/domain-events@^2.23.4": + version "2.23.4" + resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.23.4.tgz#ab78ac57b35b723cd2184900466f4933003f057f" + integrity sha512-SvJzF6KugYruwPKGqEnB8hiBco+NqwGpqOZWiz03I8YozDeXoDhhRaKhBzCzXCRetLXf2YENppjPft+PkxkWVQ== dependencies: - "@standardnotes/auth" "^3.16.0" - "@standardnotes/features" "^1.32.2" + "@standardnotes/auth" "^3.16.1" + "@standardnotes/features" "^1.32.3" -"@standardnotes/features@1.32.2", "@standardnotes/features@^1.32.2": - version "1.32.2" - resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.32.2.tgz#e8f63a72e1cc0d5ee38015947be8af8c9eebffc4" - integrity sha512-dFtfQy18z73O0wt9/ih6g16HxWRS4C2VPNEZ1e+rypP/FKmyNjZo0NIAqfOt3ImEqr1eaJZYxT2iLkOs19I31A== +"@standardnotes/features@1.32.3", "@standardnotes/features@^1.32.3": + version "1.32.3" + resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.32.3.tgz#497d4c87f76293f0fabcaf20844f4bce048d5b47" + integrity sha512-QlHCtfKEtWTd2X6n28aGPg8xw/hHKTOzdo4nBECclO3GdE31WdZ6RpVstH8k515z0s2Q3RORXRvl7Ncn24Zd8g== dependencies: - "@standardnotes/auth" "^3.16.0" - "@standardnotes/common" "^1.10.0" + "@standardnotes/auth" "^3.16.1" + "@standardnotes/common" "^1.11.0" "@standardnotes/settings@^1.11.3": version "1.11.3" @@ -2875,22 +2875,22 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.59.6": - version "2.59.6" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.59.6.tgz#21786e3a26445205de25f4988c53c29513a7b41b" - integrity sha512-CBen9+gzvDCCjq8kYuDDPdwJ16RhPBdce+LqVsTNcehhsRsioH2UYOgDR9KFIYmz2SxTbuziW8Ouu9oBtsX/Tg== +"@standardnotes/snjs@2.59.7": + version "2.59.7" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.59.7.tgz#bf0949ee1b29909aeb6db198d27d556456cc3e58" + integrity sha512-OBOaOzgnyilpBWuehbwF/6aiuxiuvBYaqaZPj1Xm2GdpneTCRE7elPtWB4UGAQV0xyK5d1b1ze7wnhC2xOVu4A== dependencies: - "@standardnotes/auth" "^3.16.0" - "@standardnotes/common" "^1.10.0" - "@standardnotes/domain-events" "^2.23.3" - "@standardnotes/features" "^1.32.2" + "@standardnotes/auth" "^3.16.1" + "@standardnotes/common" "^1.11.0" + "@standardnotes/domain-events" "^2.23.4" + "@standardnotes/features" "^1.32.3" "@standardnotes/settings" "^1.11.3" "@standardnotes/sncrypto-common" "^1.6.0" -"@standardnotes/stylekit@5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@standardnotes/stylekit/-/stylekit-5.5.0.tgz#24d2aef1529bbb09406f95acdd55ba138cf14958" - integrity sha512-feAdjOu0tBfpRJh5pH+urji40Xu72tJ2VRWxzkxXX1SYHMbgXnjBNs5R4IWko2kMIQwNGuBOvEg87S5w2/fdhw== +"@standardnotes/stylekit@5.6.0": + version "5.6.0" + resolved "https://registry.yarnpkg.com/@standardnotes/stylekit/-/stylekit-5.6.0.tgz#52ff63e9bfc823817832786d54e21e8522f061ac" + integrity sha512-F6PxkVSKJBZMTYkGT96hVPDvcmpvevAC36KwMnaMtnjMQGfOjMLYJRgef5bJunvOeqo2bvoPX5xcd4EwacTBBw== dependencies: "@reach/listbox" "^0.15.0" "@reach/menu-button" "^0.15.1"