From c42eeea383315b2843939165c03a9cc2044854fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 5 Jan 2022 16:12:12 +0100 Subject: [PATCH 01/18] Revert "Revert "feat: New notes list design (#780)"" This reverts commit d76c636e5498c9cc42942b33c8a6329beda0448f. --- app/assets/icons/ic-authenticator.svg | 7 +- app/assets/icons/ic-code.svg | 7 +- app/assets/icons/ic-lock-filled.svg | 4 + app/assets/icons/ic-markdown.svg | 7 +- app/assets/icons/ic-pin-filled.svg | 3 + app/assets/icons/ic-spreadsheets.svg | 7 +- app/assets/icons/ic-tasks.svg | 7 +- app/assets/icons/ic-text-paragraph.svg | 7 +- app/assets/icons/ic-text-rich.svg | 5 +- app/assets/icons/ic-trash-filled.svg | 4 + .../javascripts/components/Dropdown.tsx | 18 +- app/assets/javascripts/components/Icon.tsx | 21 +- .../javascripts/components/NotesList.tsx | 32 ++-- .../javascripts/components/NotesListItem.tsx | 179 +++++++++++------- .../javascripts/components/NotesView.tsx | 1 + .../panes/general-segments/Defaults.tsx | 27 +-- app/assets/stylesheets/_notes.scss | 99 +++++++--- app/assets/stylesheets/_sn.scss | 6 + package.json | 2 +- yarn.lock | 8 +- 20 files changed, 296 insertions(+), 155 deletions(-) create mode 100644 app/assets/icons/ic-lock-filled.svg create mode 100644 app/assets/icons/ic-pin-filled.svg create mode 100644 app/assets/icons/ic-trash-filled.svg diff --git a/app/assets/icons/ic-authenticator.svg b/app/assets/icons/ic-authenticator.svg index 9a1193919..e8dd720cf 100644 --- a/app/assets/icons/ic-authenticator.svg +++ b/app/assets/icons/ic-authenticator.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-code.svg b/app/assets/icons/ic-code.svg index 4a871e270..79df4be8d 100644 --- a/app/assets/icons/ic-code.svg +++ b/app/assets/icons/ic-code.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-lock-filled.svg b/app/assets/icons/ic-lock-filled.svg new file mode 100644 index 000000000..a71db2794 --- /dev/null +++ b/app/assets/icons/ic-lock-filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/icons/ic-markdown.svg b/app/assets/icons/ic-markdown.svg index bceed54b3..1efac5876 100644 --- a/app/assets/icons/ic-markdown.svg +++ b/app/assets/icons/ic-markdown.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-pin-filled.svg b/app/assets/icons/ic-pin-filled.svg new file mode 100644 index 000000000..4e5ae92a5 --- /dev/null +++ b/app/assets/icons/ic-pin-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/icons/ic-spreadsheets.svg b/app/assets/icons/ic-spreadsheets.svg index 70f175be2..2566d69bb 100644 --- a/app/assets/icons/ic-spreadsheets.svg +++ b/app/assets/icons/ic-spreadsheets.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-tasks.svg b/app/assets/icons/ic-tasks.svg index 0f8ef0587..c6b89554f 100644 --- a/app/assets/icons/ic-tasks.svg +++ b/app/assets/icons/ic-tasks.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-text-paragraph.svg b/app/assets/icons/ic-text-paragraph.svg index 4f43cdc0c..376e8ad46 100644 --- a/app/assets/icons/ic-text-paragraph.svg +++ b/app/assets/icons/ic-text-paragraph.svg @@ -1,4 +1,3 @@ - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/assets/icons/ic-text-rich.svg b/app/assets/icons/ic-text-rich.svg index 87f57dd41..d895ca8c4 100644 --- a/app/assets/icons/ic-text-rich.svg +++ b/app/assets/icons/ic-text-rich.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-trash-filled.svg b/app/assets/icons/ic-trash-filled.svg new file mode 100644 index 000000000..63f9575bf --- /dev/null +++ b/app/assets/icons/ic-trash-filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/javascripts/components/Dropdown.tsx b/app/assets/javascripts/components/Dropdown.tsx index e06994bae..5ec2e2932 100644 --- a/app/assets/javascripts/components/Dropdown.tsx +++ b/app/assets/javascripts/components/Dropdown.tsx @@ -13,6 +13,7 @@ import { useState } from 'preact/hooks'; export type DropdownItem = { icon?: IconType; + iconClassName?: string; label: string; value: string; }; @@ -25,10 +26,7 @@ type DropdownProps = { onChange: (value: string) => void; }; -type ListboxButtonProps = { - icon?: IconType; - value: string | null; - label: string; +type ListboxButtonProps = DropdownItem & { isExpanded: boolean; }; @@ -36,12 +34,13 @@ const CustomDropdownButton: FunctionComponent = ({ label, isExpanded, icon, + iconClassName = '', }) => ( <>
{icon ? (
- +
) : null}
{label}
@@ -85,11 +84,13 @@ export const Dropdown: FunctionComponent = ({ children={({ value, label, isExpanded }) => { const current = items.find((item) => item.value === value); const icon = current ? current?.icon : null; + const iconClassName = current ? current?.iconClassName : null; return CustomDropdownButton({ - value, + value: value ? value : label.toLowerCase(), label, isExpanded, ...(icon ? { icon } : null), + ...(iconClassName ? { iconClassName } : null), }); }} /> @@ -104,7 +105,10 @@ export const Dropdown: FunctionComponent = ({ > {item.icon ? (
- +
) : null}
{item.label}
diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 96eee992f..bb82794cf 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -3,7 +3,9 @@ import PencilOffIcon from '../../icons/ic-pencil-off.svg'; import PlainTextIcon from '../../icons/ic-text-paragraph.svg'; import RichTextIcon from '../../icons/ic-text-rich.svg'; import TrashIcon from '../../icons/ic-trash.svg'; +import TrashFilledIcon from '../../icons/ic-trash-filled.svg'; import PinIcon from '../../icons/ic-pin.svg'; +import PinFilledIcon from '../../icons/ic-pin-filled.svg'; import UnpinIcon from '../../icons/ic-pin-off.svg'; import ArchiveIcon from '../../icons/ic-archive.svg'; import UnarchiveIcon from '../../icons/ic-unarchive.svg'; @@ -52,6 +54,7 @@ import ServerIcon from '../../icons/ic-server.svg'; import EyeIcon from '../../icons/ic-eye.svg'; import EyeOffIcon from '../../icons/ic-eye-off.svg'; import LockIcon from '../../icons/ic-lock.svg'; +import LockFilledIcon from '../../icons/ic-lock-filled.svg'; import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg'; import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg'; import WindowIcon from '../../icons/ic-window.svg'; @@ -69,6 +72,7 @@ const ICONS = { 'arrows-sort-up': ArrowsSortUpIcon, 'arrows-sort-down': ArrowsSortDownIcon, lock: LockIcon, + 'lock-filled': LockFilledIcon, eye: EyeIcon, 'eye-off': EyeOffIcon, server: ServerIcon, @@ -89,7 +93,9 @@ const ICONS = { spreadsheets: SpreadsheetsIcon, tasks: TasksIcon, trash: TrashIcon, + 'trash-filled': TrashFilledIcon, pin: PinIcon, + 'pin-filled': PinFilledIcon, unpin: UnpinIcon, archive: ArchiveIcon, unarchive: UnarchiveIcon, @@ -130,11 +136,22 @@ export type IconType = keyof typeof ICONS; type Props = { type: IconType; className?: string; + ariaLabel?: string; }; -export const Icon: FunctionalComponent = ({ type, className = '' }) => { +export const Icon: FunctionalComponent = ({ + type, + className = '', + ariaLabel, +}) => { const IconComponent = ICONS[type]; - return ; + return ( + + ); }; export const IconDirective = toDirective(Icon, { diff --git a/app/assets/javascripts/components/NotesList.tsx b/app/assets/javascripts/components/NotesList.tsx index e443393aa..4b505bb58 100644 --- a/app/assets/javascripts/components/NotesList.tsx +++ b/app/assets/javascripts/components/NotesList.tsx @@ -1,3 +1,4 @@ +import { WebApplication } from '@/ui_models/application'; import { KeyboardKey } from '@/services/ioService'; import { AppState } from '@/ui_models/app_state'; import { DisplayOptions } from '@/ui_models/app_state/notes_view_state'; @@ -7,6 +8,7 @@ import { FunctionComponent } from 'preact'; import { NotesListItem } from './NotesListItem'; type Props = { + application: WebApplication; appState: AppState; notes: SNNote[]; selectedNotes: Record; @@ -18,23 +20,30 @@ const FOCUSABLE_BUT_NOT_TABBABLE = -1; const NOTES_LIST_SCROLL_THRESHOLD = 200; export const NotesList: FunctionComponent = observer( - ({ appState, notes, selectedNotes, displayOptions, paginate }) => { + ({ + application, + appState, + notes, + selectedNotes, + displayOptions, + paginate, + }) => { const { selectPreviousNote, selectNextNote } = appState.notesView; const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions; - const tagsStringForNote = (note: SNNote): string => { + const tagsForNote = (note: SNNote): string[] => { if (hideTags) { - return ''; + return []; } const selectedTag = appState.selectedTag; if (!selectedTag) { - return ''; + return []; } const tags = appState.getNoteTags(note); if (!selectedTag.isSmartTag && tags.length === 1) { - return ''; + return []; } - return tags.map((tag) => `#${tag.title}`).join(' '); + return tags.map((tag) => tag.title); }; const openNoteContextMenu = (posX: number, posY: number) => { @@ -46,11 +55,9 @@ export const NotesList: FunctionComponent = observer( appState.notes.setContextMenuOpen(true); }; - const onContextMenu = async (note: SNNote, posX: number, posY: number) => { - await appState.notes.selectNote(note.uuid, true); - if (selectedNotes[note.uuid]) { - openNoteContextMenu(posX, posY); - } + const onContextMenu = (note: SNNote, posX: number, posY: number) => { + appState.notes.selectNote(note.uuid, true); + openNoteContextMenu(posX, posY); }; const onScroll = (e: Event) => { @@ -84,9 +91,10 @@ export const NotesList: FunctionComponent = observer( > {notes.map((note) => ( { const flags = [] as NoteFlag[]; - if (note.pinned) { - flags.push({ - text: 'Pinned', - class: 'info', - }); - } - if (note.archived) { - flags.push({ - text: 'Archived', - class: 'warning', - }); - } - if (note.locked) { - flags.push({ - text: 'Editing Disabled', - class: 'neutral', - }); - } - if (note.trashed) { - flags.push({ - text: 'Deleted', - class: 'danger', - }); - } if (note.conflictOf) { flags.push({ text: 'Conflicted Copy', @@ -77,6 +57,7 @@ const flagsForNote = (note: SNNote) => { }; export const NotesListItem: FunctionComponent = ({ + application, hideDate, hidePreview, hideTags, @@ -89,6 +70,9 @@ export const NotesListItem: FunctionComponent = ({ }) => { const flags = flagsForNote(note); const showModifiedDate = sortedBy === CollectionSort.UpdatedAt; + const editorForNote = application.componentManager.editorForNote(note); + const editorName = editorForNote?.name ?? 'Plain editor'; + const [icon, tint] = getIconAndTintForEditor(editorForNote?.identifier); return (
= ({ onClick={onClick} onContextMenu={onContextMenu} > - {flags && flags.length > 0 ? ( -
- {flags.map((flag) => ( -
-
{flag.text}
-
- ))} +
+ +
+
+
+
{note.title}
+
+ {note.locked && ( + + + + )} + {note.trashed && ( + + + + )} + {note.archived && ( + + + + )} + {note.pinned && ( + + + + )} +
- ) : null} -
{note.title}
- {!hidePreview && !note.hidePreview && !note.protected ? ( -
- {note.preview_html ? ( -
- ) : null} - {!note.preview_html && note.preview_plain ? ( -
{note.preview_plain}
- ) : null} - {!note.preview_html && !note.preview_plain ? ( -
{note.text}
- ) : null} -
- ) : null} - {!hideDate || note.protected ? ( -
- {note.protected ? ( - Protected {hideDate ? '' : ' • '} - ) : null} - {!hideDate && showModifiedDate ? ( - Modified {note.updatedAtString || 'Now'} - ) : null} - {!hideDate && !showModifiedDate ? ( - {note.createdAtString || 'Now'} - ) : null} -
- ) : null} - {!hideTags && ( -
-
{tags}
-
- )} + {!hidePreview && !note.hidePreview && !note.protected && ( +
+ {note.preview_html && ( +
+ )} + {!note.preview_html && note.preview_plain && ( +
{note.preview_plain}
+ )} + {!note.preview_html && !note.preview_plain && note.text && ( +
{note.text}
+ )} +
+ )} + {!hideDate || note.protected ? ( +
+ {note.protected && Protected {hideDate ? '' : ' • '}} + {!hideDate && showModifiedDate && ( + Modified {note.updatedAtString || 'Now'} + )} + {!hideDate && !showModifiedDate && ( + {note.createdAtString || 'Now'} + )} +
+ ) : null} + {!hideTags && tags.length ? ( +
+ {tags.map((tag) => ( + + + {tag} + + ))} +
+ ) : null} + {flags.length ? ( +
+ {flags.map((flag) => ( +
+
{flag.text}
+
+ ))} +
+ ) : null} +
); }; diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx index 004a54e1d..6c1b8e0df 100644 --- a/app/assets/javascripts/components/NotesView.tsx +++ b/app/assets/javascripts/components/NotesView.tsx @@ -230,6 +230,7 @@ const NotesView: FunctionComponent = observer( { +export const getIconAndTintForEditor = ( + identifier: FeatureIdentifier | undefined +): [IconType, number] => { switch (identifier) { case FeatureIdentifier.BoldEditor: case FeatureIdentifier.PlusEditor: - return 'rich-text'; + return ['rich-text', 1]; case FeatureIdentifier.MarkdownBasicEditor: case FeatureIdentifier.MarkdownMathEditor: case FeatureIdentifier.MarkdownMinimistEditor: case FeatureIdentifier.MarkdownProEditor: - return 'markdown'; + return ['markdown', 2]; case FeatureIdentifier.TokenVaultEditor: - return 'authenticator'; + return ['authenticator', 6]; case FeatureIdentifier.SheetsEditor: - return 'spreadsheets'; + return ['spreadsheets', 5]; case FeatureIdentifier.TaskEditor: - return 'tasks'; + return ['tasks', 3]; case FeatureIdentifier.CodeEditor: - return 'code'; + return ['code', 4]; + default: + return ['plain-text', 1]; } - return null; }; const makeEditorDefault = ( @@ -91,17 +92,19 @@ export const Defaults: FunctionComponent = ({ application }) => { .componentsForArea(ComponentArea.Editor) .map((editor): EditorOption => { const identifier = editor.package_info.identifier; - const iconType = getEditorIconType(identifier); + const [iconType, tint] = getIconAndTintForEditor(identifier); return { label: editor.name, value: identifier, ...(iconType ? { icon: iconType } : null), + ...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null), }; }) .concat([ { icon: 'plain-text', + iconClassName: `color-accessory-tint-1`, label: 'Plain Editor', value: 'plain-editor', }, diff --git a/app/assets/stylesheets/_notes.scss b/app/assets/stylesheets/_notes.scss index 6ec937804..cf4a3a365 100644 --- a/app/assets/stylesheets/_notes.scss +++ b/app/assets/stylesheets/_notes.scss @@ -123,42 +123,95 @@ notes-view { } .note { + display: flex; + align-items: stretch; + width: 100%; - padding: 15px; - border-bottom: 1px solid var(--sn-stylekit-border-color); cursor: pointer; - > .name { - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; + &:hover { + background-color: var(--sn-stylekit-grey-5); } - > .bottom-info { - font-size: 12px; - margin-top: 4px; + .icon { + display: flex; + flex-flow: column; + align-items: center; + justify-content: space-between; + padding: 0.9rem; + padding-right: 0.75rem; + margin-right: 0; + } + + .meta { + flex-grow: 1; + min-width: 0; + padding: 0.9rem; + padding-left: 0; + border-bottom: 1px solid var(--sn-stylekit-border-color); + + .name { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 1rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + } + + .flag-icons { + &, + & > * { + display: flex; + align-items: center; + } + + & > * + * { + margin-left: 0.375rem; + } + } + + .bottom-info { + font-size: 12px; + line-height: 1.4; + margin-top: 0.25rem; + } } .tags-string { - margin-top: 4px; - font-size: 12px; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.345rem; + font-size: 0.725rem; + + .tag { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.375rem 0.25rem 0.325rem; + background-color: var(--sn-stylekit-grey-4-opacity-variant); + border-radius: 0.125rem; + } } .note-preview { font-size: var(--sn-stylekit-font-size-h3); - margin-top: 2px; - overflow: hidden; text-overflow: ellipsis; + & > * { + margin-top: 0.15rem; + } + .default-preview, .plain-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; /* number of lines to show */ - $line-height: 18px; - line-height: $line-height; /* fallback */ - max-height: calc(#{$line-height} * 1); /* fallback */ + line-height: 1.3; + overflow: hidden; } .html-preview { @@ -175,8 +228,7 @@ notes-view { display: flex; flex-direction: row; align-items: center; - margin-bottom: 8px; - margin-top: -4px; + margin-top: 0.125rem; .flag { padding: 4px; @@ -238,13 +290,8 @@ notes-view { } &.selected { - background-color: var(--sn-stylekit-info-color); - color: var(--sn-stylekit-info-contrast-color); - - .note-flags .flag { - background-color: var(--sn-stylekit-info-contrast-color); - color: var(--sn-stylekit-info-color); - } + background-color: var(--sn-stylekit-grey-5); + border-left: 2px solid var(--sn-stylekit-info-color); progress { background-color: var(--sn-stylekit-secondary-foreground-color); @@ -255,7 +302,7 @@ notes-view { } &::-webkit-progress-value { - background-color: var(--sn-stylekit-secondary-background-color); + background-color: var(--sn-stylekit-info-color); } &::-moz-progress-bar { diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 5ba5161c0..48198c039 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -40,6 +40,11 @@ @extend .h-3\.5; @extend .w-3\.5; } + + &.sn-icon--mid { + @extend .w-4; + @extend .h-4; + } } .sn-dropdown { @@ -777,6 +782,7 @@ } &:hover { + background-color: var(--sn-stylekit-contrast-background-color) !important; @extend .color-info; @extend .border-info; } diff --git a/package.json b/package.json index 087a79eed..fef47586f 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "pug-loader": "^2.4.0", "sass-loader": "^12.2.0", "serve-static": "^1.14.1", - "sn-stylekit": "5.2.20", + "sn-stylekit": "5.2.21", "svg-jest": "^1.0.1", "ts-jest": "^27.0.7", "ts-loader": "^9.2.6", diff --git a/yarn.lock b/yarn.lock index 08588f4fb..3a4040c30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9264,10 +9264,10 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" -sn-stylekit@5.2.20: - version "5.2.20" - resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.20.tgz#c18f40ff3aaf4c59af89152439a8efbdde35f2dd" - integrity sha512-JymHBiZOzQPfCqHYgnVPSA2PwJqiKR268qqQoEMqI85MMAWSG3WYzuKEbd0LgfIQAKLElCxJjeZkrhejyRg+2A== +sn-stylekit@5.2.21: + version "5.2.21" + resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.21.tgz#5aec6c329949bda64a1e3c563ee594b141295d27" + integrity sha512-rjlgo42A/kx+M4iY7HYRpnQyp4dLb2HQpEMHz+CYumOzTf/lsRy0Up5HI1haNK4/JMmpq36Eb/7BMDmvLpdXnQ== dependencies: "@reach/listbox" "^0.15.0" "@reach/menu-button" "^0.15.1" From 80bf7baf16e1f926d2d562bd9290d871cd5d1075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 5 Jan 2022 16:12:22 +0100 Subject: [PATCH 02/18] Revert "Revert "feat: native smart tags (#782)"" This reverts commit b57350c8991a87f6a9534878e9f35c5a7f31ae61. --- app/assets/icons/ic-notes.svg | 3 + app/assets/javascripts/app.ts | 79 ++--- app/assets/javascripts/components/Icon.tsx | 2 + .../javascripts/components/Navigation.tsx | 117 +++++++ app/assets/javascripts/components/NoteTag.tsx | 2 +- .../javascripts/components/NotesView.tsx | 6 +- .../components/Premium/usePremiumModal.tsx | 50 +-- .../components/{ => Tags}/RootTagDropZone.tsx | 8 +- .../components/Tags/SmartTagsList.tsx | 29 ++ .../components/Tags/SmartTagsListItem.tsx | 163 ++++++++++ .../components/Tags/SmartTagsSection.tsx | 19 ++ .../javascripts/components/Tags/TagsList.tsx | 48 +++ .../components/{ => Tags}/TagsListItem.tsx | 68 ++-- .../components/Tags/TagsSection.tsx | 109 +------ .../components/Tags/TagsSectionAddButton.tsx | 30 ++ .../components/Tags/TagsSectionTitle.tsx | 62 ++++ .../javascripts/components/Tags/dragndrop.ts | 9 + .../javascripts/components/TagsList.tsx | 153 --------- app/assets/javascripts/strings.ts | 2 + .../ui_models/app_state/app_state.ts | 217 ++++++------- .../ui_models/app_state/features_state.ts | 63 +++- .../ui_models/app_state/notes_state.ts | 15 +- .../ui_models/app_state/notes_view_state.ts | 4 +- .../ui_models/app_state/tags_state.ts | 261 ++++++++++++++- .../javascripts/ui_models/panel_resizer.ts | 11 +- .../views/application/application-view.pug | 2 +- .../views/application/application_view.ts | 12 +- app/assets/javascripts/views/constants.ts | 2 +- app/assets/javascripts/views/index.ts | 1 - .../javascripts/views/tags/tags-view.pug | 43 --- .../javascripts/views/tags/tags_view.ts | 298 ------------------ app/assets/stylesheets/_focused.scss | 6 +- app/assets/stylesheets/_tags.scss | 4 + 33 files changed, 1030 insertions(+), 868 deletions(-) create mode 100644 app/assets/icons/ic-notes.svg create mode 100644 app/assets/javascripts/components/Navigation.tsx rename app/assets/javascripts/components/{ => Tags}/RootTagDropZone.tsx (88%) create mode 100644 app/assets/javascripts/components/Tags/SmartTagsList.tsx create mode 100644 app/assets/javascripts/components/Tags/SmartTagsListItem.tsx create mode 100644 app/assets/javascripts/components/Tags/SmartTagsSection.tsx create mode 100644 app/assets/javascripts/components/Tags/TagsList.tsx rename app/assets/javascripts/components/{ => Tags}/TagsListItem.tsx (81%) create mode 100644 app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx create mode 100644 app/assets/javascripts/components/Tags/TagsSectionTitle.tsx create mode 100644 app/assets/javascripts/components/Tags/dragndrop.ts delete mode 100644 app/assets/javascripts/components/TagsList.tsx delete mode 100644 app/assets/javascripts/views/tags/tags-view.pug delete mode 100644 app/assets/javascripts/views/tags/tags_view.ts diff --git a/app/assets/icons/ic-notes.svg b/app/assets/icons/ic-notes.svg new file mode 100644 index 000000000..ece661333 --- /dev/null +++ b/app/assets/icons/ic-notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 2d1762e8d..bd5d4e066 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -20,23 +20,32 @@ declare global { } } -import { SNLog } from '@standardnotes/snjs'; -import angular from 'angular'; -import { configRoutes } from './routes'; - -import { ApplicationGroup } from './ui_models/application_group'; -import { AccountSwitcher } from './views/account_switcher/account_switcher'; - +import { ComponentViewDirective } from '@/components/ComponentView'; +import { NavigationDirective } from '@/components/Navigation'; +import { PinNoteButtonDirective } from '@/components/PinNoteButton'; +import { IsWebPlatform, WebAppVersion } from '@/version'; import { ApplicationGroupView, - ApplicationView, - NoteGroupViewDirective, - NoteViewDirective, - TagsView, - FooterView, - ChallengeModal, + ApplicationView, ChallengeModal, + FooterView, NoteGroupViewDirective, + NoteViewDirective } from '@/views'; - +import { SNLog } from '@standardnotes/snjs'; +import angular from 'angular'; +import { AccountMenuDirective } from './components/AccountMenu'; +import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; +import { IconDirective } from './components/Icon'; +import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; +import { NoAccountWarningDirective } from './components/NoAccountWarning'; +import { NotesContextMenuDirective } from './components/NotesContextMenu'; +import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; +import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; +import { NotesViewDirective } from './components/NotesView'; +import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; +import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; +import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; +import { SearchOptionsDirective } from './components/SearchOptions'; +import { SessionsModalDirective } from './components/SessionsModal'; import { autofocus, clickOutside, @@ -46,49 +55,31 @@ import { infiniteScroll, lowercase, selectOnFocus, - snEnter, + snEnter } from './directives/functional'; - import { ActionsMenu, EditorMenu, + HistoryMenu, InputModal, MenuRow, PanelResizer, PasswordWizard, PermissionsModal, RevisionPreviewModal, - HistoryMenu, - SyncResolutionMenu, + SyncResolutionMenu } from './directives/views'; - import { trusted } from './filters'; -import { isDev } from './utils'; +import { PreferencesDirective } from './preferences'; +import { PurchaseFlowDirective } from './purchaseFlow'; +import { configRoutes } from './routes'; +import { Bridge } from './services/bridge'; import { BrowserBridge } from './services/browserBridge'; import { startErrorReporting } from './services/errorReporting'; import { StartApplication } from './startApplication'; -import { Bridge } from './services/bridge'; -import { SessionsModalDirective } from './components/SessionsModal'; -import { NoAccountWarningDirective } from './components/NoAccountWarning'; -import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; -import { SearchOptionsDirective } from './components/SearchOptions'; -import { AccountMenuDirective } from './components/AccountMenu'; -import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; -import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; -import { NotesContextMenuDirective } from './components/NotesContextMenu'; -import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; -import { IconDirective } from './components/Icon'; -import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; -import { PreferencesDirective } from './preferences'; -import { WebAppVersion, IsWebPlatform } from '@/version'; -import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; -import { PurchaseFlowDirective } from './purchaseFlow'; -import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; -import { ComponentViewDirective } from '@/components/ComponentView'; -import { TagsListDirective } from '@/components/TagsList'; -import { NotesViewDirective } from './components/NotesView'; -import { PinNoteButtonDirective } from '@/components/PinNoteButton'; -import { TagsSectionDirective } from './components/Tags/TagsSection'; +import { ApplicationGroup } from './ui_models/application_group'; +import { isDev } from './utils'; +import { AccountSwitcher } from './views/account_switcher/account_switcher'; function reloadHiddenFirefoxTab(): boolean { /** @@ -143,7 +134,6 @@ const startApplication: StartApplication = async function startApplication( .directive('applicationView', () => new ApplicationView()) .directive('noteGroupView', () => new NoteGroupViewDirective()) .directive('noteView', () => new NoteViewDirective()) - .directive('tagsView', () => new TagsView()) .directive('footerView', () => new FooterView()); // Directives - Functional @@ -188,8 +178,7 @@ const startApplication: StartApplication = async function startApplication( .directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('tagsList', TagsListDirective) - .directive('tagsSection', TagsSectionDirective) + .directive('navigation', NavigationDirective) .directive('preferences', PreferencesDirective) .directive('purchaseFlow', PurchaseFlowDirective) .directive('notesView', NotesViewDirective) diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index bb82794cf..6d5b6d3bc 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -23,6 +23,7 @@ import AuthenticatorIcon from '../../icons/ic-authenticator.svg'; import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg'; import TasksIcon from '../../icons/ic-tasks.svg'; import MarkdownIcon from '../../icons/ic-markdown.svg'; +import NotesIcon from '../../icons/ic-notes.svg'; import CodeIcon from '../../icons/ic-code.svg'; import AccessibilityIcon from '../../icons/ic-accessibility.svg'; @@ -69,6 +70,7 @@ import { FunctionalComponent } from 'preact'; const ICONS = { 'menu-arrow-down-alt': MenuArrowDownAlt, 'menu-arrow-right': MenuArrowRight, + notes: NotesIcon, 'arrows-sort-up': ArrowsSortUpIcon, 'arrows-sort-down': ArrowsSortDownIcon, lock: LockIcon, diff --git a/app/assets/javascripts/components/Navigation.tsx b/app/assets/javascripts/components/Navigation.tsx new file mode 100644 index 000000000..b3e5dcfd4 --- /dev/null +++ b/app/assets/javascripts/components/Navigation.tsx @@ -0,0 +1,117 @@ +import { ComponentView } from '@/components/ComponentView'; +import { PanelResizer } from '@/components/PanelResizer'; +import { SmartTagsSection } from '@/components/Tags/SmartTagsSection'; +import { TagsSection } from '@/components/Tags/TagsSection'; +import { toDirective } from '@/components/utils'; +import { + PanelSide, + ResizeFinishCallback, +} from '@/directives/views/panelResizer'; +import { WebApplication } from '@/ui_models/application'; +import { PANEL_NAME_NAVIGATION } from '@/views/constants'; +import { PrefKey } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { PremiumModalProvider } from './Premium'; + +type Props = { + application: WebApplication; +}; + +export const Navigation: FunctionComponent = observer( + ({ application }) => { + const appState = useMemo(() => application.getAppState(), [application]); + const componentViewer = appState.foldersComponentViewer; + const enableNativeSmartTagsFeature = + appState.features.enableNativeSmartTagsFeature; + const [panelRef, setPanelRef] = useState(null); + + useEffect(() => { + const elem = document.querySelector( + 'navigation' + ) as HTMLDivElement | null; + setPanelRef(elem); + }, [setPanelRef]); + + const onCreateNewTag = useCallback(() => { + appState.tags.createNewTemplate(); + }, [appState]); + + const panelResizeFinishCallback: ResizeFinishCallback = useCallback( + (_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => { + appState.noteTags.reloadTagsContainerMaxWidth(); + appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed); + }, + [appState] + ); + + const panelWidthEventCallback = useCallback(() => { + appState.noteTags.reloadTagsContainerMaxWidth(); + }, [appState]); + + return ( + +
+ {componentViewer ? ( +
+
+ +
+
+ ) : ( +
+
+
+
+ Views +
+ {!enableNativeSmartTagsFeature && ( +
+
+ +
+
+ )} +
+
+
+
+ + +
+
+
+ )} + {panelRef && ( + + )} +
+
+ ); + } +); + +export const NavigationDirective = toDirective(Navigation); diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx index 91dc12aba..bd6ecd767 100644 --- a/app/assets/javascripts/components/NoteTag.tsx +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -32,7 +32,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => { const onTagClick = (event: MouseEvent) => { if (tagClicked && event.target !== deleteTagRef.current) { setTagClicked(false); - appState.setSelectedTag(tag); + appState.selectedTag = tag; } else { setTagClicked(true); } diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx index 6c1b8e0df..622bd31aa 100644 --- a/app/assets/javascripts/components/NotesView.tsx +++ b/app/assets/javascripts/components/NotesView.tsx @@ -124,9 +124,9 @@ const NotesView: FunctionComponent = observer( }; const panelResizeFinishCallback: ResizeFinishCallback = ( - _w, - _l, - _mw, + _lastWidth, + _lastLeft, + _isMaxWidth, isCollapsed ) => { appState.noteTags.reloadTagsContainerMaxWidth(); diff --git a/app/assets/javascripts/components/Premium/usePremiumModal.tsx b/app/assets/javascripts/components/Premium/usePremiumModal.tsx index d52b15823..a30e9dbf9 100644 --- a/app/assets/javascripts/components/Premium/usePremiumModal.tsx +++ b/app/assets/javascripts/components/Premium/usePremiumModal.tsx @@ -1,3 +1,5 @@ +import { FeaturesState } from '@/ui_models/app_state/features_state'; +import { observer } from 'mobx-react-lite'; import { FunctionalComponent } from 'preact'; import { useCallback, useContext, useState } from 'preact/hooks'; import { createContext } from 'react'; @@ -21,29 +23,31 @@ export const usePremiumModal = (): PremiumModalContextData => { return value; }; -export const PremiumModalProvider: FunctionalComponent = ({ children }) => { - const [featureName, setFeatureName] = useState(null); +interface Props { + state: FeaturesState; +} - const activate = setFeatureName; +export const PremiumModalProvider: FunctionalComponent = observer( + ({ state, children }) => { + const featureName = state._premiumAlertFeatureName; + const activate = state.showPremiumAlert; + const close = state.closePremiumAlert; - const closeModal = useCallback(() => { - setFeatureName(null); - }, [setFeatureName]); + const showModal = !!featureName; - const showModal = !!featureName; - - return ( - <> - {showModal && ( - - )} - - {children} - - - ); -}; + return ( + <> + {showModal && ( + + )} + + {children} + + + ); + } +); diff --git a/app/assets/javascripts/components/RootTagDropZone.tsx b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx similarity index 88% rename from app/assets/javascripts/components/RootTagDropZone.tsx rename to app/assets/javascripts/components/Tags/RootTagDropZone.tsx index 0e39ef5a8..1c6908a51 100644 --- a/app/assets/javascripts/components/RootTagDropZone.tsx +++ b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx @@ -1,3 +1,5 @@ +import { Icon } from '@/components/Icon'; +import { usePremiumModal } from '@/components/Premium'; import { FeaturesState, TAG_FOLDERS_FEATURE_NAME, @@ -5,9 +7,7 @@ import { import { TagsState } from '@/ui_models/app_state/tags_state'; import { observer } from 'mobx-react-lite'; import { useDrop } from 'react-dnd'; -import { Icon } from './Icon'; -import { usePremiumModal } from './Premium'; -import { DropItem, DropProps, ItemTypes } from './TagsListItem'; +import { DropItem, DropProps, ItemTypes } from './dragndrop'; type Props = { tagsState: TagsState; @@ -18,7 +18,7 @@ export const RootTagDropZone: React.FC = observer( ({ tagsState, featuresState }) => { const premiumModal = usePremiumModal(); const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature; - const hasFolders = tagsState.hasFolders; + const hasFolders = featuresState.hasFolders; const [{ isOver, canDrop }, dropRef] = useDrop( () => ({ diff --git a/app/assets/javascripts/components/Tags/SmartTagsList.tsx b/app/assets/javascripts/components/Tags/SmartTagsList.tsx new file mode 100644 index 000000000..77b06eec8 --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsList.tsx @@ -0,0 +1,29 @@ +import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { SmartTagsListItem } from './SmartTagsListItem'; + +type Props = { + appState: AppState; +}; + +export const SmartTagsList: FunctionComponent = observer( + ({ appState }) => { + const allTags = appState.tags.smartTags; + + return ( + <> + {allTags.map((tag) => { + return ( + + ); + })} + + ); + } +); diff --git a/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx new file mode 100644 index 000000000..0284b0ae9 --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx @@ -0,0 +1,163 @@ +import { Icon, IconType } from '@/components/Icon'; +import { FeaturesState } from '@/ui_models/app_state/features_state'; +import { TagsState } from '@/ui_models/app_state/tags_state'; +import '@reach/tooltip/styles.css'; +import { SNSmartTag } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; + +type Props = { + tag: SNSmartTag; + tagsState: TagsState; + features: FeaturesState; +}; + +const smartTagIconType = (tag: SNSmartTag): IconType => { + if (tag.isAllTag) { + return 'notes'; + } + if (tag.isArchiveTag) { + return 'archive'; + } + if (tag.isTrashTag) { + return 'trash'; + } + return 'hashtag'; +}; + +export const SmartTagsListItem: FunctionComponent = observer( + ({ tag, tagsState, features }) => { + const [title, setTitle] = useState(tag.title || ''); + const inputRef = useRef(null); + + const level = 0; + const isSelected = tagsState.selected === tag; + const isEditing = tagsState.editingTag === tag; + const isSmartTagsEnabled = features.enableNativeSmartTagsFeature; + + useEffect(() => { + setTitle(tag.title || ''); + }, [setTitle, tag]); + + const selectCurrentTag = useCallback(() => { + tagsState.selected = tag; + }, [tagsState, tag]); + + const onBlur = useCallback(() => { + tagsState.save(tag, title); + setTitle(tag.title); + }, [tagsState, tag, title, setTitle]); + + const onInput = useCallback( + (e: Event) => { + const value = (e.target as HTMLInputElement).value; + setTitle(value); + }, + [setTitle] + ); + + const onKeyUp = useCallback( + (e: KeyboardEvent) => { + if (e.code === 'Enter') { + inputRef.current?.blur(); + e.preventDefault(); + } + }, + [inputRef] + ); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } + }, [inputRef, isEditing]); + + const onClickRename = useCallback(() => { + tagsState.editingTag = tag; + }, [tagsState, tag]); + + const onClickSave = useCallback(() => { + inputRef.current?.blur(); + }, [inputRef]); + + const onClickDelete = useCallback(() => { + tagsState.remove(tag); + }, [tagsState, tag]); + + const isFaded = !isSmartTagsEnabled && !tag.isAllTag; + const iconType = smartTagIconType(tag); + + return ( + <> +
+ {!tag.errorDecrypting ? ( +
+ {isSmartTagsEnabled && ( +
+ +
+ )} + +
+ {tag.isAllTag && tagsState.allNotesCount} +
+
+ ) : null} + {!tag.isSystemSmartTag && ( +
+ {tag.conflictOf && ( +
+ Conflicted Copy {tag.conflictOf} +
+ )} + {tag.errorDecrypting && !tag.waitingForKey && ( +
Missing Keys
+ )} + {tag.errorDecrypting && tag.waitingForKey && ( +
+ Waiting For Keys +
+ )} + {isSelected && ( +
+ {!isEditing && ( + + Rename + + )} + {isEditing && ( + + Save + + )} + + Delete + +
+ )} +
+ )} +
+ + ); + } +); diff --git a/app/assets/javascripts/components/Tags/SmartTagsSection.tsx b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx new file mode 100644 index 000000000..d647ca6ef --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx @@ -0,0 +1,19 @@ +import { WebApplication } from '@/ui_models/application'; +import { AppState } from '@/ui_models/app_state'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { SmartTagsList } from './SmartTagsList'; + +type Props = { + appState: AppState; +}; + +export const SmartTagsSection: FunctionComponent = observer( + ({ appState }) => { + return ( +
+ +
+ ); + } +); diff --git a/app/assets/javascripts/components/Tags/TagsList.tsx b/app/assets/javascripts/components/Tags/TagsList.tsx new file mode 100644 index 000000000..0d6ab0b15 --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsList.tsx @@ -0,0 +1,48 @@ +import { AppState } from '@/ui_models/app_state'; +import { isMobile } from '@/utils'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { TouchBackend } from 'react-dnd-touch-backend'; +import { RootTagDropZone } from './RootTagDropZone'; +import { TagsListItem } from './TagsListItem'; + +type Props = { + appState: AppState; +}; + +export const TagsList: FunctionComponent = observer(({ appState }) => { + const tagsState = appState.tags; + const allTags = tagsState.allLocalRootTags; + + const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend; + + return ( + + {allTags.length === 0 ? ( +
+ No tags. Create one using the add button above. +
+ ) : ( + <> + {allTags.map((tag) => { + return ( + + ); + })} + + + )} +
+ ); +}); diff --git a/app/assets/javascripts/components/TagsListItem.tsx b/app/assets/javascripts/components/Tags/TagsListItem.tsx similarity index 81% rename from app/assets/javascripts/components/TagsListItem.tsx rename to app/assets/javascripts/components/Tags/TagsListItem.tsx index ed6e8cf5e..8dac77728 100644 --- a/app/assets/javascripts/components/TagsListItem.tsx +++ b/app/assets/javascripts/components/Tags/TagsListItem.tsx @@ -1,3 +1,5 @@ +import { Icon } from '@/components/Icon'; +import { usePremiumModal } from '@/components/Premium'; import { FeaturesState, TAG_FOLDERS_FEATURE_NAME, @@ -5,56 +7,36 @@ import { import { TagsState } from '@/ui_models/app_state/tags_state'; import '@reach/tooltip/styles.css'; import { SNTag } from '@standardnotes/snjs'; -import { computed, runInAction } from 'mobx'; +import { computed } from 'mobx'; import { observer } from 'mobx-react-lite'; import { FunctionComponent, JSX } from 'preact'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useDrag, useDrop } from 'react-dnd'; -import { Icon } from './Icon'; -import { usePremiumModal } from './Premium'; - -export enum ItemTypes { - TAG = 'TAG', -} - -export type DropItemTag = { uuid: string }; - -export type DropItem = DropItemTag; - -export type DropProps = { isOver: boolean; canDrop: boolean }; +import { DropItem, DropProps, ItemTypes } from './dragndrop'; type Props = { tag: SNTag; tagsState: TagsState; - selectTag: (tag: SNTag) => void; - removeTag: (tag: SNTag) => void; - saveTag: (tag: SNTag, newTitle: string) => void; - appState: TagsListState; + features: FeaturesState; level: number; }; -export type TagsListState = { - readonly selectedTag: SNTag | undefined; - tags: TagsState; - editingTag: SNTag | undefined; - features: FeaturesState; -}; - export const TagsListItem: FunctionComponent = observer( - ({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => { + ({ tag, features, tagsState, level }) => { const [title, setTitle] = useState(tag.title || ''); const inputRef = useRef(null); - const isSelected = appState.selectedTag === tag; - const isEditing = appState.editingTag === tag; - const noteCounts = computed(() => appState.tags.getNotesCount(tag)); + const isSelected = tagsState.selected === tag; + const isEditing = tagsState.editingTag === tag; + const noteCounts = computed(() => tagsState.getNotesCount(tag)); const childrenTags = computed(() => tagsState.getChildren(tag)).get(); const hasChildren = childrenTags.length > 0; - const hasFolders = tagsState.hasFolders; - const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature; + const hasFolders = features.hasFolders; + const isNativeFoldersEnabled = features.enableNativeFoldersFeature; const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder; + const premiumModal = usePremiumModal(); const [showChildren, setShowChildren] = useState(hasChildren); @@ -80,16 +62,13 @@ export const TagsListItem: FunctionComponent = observer( ); const selectCurrentTag = useCallback(() => { - if (isEditing || isSelected) { - return; - } - selectTag(tag); - }, [isSelected, isEditing, selectTag, tag]); + tagsState.selected = tag; + }, [tagsState, tag]); const onBlur = useCallback(() => { - saveTag(tag, title); + tagsState.save(tag, title); setTitle(tag.title); - }, [tag, saveTag, title, setTitle]); + }, [tagsState, tag, title, setTitle]); const onInput = useCallback( (e: JSX.TargetedEvent) => { @@ -116,18 +95,16 @@ export const TagsListItem: FunctionComponent = observer( }, [inputRef, isEditing]); const onClickRename = useCallback(() => { - runInAction(() => { - appState.editingTag = tag; - }); - }, [appState, tag]); + tagsState.editingTag = tag; + }, [tagsState, tag]); const onClickSave = useCallback(() => { inputRef.current?.blur(); }, [inputRef]); const onClickDelete = useCallback(() => { - removeTag(tag); - }, [removeTag, tag]); + tagsState.remove(tag); + }, [tagsState, tag]); const [, dragRef] = useDrag( () => ({ @@ -255,10 +232,7 @@ export const TagsListItem: FunctionComponent = observer( key={tag.uuid} tag={tag} tagsState={tagsState} - selectTag={selectTag} - saveTag={saveTag} - removeTag={removeTag} - appState={appState} + features={features} /> ); })} diff --git a/app/assets/javascripts/components/Tags/TagsSection.tsx b/app/assets/javascripts/components/Tags/TagsSection.tsx index 5cc7a452d..636206f73 100644 --- a/app/assets/javascripts/components/Tags/TagsSection.tsx +++ b/app/assets/javascripts/components/Tags/TagsSection.tsx @@ -1,108 +1,29 @@ -import { TagsList } from '@/components/TagsList'; -import { toDirective } from '@/components/utils'; -import { WebApplication } from '@/ui_models/application'; +import { TagsList } from '@/components/Tags/TagsList'; import { AppState } from '@/ui_models/app_state'; -import { - FeaturesState, - TAG_FOLDERS_FEATURE_NAME, - TAG_FOLDERS_FEATURE_TOOLTIP, -} from '@/ui_models/app_state/features_state'; -import { Tooltip } from '@reach/tooltip'; import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; -import { useCallback } from 'preact/hooks'; -import { IconButton } from '../IconButton'; -import { PremiumModalProvider, usePremiumModal } from '../Premium'; +import { TagsSectionAddButton } from './TagsSectionAddButton'; +import { TagsSectionTitle } from './TagsSectionTitle'; type Props = { - application: WebApplication; appState: AppState; }; -const TagAddButton: FunctionComponent<{ - appState: AppState; - features: FeaturesState; -}> = observer(({ appState, features }) => { - const isNativeFoldersEnabled = features.enableNativeFoldersFeature; - - if (!isNativeFoldersEnabled) { - return null; - } - - return ( - appState.createNewTag()} - /> - ); -}); - -const TagTitle: FunctionComponent<{ - features: FeaturesState; -}> = observer(({ features }) => { - const isNativeFoldersEnabled = features.enableNativeFoldersFeature; - const hasFolders = features.hasFolders; - const modal = usePremiumModal(); - - const showPremiumAlert = useCallback(() => { - modal.activate(TAG_FOLDERS_FEATURE_NAME); - }, [modal]); - - if (!isNativeFoldersEnabled) { - return ( - <> -
- Tags -
- - ); - } - - if (hasFolders) { - return ( - <> -
- Folders -
- - ); - } - - return ( - <> -
- Tags - - - -
- - ); -}); - export const TagsSection: FunctionComponent = observer( - ({ application, appState }) => { + ({ appState }) => { return ( - -
-
-
- - -
+
+
+
+ +
- -
- +
+ +
); } ); - -export const TagsSectionDirective = toDirective(TagsSection); diff --git a/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx new file mode 100644 index 000000000..9a748b7dc --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx @@ -0,0 +1,30 @@ +import { IconButton } from '@/components/IconButton'; +import { AppState } from '@/ui_models/app_state'; +import { FeaturesState } from '@/ui_models/app_state/features_state'; +import { TagsState } from '@/ui_models/app_state/tags_state'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; + +type Props = { + tags: TagsState; + features: FeaturesState; +}; + +export const TagsSectionAddButton: FunctionComponent = observer( + ({ tags, features }) => { + const isNativeFoldersEnabled = features.enableNativeFoldersFeature; + + if (!isNativeFoldersEnabled) { + return null; + } + + return ( + tags.createNewTemplate()} + /> + ); + } +); diff --git a/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx new file mode 100644 index 000000000..6298dbf1e --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx @@ -0,0 +1,62 @@ +import { usePremiumModal } from '@/components/Premium'; +import { + FeaturesState, + TAG_FOLDERS_FEATURE_NAME, + TAG_FOLDERS_FEATURE_TOOLTIP, +} from '@/ui_models/app_state/features_state'; +import { Tooltip } from '@reach/tooltip'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useCallback } from 'preact/hooks'; + +type Props = { + features: FeaturesState; +}; + +export const TagsSectionTitle: FunctionComponent = observer( + ({ features }) => { + const isNativeFoldersEnabled = features.enableNativeFoldersFeature; + const hasFolders = features.hasFolders; + const modal = usePremiumModal(); + + const showPremiumAlert = useCallback(() => { + modal.activate(TAG_FOLDERS_FEATURE_NAME); + }, [modal]); + + if (!isNativeFoldersEnabled) { + return ( + <> +
+ Tags +
+ + ); + } + + if (hasFolders) { + return ( + <> +
+ Folders +
+ + ); + } + + return ( + <> +
+ Tags + + + +
+ + ); + } +); diff --git a/app/assets/javascripts/components/Tags/dragndrop.ts b/app/assets/javascripts/components/Tags/dragndrop.ts new file mode 100644 index 000000000..5a5033ad5 --- /dev/null +++ b/app/assets/javascripts/components/Tags/dragndrop.ts @@ -0,0 +1,9 @@ +export enum ItemTypes { + TAG = 'TAG', +} + +export type DropItemTag = { uuid: string }; + +export type DropItem = DropItemTag; + +export type DropProps = { isOver: boolean; canDrop: boolean }; diff --git a/app/assets/javascripts/components/TagsList.tsx b/app/assets/javascripts/components/TagsList.tsx deleted file mode 100644 index 66925c2c1..000000000 --- a/app/assets/javascripts/components/TagsList.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { PremiumModalProvider } from '@/components/Premium'; -import { confirmDialog } from '@/services/alertService'; -import { STRING_DELETE_TAG } from '@/strings'; -import { WebApplication } from '@/ui_models/application'; -import { AppState } from '@/ui_models/app_state'; -import { isMobile } from '@/utils'; -import { SNTag, TagMutator } from '@standardnotes/snjs'; -import { runInAction } from 'mobx'; -import { observer } from 'mobx-react-lite'; -import { FunctionComponent } from 'preact'; -import { useCallback } from 'preact/hooks'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import { TouchBackend } from 'react-dnd-touch-backend'; -import { RootTagDropZone } from './RootTagDropZone'; -import { TagsListItem } from './TagsListItem'; -import { toDirective } from './utils'; - -type Props = { - application: WebApplication; - appState: AppState; -}; - -const tagsWithOptionalTemplate = ( - template: SNTag | undefined, - tags: SNTag[] -): SNTag[] => { - if (!template) { - return tags; - } - return [template, ...tags]; -}; - -export const TagsList: FunctionComponent = observer( - ({ application, appState }) => { - const templateTag = appState.templateTag; - const rootTags = appState.tags.rootTags; - - const allTags = tagsWithOptionalTemplate(templateTag, rootTags); - - const selectTag = useCallback( - (tag: SNTag) => { - appState.setSelectedTag(tag); - }, - [appState] - ); - - const saveTag = useCallback( - async (tag: SNTag, newTitle: string) => { - const templateTag = appState.templateTag; - - const hasEmptyTitle = newTitle.length === 0; - const hasNotChangedTitle = newTitle === tag.title; - const isTemplateChange = templateTag && tag.uuid === templateTag.uuid; - const hasDuplicatedTitle = !!application.findTagByTitle(newTitle); - - runInAction(() => { - appState.templateTag = undefined; - appState.editingTag = undefined; - }); - - if (hasEmptyTitle || hasNotChangedTitle) { - if (isTemplateChange) { - appState.undoCreateNewTag(); - } - return; - } - - if (hasDuplicatedTitle) { - if (isTemplateChange) { - appState.undoCreateNewTag(); - } - application.alertService?.alert( - 'A tag with this name already exists.' - ); - return; - } - - if (isTemplateChange) { - const insertedTag = await application.insertItem(templateTag); - const changedTag = await application.changeItem( - insertedTag.uuid, - (m) => { - m.title = newTitle; - } - ); - - selectTag(changedTag as SNTag); - await application.saveItem(insertedTag.uuid); - } else { - await application.changeAndSaveItem( - tag.uuid, - (mutator) => { - mutator.title = newTitle; - } - ); - } - }, - [appState, application, selectTag] - ); - - const removeTag = useCallback( - async (tag: SNTag) => { - if ( - await confirmDialog({ - text: STRING_DELETE_TAG, - confirmButtonStyle: 'danger', - }) - ) { - appState.removeTag(tag); - } - }, - [appState] - ); - - const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend; - - return ( - - - {allTags.length === 0 ? ( -
- No tags. Create one using the add button above. -
- ) : ( - <> - {allTags.map((tag) => { - return ( - - ); - })} - - - )} -
-
- ); - } -); - -export const TagsListDirective = toDirective(TagsList); diff --git a/app/assets/javascripts/strings.ts b/app/assets/javascripts/strings.ts index 1f6f647d8..75a099619 100644 --- a/app/assets/javascripts/strings.ts +++ b/app/assets/javascripts/strings.ts @@ -22,6 +22,8 @@ export const STRING_NEW_UPDATE_READY = export const STRING_DELETE_TAG = 'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.'; +export const STRING_MISSING_SYSTEM_TAG = 'We are missing a System Tag.'; + /** @editor */ export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN = 'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.'; diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 71d4440f0..124fa2e16 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -6,20 +6,26 @@ import { NoteViewController } from '@/views/note_view/note_view_controller'; import { isDesktopApplication } from '@/utils'; import { ApplicationEvent, + ComponentArea, ContentType, DeinitSource, + isPayloadSourceInternalChange, PayloadSource, PrefKey, + SNComponent, SNNote, + SNSmartTag, + ComponentViewer, SNTag, } from '@standardnotes/snjs'; import pull from 'lodash/pull'; import { action, computed, + IReactionDisposer, makeObservable, observable, - runInAction, + reaction, } from 'mobx'; import { ActionsMenuState } from './actions_menu_state'; import { FeaturesState } from './features_state'; @@ -72,11 +78,6 @@ export class AppState { onVisibilityChange: any; showBetaWarning: boolean; - selectedTag: SNTag | undefined; - previouslySelectedTag: SNTag | undefined; - editingTag: SNTag | undefined; - _templateTag: SNTag | undefined; - private multiEditorSupport = false; readonly quickSettingsMenu = new QuickSettingsState(); @@ -92,10 +93,16 @@ export class AppState { readonly features: FeaturesState; readonly tags: TagsState; readonly notesView: NotesViewState; + + public foldersComponentViewer?: ComponentViewer; + isSessionsModalVisible = false; private appEventObserverRemovers: (() => void)[] = []; + private readonly tagChangedDisposer: IReactionDisposer; + private readonly foldersComponentViewerDisposer: () => void; + /* @ngInject */ constructor( $rootScope: ng.IRootScopeService, @@ -160,30 +167,27 @@ export class AppState { this.showBetaWarning = false; } - this.selectedTag = undefined; - this.previouslySelectedTag = undefined; - this.editingTag = undefined; - this._templateTag = undefined; + this.foldersComponentViewer = undefined; makeObservable(this, { + selectedTag: computed, + showBetaWarning: observable, isSessionsModalVisible: observable, preferences: observable, - selectedTag: observable, - previouslySelectedTag: observable, - _templateTag: observable, - templateTag: computed, - createNewTag: action, - editingTag: observable, - setSelectedTag: action, - removeTag: action, - enableBetaWarning: action, disableBetaWarning: action, openSessionsModal: action, closeSessionsModal: action, + + foldersComponentViewer: observable.ref, + setFoldersComponent: action, }); + + this.tagChangedDisposer = this.tagChangedNotifier(); + this.foldersComponentViewerDisposer = + this.subscribeToFoldersComponentChanges(); } deinit(source: DeinitSource): void { @@ -206,6 +210,8 @@ export class AppState { } document.removeEventListener('visibilitychange', this.onVisibilityChange); this.onVisibilityChange = undefined; + this.tagChangedDisposer(); + this.foldersComponentViewerDisposer(); } openSessionsModal(): void { @@ -234,16 +240,16 @@ export class AppState { if (!this.multiEditorSupport) { this.closeActiveNoteController(); } - const activeTagUuid = this.selectedTag - ? this.selectedTag.isSmartTag - ? undefined - : this.selectedTag.uuid - : undefined; + + const selectedTag = this.selectedTag; + + const activeRegularTagUuid = + selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined; await this.application.noteControllerGroup.createNoteView( undefined, title, - activeTagUuid + activeRegularTagUuid ); } @@ -275,10 +281,88 @@ export class AppState { } } + private tagChangedNotifier(): IReactionDisposer { + return reaction( + () => this.tags.selectedUuid, + () => { + const tag = this.tags.selected; + const previousTag = this.tags.previouslySelected; + + if (!tag) { + return; + } + + if (this.application.isTemplateItem(tag)) { + return; + } + + this.notifyEvent(AppStateEvent.TagChanged, { + tag, + previousTag, + }); + } + ); + } + + async setFoldersComponent(component?: SNComponent) { + const foldersComponentViewer = this.foldersComponentViewer; + + if (foldersComponentViewer) { + this.application.componentManager.destroyComponentViewer( + foldersComponentViewer + ); + this.foldersComponentViewer = undefined; + } + + if (component) { + this.foldersComponentViewer = + this.application.componentManager.createComponentViewer( + component, + undefined, + this.tags.onFoldersComponentMessage.bind(this.tags) + ); + } + } + + private subscribeToFoldersComponentChanges() { + return this.application.streamItems( + [ContentType.Component], + async (items, source) => { + if ( + isPayloadSourceInternalChange(source) || + source === PayloadSource.InitialObserverRegistrationPush + ) { + return; + } + const components = items as SNComponent[]; + const hasFoldersChange = !!components.find( + (component) => component.area === ComponentArea.TagsList + ); + if (hasFoldersChange) { + const componentViewer = this.application.componentManager + .componentsForArea(ComponentArea.TagsList) + .find((component) => component.active); + + this.setFoldersComponent(componentViewer); + } + } + ); + } + + public get selectedTag(): SNTag | SNSmartTag | undefined { + return this.tags.selected; + } + + public set selectedTag(tag: SNTag | SNSmartTag | undefined) { + this.tags.selected = tag; + } + streamNotesAndTags() { this.application.streamItems( [ContentType.Note, ContentType.Tag], async (items, source) => { + const selectedTag = this.tags.selected; + /** Close any note controllers for deleted/trashed/archived notes */ if (source === PayloadSource.PreSyncSave) { const notes = items.filter( @@ -293,13 +377,13 @@ export class AppState { this.closeNoteController(noteController); } else if ( note.trashed && - !this.selectedTag?.isTrashTag && + !selectedTag?.isTrashTag && !this.searchOptions.includeTrashed ) { this.closeNoteController(noteController); } else if ( note.archived && - !this.selectedTag?.isArchiveTag && + !selectedTag?.isArchiveTag && !this.searchOptions.includeArchived && !this.application.getPreference(PrefKey.NotesShowArchived, false) ) { @@ -307,17 +391,6 @@ export class AppState { } } } - if (this.selectedTag) { - const matchingTag = items.find( - (candidate) => - this.selectedTag && candidate.uuid === this.selectedTag.uuid - ); - if (matchingTag) { - runInAction(() => { - this.selectedTag = matchingTag as SNTag; - }); - } - } } ); } @@ -385,74 +458,6 @@ export class AppState { }); } - setSelectedTag(tag: SNTag) { - if (tag.conflictOf) { - this.application.changeAndSaveItem(tag.uuid, (mutator) => { - mutator.conflictOf = undefined; - }); - } - - if (this.selectedTag === tag) { - return; - } - - this.previouslySelectedTag = this.selectedTag; - this.selectedTag = tag; - - if (this.templateTag?.uuid === tag.uuid) { - return; - } - - this.notifyEvent(AppStateEvent.TagChanged, { - tag: tag, - previousTag: this.previouslySelectedTag, - }); - } - - public getSelectedTag() { - return this.selectedTag; - } - - public get templateTag(): SNTag | undefined { - return this._templateTag; - } - - public set templateTag(tag: SNTag | undefined) { - const previous = this._templateTag; - this._templateTag = tag; - - if (tag) { - this.setSelectedTag(tag); - this.editingTag = tag; - } else if (previous) { - this.selectedTag = - previous === this.selectedTag ? undefined : this.selectedTag; - this.editingTag = - previous === this.editingTag ? undefined : this.editingTag; - } - } - - public removeTag(tag: SNTag) { - this.application.deleteItem(tag); - this.setSelectedTag(this.tags.smartTags[0]); - } - - public async createNewTag() { - if (this.templateTag) { - return; - } - - const newTag = (await this.application.createTemplateItem( - ContentType.Tag - )) as SNTag; - this.templateTag = newTag; - } - - public async undoCreateNewTag() { - const previousTag = this.previouslySelectedTag || this.tags.smartTags[0]; - this.setSelectedTag(previousTag); - } - /** Returns the tags that are referncing this note */ public getNoteTags(note: SNNote) { return this.application.referencingForItem(note).filter((ref) => { diff --git a/app/assets/javascripts/ui_models/app_state/features_state.ts b/app/assets/javascripts/ui_models/app_state/features_state.ts index ca022d8b4..323f2931f 100644 --- a/app/assets/javascripts/ui_models/app_state/features_state.ts +++ b/app/assets/javascripts/ui_models/app_state/features_state.ts @@ -3,13 +3,22 @@ import { FeatureIdentifier, FeatureStatus, } from '@standardnotes/snjs'; -import { computed, makeObservable, observable, runInAction } from 'mobx'; +import { + action, + computed, + makeObservable, + observable, + runInAction, + when, +} from 'mobx'; import { WebApplication } from '../application'; export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders'; export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to enable Tag folders.'; +export const SMART_TAGS_FEATURE_NAME = 'Smart Tags'; + /** * Holds state for premium/non premium features for the current user features, * and eventually for in-development features (feature flags). @@ -19,23 +28,37 @@ export class FeaturesState { window?._enable_unfinished_features; _hasFolders = false; + _hasSmartTags = false; + _premiumAlertFeatureName: string | undefined; + private unsub: () => void; constructor(private application: WebApplication) { this._hasFolders = this.hasNativeFolders(); + this._hasSmartTags = this.hasNativeSmartTags(); + this._premiumAlertFeatureName = undefined; makeObservable(this, { _hasFolders: observable, + _hasSmartTags: observable, hasFolders: computed, enableNativeFoldersFeature: computed, + enableNativeSmartTagsFeature: computed, + _premiumAlertFeatureName: observable, + showPremiumAlert: action, + closePremiumAlert: action, }); + this.showPremiumAlert = this.showPremiumAlert.bind(this); + this.closePremiumAlert = this.closePremiumAlert.bind(this); + this.unsub = this.application.addEventObserver(async (eventName) => { switch (eventName) { case ApplicationEvent.FeaturesUpdated: case ApplicationEvent.Launched: runInAction(() => { this._hasFolders = this.hasNativeFolders(); + this._hasSmartTags = this.hasNativeSmartTags(); }); break; default: @@ -52,25 +75,25 @@ export class FeaturesState { return this.enableUnfinishedFeatures; } + public get enableNativeSmartTagsFeature(): boolean { + return this.enableUnfinishedFeatures; + } + public get hasFolders(): boolean { return this._hasFolders; } - public set hasFolders(hasFolders: boolean) { - if (!hasFolders) { - this._hasFolders = false; - return; - } + public get hasSmartTags(): boolean { + return this._hasSmartTags; + } - if (!this.hasNativeFolders()) { - this.application.alertService?.alert( - `${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.` - ); - this._hasFolders = false; - return; - } + public async showPremiumAlert(featureName: string): Promise { + this._premiumAlertFeatureName = featureName; + return when(() => this._premiumAlertFeatureName === undefined); + } - this._hasFolders = hasFolders; + public async closePremiumAlert(): Promise { + this._premiumAlertFeatureName = undefined; } private hasNativeFolders(): boolean { @@ -84,4 +107,16 @@ export class FeaturesState { return status === FeatureStatus.Entitled; } + + private hasNativeSmartTags(): boolean { + if (!this.enableNativeSmartTagsFeature) { + return false; + } + + const status = this.application.getFeatureStatus( + FeatureIdentifier.SmartFilters + ); + + return status === FeatureStatus.Entitled; + } } 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 44dda28e1..8bfbae2c2 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts @@ -115,12 +115,12 @@ export class NotesState { async selectNote(uuid: UuidString, userTriggered?: boolean): Promise { const note = this.application.findItem(uuid) as SNNote; + const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta); + const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl); + const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift); + if (note) { - if ( - userTriggered && - (this.io.activeModifiers.has(KeyboardModifier.Meta) || - this.io.activeModifiers.has(KeyboardModifier.Ctrl)) - ) { + if (userTriggered && (hasMeta || hasCtrl)) { if (this.selectedNotes[uuid]) { delete this.selectedNotes[uuid]; } else if (await this.application.authorizeNoteAccess(note)) { @@ -129,10 +129,7 @@ export class NotesState { this.lastSelectedNote = note; }); } - } else if ( - userTriggered && - this.io.activeModifiers.has(KeyboardModifier.Shift) - ) { + } else if (userTriggered && hasShift) { await this.selectNotesRange(note); } else { const shouldSelectNote = diff --git a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts index e0be2a0e0..a7ad404bb 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts @@ -495,7 +495,9 @@ export class NotesViewState { this.reloadNotesDisplayOptions(); this.reloadNotes(); - if (this.notes.length > 0) { + const hasSomeNotes = this.notes.length > 0; + + if (hasSomeNotes) { this.selectFirstNote(); } else if (dbLoaded) { if ( diff --git a/app/assets/javascripts/ui_models/app_state/tags_state.ts b/app/assets/javascripts/ui_models/app_state/tags_state.ts index 13e698d89..8a19847e6 100644 --- a/app/assets/javascripts/ui_models/app_state/tags_state.ts +++ b/app/assets/javascripts/ui_models/app_state/tags_state.ts @@ -1,8 +1,13 @@ +import { confirmDialog } from '@/services/alertService'; +import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings'; import { + ComponentAction, ContentType, + MessageData, SNSmartTag, SNTag, - UuidString, + TagMutator, + UuidString } from '@standardnotes/snjs'; import { action, @@ -10,14 +15,21 @@ import { makeAutoObservable, makeObservable, observable, - runInAction, + runInAction } from 'mobx'; import { WebApplication } from '../application'; -import { FeaturesState } from './features_state'; +import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state'; + +type AnyTag = SNTag | SNSmartTag; export class TagsState { tags: SNTag[] = []; smartTags: SNSmartTag[] = []; + allNotesCount_ = 0; + selected_: AnyTag | undefined; + previouslySelected_: AnyTag | undefined; + editing_: SNTag | undefined; + private readonly tagsCountsState: TagsCountsState; constructor( @@ -27,22 +39,40 @@ export class TagsState { ) { this.tagsCountsState = new TagsCountsState(this.application); + this.selected_ = undefined; + this.previouslySelected_ = undefined; + this.editing_ = undefined; + makeObservable(this, { tags: observable.ref, smartTags: observable.ref, - hasFolders: computed, hasAtLeastOneFolder: computed, + allNotesCount_: observable, + allNotesCount: computed, + + selected_: observable.ref, + previouslySelected_: observable.ref, + previouslySelected: computed, + editing_: observable.ref, + selected: computed, + selectedUuid: computed, + editingTag: computed, assignParent: action, rootTags: computed, tagsCount: computed, + + createNewTemplate: action, + undoCreateNewTag: action, + save: action, + remove: action, }); appEventListeners.push( this.application.streamItems( [ContentType.Tag, ContentType.SmartTag], - () => { + (items) => { runInAction(() => { this.tags = this.application.getDisplayableItems( ContentType.Tag @@ -50,18 +80,42 @@ export class TagsState { this.smartTags = this.application.getSmartTags(); this.tagsCountsState.update(this.tags); + this.allNotesCount_ = this.countAllNotes(); + + const selectedTag = this.selected_; + if (selectedTag) { + const matchingTag = items.find( + (candidate) => candidate.uuid === selectedTag.uuid + ); + if (matchingTag) { + if (matchingTag.deleted) { + this.selected_ = this.smartTags[0]; + } else { + this.selected_ = matchingTag as AnyTag; + } + } + } else { + this.selected_ = this.smartTags[0]; + } }); } ) ); } + public get allLocalRootTags(): SNTag[] { + if (this.editing_ && this.application.isTemplateItem(this.editing_)) { + return [this.editing_, ...this.rootTags]; + } + return this.rootTags; + } + public getNotesCount(tag: SNTag): number { return this.tagsCountsState.counts[tag.uuid] || 0; } getChildren(tag: SNTag): SNTag[] { - if (!this.hasFolders) { + if (!this.features.hasFolders) { return []; } @@ -69,7 +123,10 @@ export class TagsState { return []; } - const children = this.application.getTagChildren(tag); + const children = this.application + .getTagChildren(tag) + .filter((tag) => !tag.isSmartTag); + const childrenUuids = children.map((childTag) => childTag.uuid); const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid) @@ -100,7 +157,7 @@ export class TagsState { } get rootTags(): SNTag[] { - if (!this.hasFolders) { + if (!this.features.hasFolders) { return this.tags; } @@ -111,12 +168,192 @@ export class TagsState { return this.tags.length; } - public get hasFolders(): boolean { - return this.features.hasFolders; + public get allNotesCount(): number { + return this.allNotesCount_; } - public set hasFolders(hasFolders: boolean) { - this.features.hasFolders = hasFolders; + public get previouslySelected(): AnyTag | undefined { + return this.previouslySelected_; + } + + public get selected(): AnyTag | undefined { + return this.selected_; + } + + public set selected(tag: AnyTag | undefined) { + if (tag && tag.conflictOf) { + this.application.changeAndSaveItem(tag.uuid, (mutator) => { + mutator.conflictOf = undefined; + }); + } + + const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid; + + if (selectionHasNotChanged) { + return; + } + + this.previouslySelected_ = this.selected_; + this.selected_ = tag; + } + + public get selectedUuid(): UuidString | undefined { + return this.selected_?.uuid; + } + + public get editingTag(): SNTag | undefined { + return this.editing_; + } + + public set editingTag(editingTag: SNTag | undefined) { + this.editing_ = editingTag; + this.selected = editingTag; + } + + public async createNewTemplate() { + const isAlreadyEditingATemplate = + this.editing_ && this.application.isTemplateItem(this.editing_); + + if (isAlreadyEditingATemplate) { + return; + } + + const newTag = (await this.application.createTemplateItem( + ContentType.Tag + )) as SNTag; + + runInAction(() => { + this.editing_ = newTag; + }); + } + + public undoCreateNewTag() { + this.editing_ = undefined; + const previousTag = this.previouslySelected_ || this.smartTags[0]; + this.selected = previousTag; + } + + public async remove(tag: SNTag) { + if ( + await confirmDialog({ + text: STRING_DELETE_TAG, + confirmButtonStyle: 'danger', + }) + ) { + this.application.deleteItem(tag); + this.selected = this.smartTags[0]; + } + } + + public async save(tag: SNTag, newTitle: string) { + const hasEmptyTitle = newTitle.length === 0; + const hasNotChangedTitle = newTitle === tag.title; + const isTemplateChange = this.application.isTemplateItem(tag); + const hasDuplicatedTitle = !!this.application.findTagByTitle(newTitle); + + runInAction(() => { + this.editing_ = undefined; + }); + + if (hasEmptyTitle || hasNotChangedTitle) { + if (isTemplateChange) { + this.undoCreateNewTag(); + } + return; + } + + if (hasDuplicatedTitle) { + if (isTemplateChange) { + this.undoCreateNewTag(); + } + this.application.alertService?.alert( + 'A tag with this name already exists.' + ); + return; + } + + if (isTemplateChange) { + if (this.features.enableNativeSmartTagsFeature) { + const isSmartTagTitle = this.application.isSmartTagTitle(newTitle); + + if (isSmartTagTitle) { + if (!this.features.hasSmartTags) { + await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME); + return; + } + } + + const insertedTag = await this.application.createTagOrSmartTag( + newTitle + ); + runInAction(() => { + this.selected = insertedTag as SNTag; + }); + } else { + // Legacy code, remove me after we enableNativeSmartTagsFeature for everyone. + // See https://app.asana.com/0/0/1201612665552831/f + const insertedTag = await this.application.insertItem(tag); + const changedTag = await this.application.changeItem( + insertedTag.uuid, + (m) => { + m.title = newTitle; + } + ); + this.selected = changedTag as SNTag; + await this.application.saveItem(insertedTag.uuid); + } + } else { + await this.application.changeAndSaveItem( + tag.uuid, + (mutator) => { + mutator.title = newTitle; + } + ); + } + } + + private countAllNotes(): number { + const allTag = this.application.getSmartTags().find((tag) => tag.isAllTag); + + if (!allTag) { + console.error(STRING_MISSING_SYSTEM_TAG); + return -1; + } + + const notes = this.application + .notesMatchingSmartTag(allTag) + .filter((note) => { + return !note.archived && !note.trashed; + }); + + return notes.length; + } + + public onFoldersComponentMessage( + action: ComponentAction, + data: MessageData + ): void { + if (action === ComponentAction.SelectItem) { + const item = data.item; + + if (!item) { + return; + } + + if ( + item.content_type === ContentType.Tag || + item.content_type === ContentType.SmartTag + ) { + const matchingTag = this.application.findItem(item.uuid); + + if (matchingTag) { + this.selected = matchingTag as AnyTag; + return; + } + } + } else if (action === ComponentAction.ClearSelection) { + this.selected = this.smartTags[0]; + } } public get hasAtLeastOneFolder(): boolean { diff --git a/app/assets/javascripts/ui_models/panel_resizer.ts b/app/assets/javascripts/ui_models/panel_resizer.ts index 66ce0f9e9..c53b49c9d 100644 --- a/app/assets/javascripts/ui_models/panel_resizer.ts +++ b/app/assets/javascripts/ui_models/panel_resizer.ts @@ -56,6 +56,12 @@ export class PanelResizerState { side, widthEventCallback, }: PanelResizerProps) { + const currentKnownPref = + (application.getPreference(prefKey) as number) ?? defaultWidth ?? 0; + + this.panel = panel; + this.startLeft = this.panel.offsetLeft; + this.startWidth = this.panel.scrollWidth; this.alwaysVisible = alwaysVisible ?? false; this.application = application; this.collapsable = collapsable ?? false; @@ -66,16 +72,15 @@ export class PanelResizerState { this.lastDownX = 0; this.lastLeft = this.startLeft; this.lastWidth = this.startWidth; - this.panel = panel; this.prefKey = prefKey; this.pressed = false; this.side = side; - this.startLeft = this.panel.offsetLeft; - this.startWidth = this.panel.scrollWidth; this.widthBeforeLastDblClick = 0; this.widthEventCallback = widthEventCallback; this.resizeFinishCallback = resizeFinishCallback; + this.setWidth(currentKnownPref, true); + application.addEventObserver(async () => { const changedWidth = application.getPreference(prefKey) as number; if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true); diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index 01630f2b5..58466fcf4 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -5,7 +5,7 @@ ng-class='self.state.appClass', ng-if='!self.state.needsUnlock && self.state.launched' ) - tags-view(application='self.application') + navigation(application='self.application', appState='self.appState') notes-view( application='self.application' app-state='self.appState' diff --git a/app/assets/javascripts/views/application/application_view.ts b/app/assets/javascripts/views/application/application_view.ts index cc861404b..c2c41eede 100644 --- a/app/assets/javascripts/views/application/application_view.ts +++ b/app/assets/javascripts/views/application/application_view.ts @@ -8,7 +8,7 @@ import { Challenge, removeFromArray, } from '@standardnotes/snjs'; -import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants'; +import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants'; import { STRING_DEFAULT_FILE_ERROR } from '@/strings'; import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; import { alertDialog } from '@/services/alertService'; @@ -24,7 +24,7 @@ class ApplicationViewCtrl extends PureViewCtrl< > { public platformString: string; private notesCollapsed = false; - private tagsCollapsed = false; + private navigationCollapsed = false; /** * To prevent stale state reads (setState is async), @@ -136,15 +136,15 @@ class ApplicationViewCtrl extends PureViewCtrl< if (panel === PANEL_NAME_NOTES) { this.notesCollapsed = collapsed; } - if (panel === PANEL_NAME_TAGS) { - this.tagsCollapsed = collapsed; + if (panel === PANEL_NAME_NAVIGATION) { + this.navigationCollapsed = collapsed; } let appClass = ''; if (this.notesCollapsed) { appClass += 'collapsed-notes'; } - if (this.tagsCollapsed) { - appClass += ' collapsed-tags'; + if (this.navigationCollapsed) { + appClass += ' collapsed-navigation'; } this.setState({ appClass }); } else if (eventName === AppStateEvent.WindowDidFocus) { diff --git a/app/assets/javascripts/views/constants.ts b/app/assets/javascripts/views/constants.ts index 57c83d9af..a84c79f91 100644 --- a/app/assets/javascripts/views/constants.ts +++ b/app/assets/javascripts/views/constants.ts @@ -1,4 +1,4 @@ export const PANEL_NAME_NOTES = 'notes'; -export const PANEL_NAME_TAGS = 'tags'; +export const PANEL_NAME_NAVIGATION = 'navigation'; export const EMAIL_REGEX = /^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/; diff --git a/app/assets/javascripts/views/index.ts b/app/assets/javascripts/views/index.ts index 82223ae4c..81f8920df 100644 --- a/app/assets/javascripts/views/index.ts +++ b/app/assets/javascripts/views/index.ts @@ -4,5 +4,4 @@ export { ApplicationView } from './application/application_view'; export { NoteGroupViewDirective } from './note_group_view/note_group_view'; export { NoteViewDirective } from './note_view/note_view'; export { FooterView } from './footer/footer_view'; -export { TagsView } from './tags/tags_view'; export { ChallengeModal } from './challenge_modal/challenge_modal'; diff --git a/app/assets/javascripts/views/tags/tags-view.pug b/app/assets/javascripts/views/tags/tags-view.pug deleted file mode 100644 index 4256b0181..000000000 --- a/app/assets/javascripts/views/tags/tags-view.pug +++ /dev/null @@ -1,43 +0,0 @@ -#tags-column.sn-component.section.tags(aria-label='Tags') - .component-view-container(ng-if='self.state.componentViewer') - component-view.component-view( - component-viewer='self.state.componentViewer', - application='self.application' - app-state='self.appState' - ) - #tags-content.content(ng-if='!(self.state.componentViewer)') - .tags-title-section.section-title-bar - .section-title-bar-header - .sk-h3.title - span.sk-bold Views - .sk-button.sk-secondary-contrast.wide( - ng-click='self.clickedAddNewTag()', - title='Create a new tag' - ) - .sk-label - i.icon.ion-plus.add-button - .scrollable - .infinite-scroll - .tag( - ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}", - ng-click='self.selectTag(tag)', - ng-repeat='tag in self.state.smartTags track by tag.uuid' - ) - .tag-info - .title(ng-if="!tag.errorDecrypting") {{tag.title}} - .count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}} - .danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy - .danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys - .info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys - tags-section( - application='self.application', - app-state='self.appState' - ) - panel-resizer( - collapsable='true', - control='self.panelPuppet', - default-width='150', - hoverable='true', - on-resize-finish='self.onPanelResize', - panel-id="'tags-column'" - ) diff --git a/app/assets/javascripts/views/tags/tags_view.ts b/app/assets/javascripts/views/tags/tags_view.ts deleted file mode 100644 index 69b94db50..000000000 --- a/app/assets/javascripts/views/tags/tags_view.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { PanelPuppet, WebDirective } from '@/types'; -import { WebApplication } from '@/ui_models/application'; -import { AppStateEvent } from '@/ui_models/app_state'; -import { PANEL_NAME_TAGS } from '@/views/constants'; -import { - ApplicationEvent, - ComponentAction, - ComponentArea, - ComponentViewer, - ContentType, - isPayloadSourceInternalChange, - MessageData, - PayloadSource, - PrefKey, - SNComponent, - SNSmartTag, - SNTag, - UuidString, -} from '@standardnotes/snjs'; -import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; -import template from './tags-view.pug'; - -type NoteCounts = Partial>; - -type TagState = { - smartTags: SNSmartTag[]; - noteCounts: NoteCounts; - selectedTag?: SNTag; - componentViewer?: ComponentViewer; -}; - -class TagsViewCtrl extends PureViewCtrl { - /** Passed through template */ - readonly application!: WebApplication; - private readonly panelPuppet: PanelPuppet; - private unregisterComponent?: () => void; - /** The original name of the edtingTag before it began editing */ - formData: { tagTitle?: string } = {}; - titles: Partial> = {}; - private removeTagsObserver!: () => void; - private removeFoldersObserver!: () => void; - - /* @ngInject */ - constructor($timeout: ng.ITimeoutService) { - super($timeout); - this.panelPuppet = { - onReady: () => this.loadPreferences(), - }; - } - - deinit() { - this.removeTagsObserver?.(); - (this.removeTagsObserver as unknown) = undefined; - (this.removeFoldersObserver as unknown) = undefined; - this.unregisterComponent?.(); - this.unregisterComponent = undefined; - super.deinit(); - } - - getInitialState(): TagState { - return { - smartTags: [], - noteCounts: {}, - }; - } - - getState(): TagState { - return this.state; - } - - async onAppLaunch() { - super.onAppLaunch(); - this.loadPreferences(); - this.streamForFoldersComponent(); - - const smartTags = this.application.getSmartTags(); - this.setState({ smartTags }); - this.selectTag(smartTags[0]); - } - - /** @override */ - onAppIncrementalSync() { - super.onAppIncrementalSync(); - this.reloadNoteCounts(); - } - - async setFoldersComponent(component?: SNComponent) { - if (this.state.componentViewer) { - this.application.componentManager.destroyComponentViewer( - this.state.componentViewer - ); - await this.setState({ componentViewer: undefined }); - } - if (component) { - await this.setState({ - componentViewer: - this.application.componentManager.createComponentViewer( - component, - undefined, - this.handleFoldersComponentMessage.bind(this) - ), - }); - } - } - - handleFoldersComponentMessage( - action: ComponentAction, - data: MessageData - ): void { - if (action === ComponentAction.SelectItem) { - const item = data.item; - if (!item) { - return; - } - - if (item.content_type === ContentType.Tag) { - const matchingTag = this.application.findItem(item.uuid); - - if (matchingTag) { - this.selectTag(matchingTag as SNTag); - } - } else if (item.content_type === ContentType.SmartTag) { - const matchingTag = this.getState().smartTags.find( - (t) => t.uuid === item.uuid - ); - - if (matchingTag) { - this.selectTag(matchingTag); - } - } - } else if (action === ComponentAction.ClearSelection) { - this.selectTag(this.getState().smartTags[0]); - } - } - - streamForFoldersComponent() { - this.removeFoldersObserver = this.application.streamItems( - [ContentType.Component], - async (items, source) => { - if ( - isPayloadSourceInternalChange(source) || - source === PayloadSource.InitialObserverRegistrationPush - ) { - return; - } - const components = items as SNComponent[]; - const hasFoldersChange = !!components.find( - (component) => component.area === ComponentArea.TagsList - ); - if (hasFoldersChange) { - this.setFoldersComponent( - this.application.componentManager - .componentsForArea(ComponentArea.TagsList) - .find((component) => component.active) - ); - } - } - ); - - this.removeTagsObserver = this.application.streamItems( - [ContentType.Tag, ContentType.SmartTag], - async (items) => { - const tags = items as Array; - - await this.setState({ - smartTags: this.application.getSmartTags(), - }); - - for (const tag of tags) { - this.titles[tag.uuid] = tag.title; - } - - this.reloadNoteCounts(); - const selectedTag = this.state.selectedTag; - - if (selectedTag) { - /** If the selected tag has been deleted, revert to All view. */ - const matchingTag = tags.find((tag) => { - return tag.uuid === selectedTag.uuid; - }); - - if (matchingTag) { - if (matchingTag.deleted) { - this.selectTag(this.getState().smartTags[0]); - } else { - this.setState({ - selectedTag: matchingTag, - }); - } - } - } - } - ); - } - - /** @override */ - onAppStateEvent(eventName: AppStateEvent) { - if (eventName === AppStateEvent.TagChanged) { - this.setState({ - selectedTag: this.application.getAppState().getSelectedTag(), - }); - } - } - - /** @override */ - async onAppEvent(eventName: ApplicationEvent) { - super.onAppEvent(eventName); - switch (eventName) { - case ApplicationEvent.LocalDataIncrementalLoad: - this.reloadNoteCounts(); - break; - case ApplicationEvent.PreferencesChanged: - this.loadPreferences(); - break; - } - } - - reloadNoteCounts() { - const smartTags = this.state.smartTags; - const noteCounts: NoteCounts = {}; - - for (const tag of smartTags) { - /** Other smart tags do not contain counts */ - if (tag.isAllTag) { - const notes = this.application - .notesMatchingSmartTag(tag as SNSmartTag) - .filter((note) => { - return !note.archived && !note.trashed; - }); - noteCounts[tag.uuid] = notes.length; - } - } - - this.setState({ - noteCounts: noteCounts, - }); - } - - loadPreferences() { - if (!this.panelPuppet.ready) { - return; - } - - const width = this.application.getPreference(PrefKey.TagsPanelWidth); - if (width) { - this.panelPuppet.setWidth!(width); - if (this.panelPuppet.isCollapsed!()) { - this.application - .getAppState() - .panelDidResize(PANEL_NAME_TAGS, this.panelPuppet.isCollapsed!()); - } - } - } - - onPanelResize = ( - newWidth: number, - _lastLeft: number, - _isAtMaxWidth: boolean, - isCollapsed: boolean - ) => { - this.application - .setPreference(PrefKey.TagsPanelWidth, newWidth) - .then(() => this.application.sync()); - this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed); - }; - - async selectTag(tag: SNTag) { - if (tag.conflictOf) { - this.application.changeAndSaveItem(tag.uuid, (mutator) => { - mutator.conflictOf = undefined; - }); - } - this.application.getAppState().setSelectedTag(tag); - } - - async clickedAddNewTag() { - if (this.appState.templateTag) { - return; - } - - this.appState.createNewTag(); - } -} - -export class TagsView extends WebDirective { - constructor() { - super(); - this.restrict = 'E'; - this.scope = { - application: '=', - }; - this.template = template; - this.replace = true; - this.controller = TagsViewCtrl; - this.controllerAs = 'self'; - this.bindToController = true; - } -} diff --git a/app/assets/stylesheets/_focused.scss b/app/assets/stylesheets/_focused.scss index d16d4866a..d6e27c824 100644 --- a/app/assets/stylesheets/_focused.scss +++ b/app/assets/stylesheets/_focused.scss @@ -35,7 +35,7 @@ opacity: 1; } - .section.tags, + navigation, notes-view { will-change: opacity; animation: fade-out 1.25s forwards; @@ -45,7 +45,7 @@ flex: none !important; } - .section.tags:hover { + navigation:hover { flex: initial; width: 0px !important; } @@ -57,7 +57,7 @@ } .disable-focus-mode { - .section.tags, + navigation, notes-view { transition: width 1.25s; will-change: opacity; diff --git a/app/assets/stylesheets/_tags.scss b/app/assets/stylesheets/_tags.scss index e44610a44..830a2d818 100644 --- a/app/assets/stylesheets/_tags.scss +++ b/app/assets/stylesheets/_tags.scss @@ -1,3 +1,7 @@ +#tags-column { + width: 100%; +} + .tags { width: 180px; flex-grow: 0; From 9ecfad92bbcda4b9266117482ad812326365b6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 5 Jan 2022 16:12:36 +0100 Subject: [PATCH 03/18] Revert "Revert "feat: allows duplicate names in tags folder & smart tags (#792)"" This reverts commit 122f8fc2669aa14d0c196d87f02834d400b05f95. --- .../components/AutocompleteTagResult.tsx | 8 ++- app/assets/javascripts/components/NoteTag.tsx | 12 +++- .../ui_models/app_state/note_tags_state.ts | 39 ++++++++++- .../ui_models/app_state/tags_state.ts | 64 ++++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/components/AutocompleteTagResult.tsx b/app/assets/javascripts/components/AutocompleteTagResult.tsx index 84bb150ac..d732541af 100644 --- a/app/assets/javascripts/components/AutocompleteTagResult.tsx +++ b/app/assets/javascripts/components/AutocompleteTagResult.tsx @@ -21,6 +21,9 @@ export const AutocompleteTagResult = observer( const tagResultRef = useRef(null); + const title = tagResult.title; + const prefixTitle = appState.noteTags.getPrefixTitle(tagResult); + const onTagOptionClick = async (tag: SNTag) => { await appState.noteTags.addTagToActiveNote(tag); appState.noteTags.clearAutocompleteSearch(); @@ -86,9 +89,10 @@ export const AutocompleteTagResult = observer( > + {prefixTitle && {prefixTitle}} {autocompleteSearchQuery === '' - ? tagResult.title - : tagResult.title + ? title + : title .split(new RegExp(`(${autocompleteSearchQuery})`, 'gi')) .map((substring, index) => ( { - const { autocompleteInputFocused, focusedTagUuid, tags } = appState.noteTags; + const noteTags = appState.noteTags; + + const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags; const [showDeleteButton, setShowDeleteButton] = useState(false); const [tagClicked, setTagClicked] = useState(false); @@ -18,6 +20,10 @@ export const NoteTag = observer(({ appState, tag }: Props) => { const tagRef = useRef(null); + const title = tag.title; + const prefixTitle = noteTags.getPrefixTitle(tag); + const longTitle = noteTags.getLongTitle(tag); + const deleteTag = () => { appState.noteTags.focusPreviousTag(tag); appState.noteTags.removeTagFromActiveNote(tag); @@ -97,10 +103,12 @@ export const NoteTag = observer(({ appState, tag }: Props) => { onFocus={onFocus} onBlur={onBlur} tabIndex={getTabIndex()} + title={longTitle} > - {tag.title} + {prefixTitle && {prefixTitle}} + {title} {showDeleteButton && (
+ )} + + {backupFrequency && ( +
+
+ )} +
+ ); +}; diff --git a/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx new file mode 100644 index 000000000..19dc52ffe --- /dev/null +++ b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { CloudBackupProvider } from './CloudBackupProvider'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import { WebApplication } from '@/ui_models/application'; +import { + PreferencesGroup, + PreferencesSegment, Subtitle, + Text, + Title +} from '@/preferences/components'; +import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; +import { FeatureIdentifier } from '@standardnotes/features'; +import { FeatureStatus } from '@standardnotes/snjs'; +import { FunctionComponent } from 'preact'; +import { CloudProvider, EmailBackupFrequency, SettingName } from '@standardnotes/settings'; +import { Switch } from '@/components/Switch'; +import { convertStringifiedBooleanToBoolean } from '@/utils'; +import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings'; + +const providerData = [{ + name: CloudProvider.Dropbox +}, { + name: CloudProvider.Google +}, { + name: CloudProvider.OneDrive +} +]; + +type Props = { + application: WebApplication; +}; + +export const CloudLink: FunctionComponent = ({ application }) => { + const [isEntitledForCloudBackups, setIsEntitledForCloudBackups] = useState(false); + const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => { + setIsLoading(true); + + try { + const userSettings = await application.listSettings(); + setIsFailedCloudBackupEmailMuted( + convertStringifiedBooleanToBoolean( + userSettings[SettingName.MuteFailedCloudBackupsEmails] as string + ) + ); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }, [application]); + + useEffect(() => { + const cloudBackupsFeatureStatus = application.getFeatureStatus( + FeatureIdentifier.CloudLink + ); + setIsEntitledForCloudBackups( + cloudBackupsFeatureStatus === FeatureStatus.Entitled + ); + loadIsFailedCloudBackupEmailMutedSetting(); + }, [application, loadIsFailedCloudBackupEmailMutedSetting]); + + const updateSetting = async ( + settingName: SettingName, + payload: string + ): Promise => { + try { + await application.updateSetting(settingName, payload); + return true; + } catch (e) { + application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING); + return false; + } + }; + + const toggleMuteFailedCloudBackupEmails = async () => { + const previousValue = isFailedCloudBackupEmailMuted; + setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted); + + const updateResult = await updateSetting( + SettingName.MuteFailedCloudBackupsEmails, + `${!isFailedCloudBackupEmailMuted}` + ); + if (!updateResult) { + setIsFailedCloudBackupEmailMuted(previousValue); + } + }; + + return ( + + + Cloud Backups + {!isEntitledForCloudBackups && ( + <> + + A Plus or{' '} + Pro subscription plan is + required to enable Cloud Backups.{' '} + + Learn more + + . + + + + )} +
+ + Configure the integrations below to enable automatic daily backups + of your encrypted data set to your third-party cloud provider. + +
+ +
+ {providerData.map(({ name }) => ( + <> + + + + ))} +
+
+ + Email preferences +
+
+ Receive a notification email if a cloud backup fails. +
+ {isLoading ? ( +
+ ) : ( + + )} +
+
+ + + ); +}; diff --git a/app/assets/javascripts/preferences/panes/backups-segments/index.ts b/app/assets/javascripts/preferences/panes/backups-segments/index.ts new file mode 100644 index 000000000..a890d9b94 --- /dev/null +++ b/app/assets/javascripts/preferences/panes/backups-segments/index.ts @@ -0,0 +1,3 @@ +export * from './DataBackups'; +export * from './EmailBackups'; +export * from './cloud-backups'; diff --git a/app/assets/javascripts/preferences/panes/security-segments/index.ts b/app/assets/javascripts/preferences/panes/security-segments/index.ts index 75a109522..38694cc50 100644 --- a/app/assets/javascripts/preferences/panes/security-segments/index.ts +++ b/app/assets/javascripts/preferences/panes/security-segments/index.ts @@ -1,4 +1,3 @@ export * from './Encryption'; export * from './PasscodeLock'; export * from './Protections'; -export * from './DataBackups'; diff --git a/app/assets/javascripts/strings.ts b/app/assets/javascripts/strings.ts index 6fd220126..5b4e19bda 100644 --- a/app/assets/javascripts/strings.ts +++ b/app/assets/javascripts/strings.ts @@ -111,6 +111,9 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade'; export const STRING_REMOVE_OFFLINE_KEY_CONFIRMATION = 'This will delete the previously saved offline key.'; +export const STRING_FAILED_TO_UPDATE_USER_SETTING = + 'There was an error while trying to update your settings. Please try again.'; + export const Strings = { protectingNoteWithoutProtectionSources: 'Access to this note will not be restricted until you set up a passcode or account.', diff --git a/app/assets/javascripts/utils/index.ts b/app/assets/javascripts/utils/index.ts index 4325f8370..b8588eb28 100644 --- a/app/assets/javascripts/utils/index.ts +++ b/app/assets/javascripts/utils/index.ts @@ -155,3 +155,12 @@ export function getDesktopVersion() { export const isEmailValid = (email: string): boolean => { return EMAIL_REGEX.test(email); }; + +export const openInNewTab = (url: string) => { + const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); + if (newWindow) newWindow.opener = null; +}; + +export const convertStringifiedBooleanToBoolean = (value: string) => { + return value !== 'false'; +}; diff --git a/app/assets/stylesheets/_main.scss b/app/assets/stylesheets/_main.scss index f52814ffd..523409136 100644 --- a/app/assets/stylesheets/_main.scss +++ b/app/assets/stylesheets/_main.scss @@ -245,3 +245,21 @@ $footer-height: 2rem; .z-index-purchase-flow { z-index: $z-index-purchase-flow; } + +textarea { + &.non-interactive { + user-select: text !important; + resize: none; + background-color: transparent; + border-color: var(--sn-stylekit-border-color); + font-family: monospace; + outline: 0; + + -webkit-user-select: none; + -webkit-touch-callout: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 48198c039..3cbcf3f1a 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -813,3 +813,9 @@ } } } + +.dimmed { + opacity: .5; + cursor: default; + pointer-events: none; +} diff --git a/app/assets/stylesheets/_ui.scss b/app/assets/stylesheets/_ui.scss index 563e9d77c..c2e8fab59 100644 --- a/app/assets/stylesheets/_ui.scss +++ b/app/assets/stylesheets/_ui.scss @@ -206,6 +206,10 @@ $screen-md-max: ($screen-lg-min - 1) !default; cursor: default; } +.pointer-events-none { + pointer-events: none; +} + .fill-current { fill: currentColor; } diff --git a/package.json b/package.json index 9d695953a..b016c0516 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,9 @@ "@reach/listbox": "^0.16.2", "@reach/tooltip": "^0.16.2", "@standardnotes/features": "1.20.6", + "@standardnotes/settings": "^1.9.0", "@standardnotes/sncrypto-web": "1.5.3", - "@standardnotes/snjs": "2.35.0", + "@standardnotes/snjs": "2.35.3", "mobx": "^6.3.5", "mobx-react-lite": "^3.2.2", "preact": "^10.5.15", diff --git a/yarn.lock b/yarn.lock index fcb330e53..ce89058d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2628,7 +2628,7 @@ dependencies: "@standardnotes/auth" "^3.8.1" -"@standardnotes/features@1.20.6", "@standardnotes/features@^1.20.6": +"@standardnotes/features@1.20.6": version "1.20.6" resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.6.tgz#94d397892dd12f76a10c89c70092933627a9c457" integrity sha512-/w8+/8J8UNJ+DAsOud8XbWkeUBN6eb+5+Ic4NgkXYkx/wv6sTDk9XVc+mOhxOkYlb2iy85JDmoLAwu+GW/3Gtg== @@ -2636,6 +2636,14 @@ "@standardnotes/auth" "3.8.3" "@standardnotes/common" "1.2.1" +"@standardnotes/features@^1.20.7": + version "1.20.7" + resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.7.tgz#d666569492e942eaecc05e40a79d50d33df4fbe9" + integrity sha512-eaZu/+PvHYXWaq6r3ET87t52lZqFknZVUEjspAL34Fdr+5cDma5ZRoylx6hPCVDO9VpHd6fjGWlS+5kZ+qJ+bA== + dependencies: + "@standardnotes/auth" "3.8.3" + "@standardnotes/common" "1.2.1" + "@standardnotes/settings@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.9.0.tgz#0f01da5f6782363e4d77ee584b40f8614c555626" @@ -2655,15 +2663,15 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.35.0": - version "2.35.0" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.35.0.tgz#c24d13d7be68767b865b9f3e728d21b473bbe006" - integrity sha512-dbjFMlEShsgo8sjaiwEFtbvm6vZHIgylZEtzqcJxl0TzEEIO1q4baomzUFj0ayzmM7g7S38o7zBfj+2AoXxPeA== +"@standardnotes/snjs@2.35.3": + version "2.35.3" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.35.3.tgz#e8604329930317000fbec239534a3020d7e7aefb" + integrity sha512-Cooby9VKS92Zo5xWKQbtCDPTzr2ugsuHzp054NaQTwhGJO/WpgAX1VVoW9MsN4dQxOi6Kf2sIRBUZU4TL+caTQ== dependencies: "@standardnotes/auth" "3.8.1" "@standardnotes/common" "1.2.1" "@standardnotes/domain-events" "2.5.1" - "@standardnotes/features" "^1.20.6" + "@standardnotes/features" "^1.20.7" "@standardnotes/settings" "^1.9.0" "@standardnotes/sncrypto-common" "1.5.2" From 6e1e5abaea644a3c193754725258a31123e2fee3 Mon Sep 17 00:00:00 2001 From: Mo Date: Wed, 12 Jan 2022 09:03:26 -0600 Subject: [PATCH 17/18] fix: remove background color from scrollable class --- app/assets/stylesheets/_navigation.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/stylesheets/_navigation.scss b/app/assets/stylesheets/_navigation.scss index 1c6ec9a97..7faf812ca 100644 --- a/app/assets/stylesheets/_navigation.scss +++ b/app/assets/stylesheets/_navigation.scss @@ -3,7 +3,6 @@ #navigation .scrollable { @include minimal_scrollbar(); height: 100%; - background-color: var(--sn-stylekit-background-color); } #navigation { From cc97991aca778923d80b0e13156d8b185d17dbde Mon Sep 17 00:00:00 2001 From: Mo Date: Wed, 12 Jan 2022 09:28:50 -0600 Subject: [PATCH 18/18] release: 3.9.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b016c0516..005ed9994 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "standard-notes-web", - "version": "3.9.13", + "version": "3.9.14", "license": "AGPL-3.0-or-later", "repository": { "type": "git",