feat: native smart tags (#782)

* feat: introduce native smart tags

* feat: introduce react navigation

* feat: render smart tag special cases

* feat: add create tag & all count

* feat: move components to react + mobx

* fix: workaround issue with snjs

* feat: nice smart tag icons in experimental

* feat: add back components

* fix: typo on all tags

* feat: add panel resizer + simplif code

* fix: panel resize size & refresh

* fix: auto select all notes

* style: remove legacy tag view

* style: remove legacy directives

* fix: select tag from note view

* feat: WIP smart tag rename

* fix: template checks

* fix: user can create new notes

* panel: init width

* fix: panel resizer ref

* fix: update with new component viewer

* fix: use fixed isTemplateItem & fixed findItems

* refactor: rename tags panel into navigation

* style: remove TODOs that are ok

* feat: smart tag premium check with premium service

* refactor: multi-select variables for debuggability

* fix: clean deinit code

* fix: prevent trigger tag changes event for the same uuid

* fix: typings

* fix: use minimal state

* style: remove dead code

* style: long variable names

* refactor: move magic string to module

* fix: use smart filter feature

* refactor: add task id in todo
This commit is contained in:
Laurent Senta
2022-01-04 14:02:58 +01:00
committed by GitHub
parent 7dd4a60595
commit c3772e06b4
33 changed files with 1030 additions and 868 deletions

View File

@@ -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) => {

View File

@@ -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<void> {
this._premiumAlertFeatureName = featureName;
return when(() => this._premiumAlertFeatureName === undefined);
}
this._hasFolders = hasFolders;
public async closePremiumAlert(): Promise<void> {
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;
}
}

View File

@@ -115,12 +115,12 @@ export class NotesState {
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> {
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 =

View File

@@ -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 (

View File

@@ -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<TagMutator>(
insertedTag.uuid,
(m) => {
m.title = newTitle;
}
);
this.selected = changedTag as SNTag;
await this.application.saveItem(insertedTag.uuid);
}
} else {
await this.application.changeAndSaveItem<TagMutator>(
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 {

View File

@@ -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);