Merge branch 'release/10.10.0'

This commit is contained in:
Mo
2022-02-11 10:27:14 -06:00
287 changed files with 7999 additions and 7584 deletions

View File

@@ -4,7 +4,6 @@
"@babel/preset-env" "@babel/preset-env"
], ],
"plugins": [ "plugins": [
"angularjs-annotate",
["@babel/plugin-transform-react-jsx", { ["@babel/plugin-transform-react-jsx", {
"pragma": "h", "pragma": "h",
"pragmaFrag": "Fragment" "pragmaFrag": "Fragment"

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1667 3.60714V8.42857L12.5 6.82143L10.8333 8.42857V3.60714H7.5V16.4643H15.8333V3.60714H14.1667ZM2.5 6.01786V4.41071H4.16667V3.60714C4.16667 2.71518 4.91667 2 5.83333 2H15.8333C16.7083 2 17.5 2.76339 17.5 3.60714V16.4643C17.5 17.308 16.7083 18.0714 15.8333 18.0714H5.83333C4.95833 18.0714 4.16667 17.308 4.16667 16.4643V15.6607H2.5V14.0536H4.16667V10.8393H2.5V9.23214H4.16667V6.01786H2.5ZM4.16667 4.41071V6.01786H5.83333V4.41071H4.16667ZM4.16667 15.6607H5.83333V14.0536H4.16667V15.6607ZM4.16667 10.8393H5.83333V9.23214H4.16667V10.8393Z" />
</svg>

After

Width:  |  Height:  |  Size: 632 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8334 17.4999V15.8333H12.5001V14.1666H15.8334V12.4999L18.3334 14.9999L15.8334 17.4999ZM10.8334 14.9999C10.8334 15.5916 10.9584 16.1583 11.1834 16.6666H1.66675V14.1666C1.66675 12.3249 4.65008 10.8333 8.33341 10.8333C9.16675 10.8333 9.96675 10.9083 10.7084 11.0499C11.4001 11.1833 12.0334 11.3666 12.5917 11.5999C11.5251 12.3583 10.8334 13.5999 10.8334 14.9999ZM3.33341 14.1666V14.9999H9.16675C9.16675 14.1333 9.35841 13.3083 9.70008 12.5666L8.33341 12.4999C5.57508 12.4999 3.33341 13.2499 3.33341 14.1666ZM8.33341 3.33325C9.21747 3.33325 10.0653 3.68444 10.6904 4.30956C11.3156 4.93468 11.6667 5.78253 11.6667 6.66659C11.6667 7.55064 11.3156 8.39849 10.6904 9.02361C10.0653 9.64873 9.21747 9.99992 8.33341 9.99992C7.44936 9.99992 6.60151 9.64873 5.97639 9.02361C5.35127 8.39849 5.00008 7.55064 5.00008 6.66659C5.00008 5.78253 5.35127 4.93468 5.97639 4.30956C6.60151 3.68444 7.44936 3.33325 8.33341 3.33325ZM8.33341 4.99992C7.89139 4.99992 7.46746 5.17551 7.1549 5.48807C6.84234 5.80063 6.66675 6.22456 6.66675 6.66659C6.66675 7.10861 6.84234 7.53254 7.1549 7.8451C7.46746 8.15766 7.89139 8.33325 8.33341 8.33325C8.77544 8.33325 9.19936 8.15766 9.51193 7.8451C9.82449 7.53254 10.0001 7.10861 10.0001 6.66659C10.0001 6.22456 9.82449 5.80063 9.51193 5.48807C9.19936 5.17551 8.77544 4.99992 8.33341 4.99992Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 20 20" fill="#fff" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.8333 10.8337H9.16663V5.83366H10.8333V10.8337ZM10.8333 14.167H9.16663V12.5003H10.8333V14.167ZM9.99996 1.66699C8.90561 1.66699 7.82198 1.88254 6.81093 2.30133C5.79988 2.72012 4.88122 3.33395 4.1074 4.10777C2.5446 5.67057 1.66663 7.79019 1.66663 10.0003C1.66663 12.2105 2.5446 14.3301 4.1074 15.8929C4.88122 16.6667 5.79988 17.2805 6.81093 17.6993C7.82198 18.1181 8.90561 18.3337 9.99996 18.3337C12.2101 18.3337 14.3297 17.4557 15.8925 15.8929C17.4553 14.3301 18.3333 12.2105 18.3333 10.0003C18.3333 8.90598 18.1177 7.82234 17.699 6.8113C17.2802 5.80025 16.6663 4.88159 15.8925 4.10777C15.1187 3.33395 14.2 2.72012 13.189 2.30133C12.1779 1.88254 11.0943 1.66699 9.99996 1.66699Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 802 B

View File

@@ -1,226 +0,0 @@
'use strict';
declare global {
interface Window {
// eslint-disable-next-line camelcase
_bugsnag_api_key?: string;
// eslint-disable-next-line camelcase
_purchase_url?: string;
// eslint-disable-next-line camelcase
_plans_url?: string;
// eslint-disable-next-line camelcase
_dashboard_url?: string;
// eslint-disable-next-line camelcase
_default_sync_server: string;
// eslint-disable-next-line camelcase
_enable_unfinished_features: boolean;
// eslint-disable-next-line camelcase
_websocket_url: string;
startApplication?: StartApplication;
_devAccountEmail?: string;
_devAccountPassword?: string;
_devAccountServer?: string;
}
}
import { ComponentViewDirective } from '@/components/ComponentView';
import { NavigationDirective } from '@/components/Navigation';
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { IsWebPlatform, WebAppVersion } from '@/version';
import {
ApplicationGroupView,
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,
delayHide,
elemReady,
fileChange,
lowercase,
selectOnFocus,
snEnter,
} from './directives/functional';
import {
ActionsMenu,
EditorMenu,
HistoryMenu,
InputModal,
MenuRow,
PanelResizer,
PasswordWizard,
PermissionsModal,
RevisionPreviewModal,
SyncResolutionMenu,
} from './directives/views';
import { trusted } from './filters';
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 { ApplicationGroup } from './ui_models/application_group';
import { isDev } from './utils';
import { AccountSwitcher } from './views/account_switcher/account_switcher';
function reloadHiddenFirefoxTab(): boolean {
/**
* For Firefox pinned tab issue:
* When a new browser session is started, and SN is in a pinned tab,
* SN exhibits strange behavior until the tab is reloaded.
*/
if (
document.hidden &&
navigator.userAgent.toLowerCase().includes('firefox')
) {
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
location.reload();
}
});
return true;
} else {
return false;
}
}
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
bridge: Bridge,
enableUnfinishedFeatures: boolean,
webSocketUrl: string
) {
if (reloadHiddenFirefoxTab()) {
return;
}
SNLog.onLog = console.log;
startErrorReporting();
angular.module('app', []);
// Config
angular
.module('app')
.config(configRoutes)
.constant('bridge', bridge)
.constant('defaultSyncServerHost', defaultSyncServerHost)
.constant('appVersion', bridge.appVersion)
.constant('enableUnfinishedFeatures', enableUnfinishedFeatures)
.constant('webSocketUrl', webSocketUrl);
// Controllers
angular
.module('app')
.directive('applicationGroupView', () => new ApplicationGroupView())
.directive('applicationView', () => new ApplicationView())
.directive('noteGroupView', () => new NoteGroupViewDirective())
.directive('noteView', () => new NoteViewDirective())
.directive('footerView', () => new FooterView());
// Directives - Functional
angular
.module('app')
.directive('snAutofocus', ['$timeout', autofocus])
.directive('clickOutside', ['$document', clickOutside])
.directive('delayHide', delayHide)
.directive('elemReady', elemReady)
.directive('fileChange', fileChange)
.directive('lowercase', lowercase)
.directive('selectOnFocus', ['$window', selectOnFocus])
.directive('snEnter', snEnter);
// Directives - Views
angular
.module('app')
.directive('accountSwitcher', () => new AccountSwitcher())
.directive('actionsMenu', () => new ActionsMenu())
.directive('challengeModal', () => new ChallengeModal())
.directive('componentView', ComponentViewDirective)
.directive('editorMenu', () => new EditorMenu())
.directive('inputModal', () => new InputModal())
.directive('menuRow', () => new MenuRow())
.directive('panelResizer', () => new PanelResizer())
.directive('passwordWizard', () => new PasswordWizard())
.directive('permissionsModal', () => new PermissionsModal())
.directive('revisionPreviewModal', () => new RevisionPreviewModal())
.directive('historyMenu', () => new HistoryMenu())
.directive('syncResolutionMenu', () => new SyncResolutionMenu())
.directive('sessionsModal', SessionsModalDirective)
.directive('accountMenu', AccountMenuDirective)
.directive('quickSettingsMenu', QuickSettingsMenuDirective)
.directive('noAccountWarning', NoAccountWarningDirective)
.directive('protectedNotePanel', ProtectedNoteOverlayDirective)
.directive('searchOptions', SearchOptionsDirective)
.directive('confirmSignout', ConfirmSignoutDirective)
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
.directive('notesContextMenu', NotesContextMenuDirective)
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
.directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('navigation', NavigationDirective)
.directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective)
.directive('notesView', NotesViewDirective)
.directive('pinNoteButton', PinNoteButtonDirective);
// Filters
angular.module('app').filter('trusted', ['$sce', trusted]);
// Services
angular.module('app').service('mainApplicationGroup', ApplicationGroup);
// Debug
if (isDev) {
Object.defineProperties(window, {
application: {
get: () =>
(
angular
.element(document)
.injector()
.get('mainApplicationGroup') as any
).primaryApplication,
},
});
}
angular.element(document).ready(() => {
angular.bootstrap(document, ['app']);
});
};
if (IsWebPlatform) {
startApplication(
window._default_sync_server,
new BrowserBridge(WebAppVersion),
window._enable_unfinished_features,
window._websocket_url
);
} else {
window.startApplication = startApplication;
}

View File

@@ -0,0 +1,89 @@
'use strict';
declare global {
interface Window {
// eslint-disable-next-line camelcase
_bugsnag_api_key?: string;
// eslint-disable-next-line camelcase
_purchase_url?: string;
// eslint-disable-next-line camelcase
_plans_url?: string;
// eslint-disable-next-line camelcase
_dashboard_url?: string;
// eslint-disable-next-line camelcase
_default_sync_server: string;
// eslint-disable-next-line camelcase
_enable_unfinished_features: boolean;
// eslint-disable-next-line camelcase
_websocket_url: string;
startApplication?: StartApplication;
_devAccountEmail?: string;
_devAccountPassword?: string;
_devAccountServer?: string;
}
}
import { IsWebPlatform, WebAppVersion } from '@/version';
import { Runtime, SNLog } from '@standardnotes/snjs';
import { render } from 'preact';
import { ApplicationGroupView } from './components/ApplicationGroupView';
import { Bridge } from './services/bridge';
import { BrowserBridge } from './services/browserBridge';
import { startErrorReporting } from './services/errorReporting';
import { StartApplication } from './startApplication';
import { ApplicationGroup } from './ui_models/application_group';
import { isDev } from './utils';
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
bridge: Bridge,
enableUnfinishedFeatures: boolean,
webSocketUrl: string
) {
SNLog.onLog = console.log;
startErrorReporting();
const mainApplicationGroup = new ApplicationGroup(
defaultSyncServerHost,
bridge,
enableUnfinishedFeatures ? Runtime.Dev : Runtime.Prod,
webSocketUrl
);
if (isDev) {
Object.defineProperties(window, {
application: {
get: () => mainApplicationGroup.primaryApplication,
},
});
}
const renderApp = () => {
render(
<ApplicationGroupView mainApplicationGroup={mainApplicationGroup} />,
document.body.appendChild(document.createElement('div'))
);
};
const domReady =
document.readyState === 'complete' || document.readyState === 'interactive';
if (domReady) {
renderApp();
} else {
window.addEventListener('DOMContentLoaded', () => {
renderApp();
});
}
};
if (IsWebPlatform) {
startApplication(
window._default_sync_server,
new BrowserBridge(WebAppVersion),
window._enable_unfinished_features,
window._websocket_url
);
} else {
window.startApplication = startApplication;
}

View File

@@ -2,38 +2,27 @@ import { ApplicationEvent } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'; import { autorun, IReactionDisposer, IReactionPublic } from 'mobx';
import { Component } from 'preact';
import { findDOMNode, unmountComponentAtNode } from 'preact/compat';
export type CtrlState = Partial<Record<string, any>>; export type PureComponentState = Partial<Record<string, any>>;
export type CtrlProps = Partial<Record<string, any>>; export type PureComponentProps = Partial<Record<string, any>>;
export class PureViewCtrl<P = CtrlProps, S = CtrlState> { export abstract class PureComponent<
$timeout: ng.ITimeoutService; P = PureComponentProps,
/** Passed through templates */ S = PureComponentState
application!: WebApplication; > extends Component<P, S> {
state: S = {} as any; private unsubApp!: () => void;
private unsubApp: any; private unsubState!: () => void;
private unsubState: any;
private stateTimeout?: ng.IPromise<void>;
/**
* Subclasses can optionally add an ng-if=ctrl.templateReady to make sure that
* no Angular handlebars/syntax render in the UI before display data is ready.
*/
protected templateReady = false;
private reactionDisposers: IReactionDisposer[] = []; private reactionDisposers: IReactionDisposer[] = [];
/* @ngInject */ constructor(props: P, protected application: WebApplication) {
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) { super(props);
this.$timeout = $timeout;
} }
$onInit(): void { componentDidMount() {
this.state = {
...this.getInitialState(),
...this.state,
};
this.addAppEventObserver(); this.addAppEventObserver();
this.addAppStateObserver(); this.addAppStateObserver();
this.templateReady = true;
} }
deinit(): void { deinit(): void {
@@ -43,63 +32,38 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
disposer(); disposer();
} }
this.reactionDisposers.length = 0; this.reactionDisposers.length = 0;
this.unsubApp = undefined; (this.unsubApp as unknown) = undefined;
this.unsubState = undefined; (this.unsubState as unknown) = undefined;
if (this.stateTimeout) {
this.$timeout.cancel(this.stateTimeout);
}
} }
$onDestroy(): void { protected dismissModal(): void {
const elem = this.getElement();
if (!elem) {
return;
}
const parent = elem.parentElement;
if (!parent) {
return;
}
parent.remove();
unmountComponentAtNode(parent);
}
componentWillUnmount(): void {
this.deinit(); this.deinit();
} }
render() {
return <div>Must override</div>;
}
public get appState(): AppState { public get appState(): AppState {
return this.application.getAppState(); return this.application.getAppState();
} }
/** @private */ protected getElement(): Element | null {
async resetState(): Promise<void> { return findDOMNode(this);
this.state = this.getInitialState();
await this.setState(this.state);
}
/** @override */
getInitialState(): S {
return {} as any;
}
async setState(state: Partial<S>): Promise<void> {
if (!this.$timeout) {
return;
}
return new Promise<void>((resolve) => {
this.stateTimeout = this.$timeout(() => {
/**
* State changes must be *inside* the timeout block for them to be affected in the UI
* Otherwise UI controllers will need to use $timeout everywhere
*/
this.state = Object.freeze(Object.assign({}, this.state, state));
resolve();
this.afterStateChange();
});
});
}
/** @override */
// eslint-disable-next-line @typescript-eslint/no-empty-function
afterStateChange(): void {}
/** @returns a promise that resolves after the UI has been updated. */
flushUI(): angular.IPromise<void> {
return this.$timeout();
}
initProps(props: CtrlProps): void {
if (Object.keys(this.props).length > 0) {
throw 'Already init-ed props.';
}
this.props = Object.freeze(Object.assign({}, this.props, props));
} }
autorun(view: (r: IReactionPublic) => void): void { autorun(view: (r: IReactionPublic) => void): void {
@@ -151,7 +115,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
/** @override */ /** @override */
async onAppStart() { async onAppStart() {
await this.resetState(); /** Optional override */
} }
onLocalDataLoaded() { onLocalDataLoaded() {

View File

@@ -1,9 +1,8 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { isDev } from '@/utils';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Checkbox } from '../Checkbox'; import { Checkbox } from '../Checkbox';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { InputWithIcon } from '../InputWithIcon'; import { InputWithIcon } from '../InputWithIcon';

View File

@@ -1,8 +1,8 @@
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toDirective } from '@/components/utils'; import { useCloseOnClickOutside } from '@/components/utils';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { useState } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import { GeneralAccountMenu } from './GeneralAccountMenu'; import { GeneralAccountMenu } from './GeneralAccountMenu';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { SignInPane } from './SignIn'; import { SignInPane } from './SignIn';
@@ -21,9 +21,12 @@ export enum AccountMenuPane {
type Props = { type Props = {
appState: AppState; appState: AppState;
application: WebApplication; application: WebApplication;
onClickOutside: () => void;
}; };
type PaneSelectorProps = Props & { type PaneSelectorProps = {
appState: AppState;
application: WebApplication;
menuPane: AccountMenuPane; menuPane: AccountMenuPane;
setMenuPane: (pane: AccountMenuPane) => void; setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void; closeMenu: () => void;
@@ -79,8 +82,8 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
} }
); );
const AccountMenu: FunctionComponent<Props> = observer( export const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState }) => { ({ application, appState, onClickOutside }) => {
const { const {
currentPane, currentPane,
setCurrentPane, setCurrentPane,
@@ -88,6 +91,11 @@ const AccountMenu: FunctionComponent<Props> = observer(
closeAccountMenu, closeAccountMenu,
} = appState.accountMenu; } = appState.accountMenu;
const ref = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(ref, () => {
onClickOutside();
});
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = ( const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
event event
) => { ) => {
@@ -105,7 +113,7 @@ const AccountMenu: FunctionComponent<Props> = observer(
}; };
return ( return (
<div className='sn-component'> <div ref={ref} id="account-menu" className="sn-component">
<div <div
className={`sn-menu-border sn-account-menu sn-dropdown ${ className={`sn-menu-border sn-account-menu sn-dropdown ${
shouldAnimateCloseMenu shouldAnimateCloseMenu
@@ -130,5 +138,3 @@ const AccountMenu: FunctionComponent<Props> = observer(
); );
} }
); );
export const AccountMenuDirective = toDirective<Props>(AccountMenu);

View File

@@ -0,0 +1,169 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebApplication } from '@/ui_models/application';
import { ApplicationDescriptor } from '@standardnotes/snjs';
import { PureComponent } from '@/components/Abstract/PureComponent';
import { JSX } from 'preact';
type Props = {
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
};
type State = {
descriptors: ApplicationDescriptor[];
editingDescriptor?: ApplicationDescriptor;
};
export class AccountSwitcher extends PureComponent<Props, State> {
private removeAppGroupObserver: any;
activeApplication!: WebApplication;
constructor(props: Props) {
super(props, props.application);
this.removeAppGroupObserver =
props.mainApplicationGroup.addApplicationChangeObserver(() => {
this.activeApplication = props.mainApplicationGroup
.primaryApplication as WebApplication;
this.reloadApplications();
});
}
reloadApplications() {
this.setState({
descriptors: this.props.mainApplicationGroup.getDescriptors(),
});
}
addNewApplication = () => {
this.dismiss();
this.props.mainApplicationGroup.addNewApplication();
};
selectDescriptor = (descriptor: ApplicationDescriptor) => {
this.dismiss();
this.props.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
};
inputForDescriptor(descriptor: ApplicationDescriptor) {
return document.getElementById(`input-${descriptor.identifier}`);
}
renameDescriptor = (event: Event, descriptor: ApplicationDescriptor) => {
event.stopPropagation();
this.setState({ editingDescriptor: descriptor });
setTimeout(() => {
this.inputForDescriptor(descriptor)?.focus();
});
};
submitRename = () => {
this.props.mainApplicationGroup.renameDescriptor(
this.state.editingDescriptor!,
this.state.editingDescriptor!.label
);
this.setState({ editingDescriptor: undefined });
};
deinit() {
super.deinit();
this.removeAppGroupObserver();
this.removeAppGroupObserver = undefined;
}
onDescriptorInputChange = (
descriptor: ApplicationDescriptor,
{ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>
) => {
descriptor.label = currentTarget.value;
};
dismiss = () => {
this.dismissModal();
};
render() {
return (
<div className="sk-modal">
<div onClick={this.dismiss} className="sk-modal-background" />
<div id="account-switcher" className="sk-modal-content">
<div className="sn-component">
<div id="menu-panel" className="sk-menu-panel">
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">
Account Switcher
</div>
</div>
<div className="sk-menu-panel-column">
<a onClick={this.addNewApplication} className="sk-label info">
Add Account
</a>
</div>
</div>
{this.state.descriptors.map((descriptor) => {
return (
<div
key={descriptor.identifier}
onClick={() => this.selectDescriptor(descriptor)}
className="sk-menu-panel-row"
>
<div className="sk-menu-panel-column stretch">
<div className="left">
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-menu-panel-column">
<div className="sk-circle small success" />
</div>
)}
<div className="sk-menu-panel-column stretch">
<input
value={descriptor.label}
disabled={
descriptor !== this.state.editingDescriptor
}
onChange={(event) =>
this.onDescriptorInputChange(descriptor, event)
}
onKeyUp={(event) =>
event.keyCode == 13 && this.submitRename()
}
id={`input-${descriptor.identifier}`}
spellcheck={false}
className="sk-label clickable"
/>
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-sublabel">
Current Application
</div>
)}
</div>
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-menu-panel-column">
<button
onClick={(event) =>
this.renameDescriptor(event, descriptor)
}
className="sn-button success"
>
Rename
</button>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,329 @@
import { WebApplication } from '@/ui_models/application';
import {
Action,
SNActionsExtension,
UuidString,
SNNote,
ListedAccount,
} from '@standardnotes/snjs';
import { ActionResponse } from '@standardnotes/snjs';
import { render } from 'preact';
import { PureComponent } from './Abstract/PureComponent';
import { MenuRow } from './MenuRow';
import { RevisionPreviewModal } from './RevisionPreviewModal';
type ActionRow = Action & {
running?: boolean;
spinnerClass?: string;
subtitle?: string;
};
type MenuSection = {
uuid: UuidString;
name: string;
loading?: boolean;
error?: boolean;
hidden?: boolean;
deprecation?: string;
extension?: SNActionsExtension;
rows?: ActionRow[];
listedAccount?: ListedAccount;
};
type State = {
menuSections: MenuSection[];
selectedActionIdentifier?: string;
};
type Props = {
application: WebApplication;
note: SNNote;
};
export class ActionsMenu extends PureComponent<Props, State> {
constructor(props: Props) {
super(props, props.application);
this.state = {
menuSections: [],
};
this.loadExtensions();
}
private async loadExtensions(): Promise<void> {
const unresolvedListedSections =
await this.getNonresolvedListedMenuSections();
const unresolvedGenericSections =
await this.getNonresolvedGenericMenuSections();
this.setState(
{
menuSections: unresolvedListedSections.concat(
unresolvedGenericSections
),
},
() => {
this.state.menuSections.forEach((menuSection) => {
this.resolveMenuSection(menuSection);
});
}
);
}
private async getNonresolvedGenericMenuSections(): Promise<MenuSection[]> {
const genericExtensions = this.props.application.actionsManager
.getExtensions()
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
});
return genericExtensions.map((extension) => {
const menuSection: MenuSection = {
uuid: extension.uuid,
name: extension.name,
extension: extension,
loading: true,
hidden: this.appState.actionsMenu.hiddenSections[extension.uuid],
};
return menuSection;
});
}
private async getNonresolvedListedMenuSections(): Promise<MenuSection[]> {
const listedAccountEntries =
await this.props.application.getListedAccounts();
return listedAccountEntries.map((entry) => {
const menuSection: MenuSection = {
uuid: entry.authorId,
name: `Listed ${entry.authorId}`,
loading: true,
listedAccount: entry,
hidden: this.appState.actionsMenu.hiddenSections[entry.authorId],
};
return menuSection;
});
}
private resolveMenuSection(menuSection: MenuSection): void {
if (menuSection.listedAccount) {
this.props.application
.getListedAccountInfo(menuSection.listedAccount, this.props.note.uuid)
.then((accountInfo) => {
if (!accountInfo) {
this.promoteMenuSection({
...menuSection,
loading: false,
});
return;
}
const existingMenuSection = this.state.menuSections.find(
(item) => item.uuid === menuSection.listedAccount?.authorId
) as MenuSection;
const resolvedMenuSection: MenuSection = {
...existingMenuSection,
loading: false,
error: false,
name: accountInfo.display_name,
rows: accountInfo?.actions,
};
this.promoteMenuSection(resolvedMenuSection);
});
} else if (menuSection.extension) {
this.props.application.actionsManager
.loadExtensionInContextOfItem(menuSection.extension, this.props.note)
.then((resolvedExtension) => {
if (!resolvedExtension) {
this.promoteMenuSection({
...menuSection,
loading: false,
});
return;
}
const actions = resolvedExtension.actionsWithContextForItem(
this.props.note
);
const resolvedMenuSection: MenuSection = {
...menuSection,
rows: actions,
deprecation: resolvedExtension.deprecation,
loading: false,
error: false,
};
this.promoteMenuSection(resolvedMenuSection);
});
}
}
private promoteMenuSection(newItem: MenuSection): void {
const menuSections = this.state.menuSections.map((menuSection) => {
if (menuSection.uuid === newItem.uuid) {
return newItem;
} else {
return menuSection;
}
});
this.setState({ menuSections });
}
private promoteAction(newAction: Action, section: MenuSection): void {
const newSection: MenuSection = {
...section,
rows: section.rows?.map((action) => {
if (action.url === newAction.url) {
return newAction;
} else {
return action;
}
}),
};
this.promoteMenuSection(newSection);
}
private idForAction(action: Action) {
return `${action.label}:${action.verb}:${action.desc}`;
}
executeAction = async (action: Action, section: MenuSection) => {
const isLegacyNoteHistoryExt = action.verb === 'nested';
if (isLegacyNoteHistoryExt) {
const showRevisionAction = action.subactions![0];
action = showRevisionAction;
}
this.promoteAction(
{
...action,
running: true,
},
section
);
const response = await this.props.application.actionsManager.runAction(
action,
this.props.note
);
this.promoteAction(
{
...action,
running: false,
},
section
);
if (!response || response.error) {
return;
}
this.handleActionResponse(action, response);
this.resolveMenuSection(section);
};
handleActionResponse(action: Action, result: ActionResponse) {
switch (action.verb) {
case 'render': {
const item = result.item;
render(
<RevisionPreviewModal
application={this.application}
uuid={item.uuid}
content={item.content}
/>,
document.body.appendChild(document.createElement('div'))
);
}
}
}
public toggleSectionVisibility(menuSection: MenuSection) {
this.appState.actionsMenu.toggleSectionVisibility(menuSection.uuid);
this.promoteMenuSection({
...menuSection,
hidden: !menuSection.hidden,
});
}
renderMenuSection(section: MenuSection) {
return (
<div>
<div
key={section.uuid}
className="sk-menu-panel-header"
onClick={($event) => {
this.toggleSectionVisibility(section);
$event.stopPropagation();
}}
>
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">{section.name}</div>
{section.hidden && <div></div>}
{section.deprecation && !section.hidden && (
<div className="sk-menu-panel-header-subtitle">
{section.deprecation}
</div>
)}
</div>
{section.loading && <div className="sk-spinner small loading" />}
</div>
<div>
{section.error && !section.hidden && (
<MenuRow
faded={true}
label="Error loading actions"
subtitle="Please try again later."
/>
)}
{!section.rows?.length && !section.hidden && (
<MenuRow faded={true} label="No Actions Available" />
)}
{!section.hidden &&
!section.loading &&
!section.error &&
section.rows?.map((action, index) => {
return (
<MenuRow
key={index}
action={() => {
this.executeAction(action, section);
}}
label={action.label}
disabled={action.running}
spinnerClass={action.running ? 'info' : undefined}
subtitle={action.desc}
>
{action.access_type && (
<div className="sk-sublabel">
{'Uses '}
<strong>{action.access_type}</strong>
{' access to this note.'}
</div>
)}
</MenuRow>
);
})}
</div>
</div>
);
}
render() {
return (
<div className="sn-component">
<div className="sk-menu-panel dropdown-menu">
{this.state.menuSections.length == 0 && (
<MenuRow label="No Actions" />
)}
{this.state.menuSections.map((extension) =>
this.renderMenuSection(extension)
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,42 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebApplication } from '@/ui_models/application';
import { Component } from 'preact';
import { ApplicationView } from './ApplicationView';
type State = {
activeApplication?: WebApplication;
};
type Props = {
mainApplicationGroup: ApplicationGroup;
};
export class ApplicationGroupView extends Component<Props, State> {
constructor(props: Props) {
super(props);
props.mainApplicationGroup.addApplicationChangeObserver(() => {
const activeApplication = props.mainApplicationGroup
.primaryApplication as WebApplication;
this.setState({ activeApplication });
});
props.mainApplicationGroup.initialize();
}
render() {
return (
<>
{this.state.activeApplication && (
<div id={this.state.activeApplication.identifier}>
<ApplicationView
key={this.state.activeApplication.ephemeralIdentifier}
mainApplicationGroup={this.props.mainApplicationGroup}
application={this.state.activeApplication}
/>
</div>
)}
</>
);
}
}

View File

@@ -0,0 +1,274 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { getPlatformString } from '@/utils';
import { AppStateEvent, PanelResizedData } from '@/ui_models/app_state';
import {
ApplicationEvent,
Challenge,
PermissionDialog,
removeFromArray,
} from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { alertDialog } from '@/services/alertService';
import { WebAppEvent, WebApplication } from '@/ui_models/application';
import { PureComponent } from '@/components/Abstract/PureComponent';
import { Navigation } from '@/components/Navigation';
import { NotesView } from '@/components/NotesView';
import { NoteGroupView } from '@/components/NoteGroupView';
import { Footer } from '@/components/Footer';
import { SessionsModal } from '@/components/SessionsModal';
import { PreferencesViewWrapper } from '@/preferences/PreferencesViewWrapper';
import { ChallengeModal } from '@/components/ChallengeModal';
import { NotesContextMenu } from '@/components/NotesContextMenu';
import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper';
import { render } from 'preact';
import { PermissionsModal } from './PermissionsModal';
import { PremiumModalProvider } from './Premium';
type Props = {
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
};
type State = {
started?: boolean;
launched?: boolean;
needsUnlock?: boolean;
appClass: string;
challenges: Challenge[];
};
export class ApplicationView extends PureComponent<Props, State> {
public readonly platformString = getPlatformString();
constructor(props: Props) {
super(props, props.application);
this.state = {
appClass: '',
challenges: [],
};
this.onDragDrop = this.onDragDrop.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.addDragDropHandlers();
}
deinit() {
(this.application as unknown) = undefined;
window.removeEventListener('dragover', this.onDragOver, true);
window.removeEventListener('drop', this.onDragDrop, true);
(this.onDragDrop as unknown) = undefined;
(this.onDragOver as unknown) = undefined;
super.deinit();
}
componentDidMount(): void {
super.componentDidMount();
this.loadApplication();
}
async loadApplication() {
this.application.componentManager.setDesktopManager(
this.application.getDesktopService()
);
await this.application.prepareForLaunch({
receiveChallenge: async (challenge) => {
const challenges = this.state.challenges.slice();
challenges.push(challenge);
this.setState({ challenges: challenges });
},
});
await this.application.launch();
}
public removeChallenge = async (challenge: Challenge) => {
const challenges = this.state.challenges.slice();
removeFromArray(challenges, challenge);
this.setState({ challenges: challenges });
};
async onAppStart() {
super.onAppStart();
this.setState({
started: true,
needsUnlock: this.application.hasPasscode(),
});
this.application.componentManager.presentPermissionsDialog =
this.presentPermissionsDialog;
}
async onAppLaunch() {
super.onAppLaunch();
this.setState({
launched: true,
needsUnlock: false,
});
this.handleDemoSignInFromParams();
}
onUpdateAvailable() {
this.application.notifyWebEvent(WebAppEvent.NewUpdateAvailable);
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
switch (eventName) {
case ApplicationEvent.LocalDatabaseReadError:
alertDialog({
text: 'Unable to load local database. Please restart the app and try again.',
});
break;
case ApplicationEvent.LocalDatabaseWriteError:
alertDialog({
text: 'Unable to write to local database. Please restart the app and try again.',
});
break;
}
}
/** @override */
async onAppStateEvent(eventName: AppStateEvent, data?: unknown) {
if (eventName === AppStateEvent.PanelResized) {
const { panel, collapsed } = data as PanelResizedData;
let appClass = '';
if (panel === PANEL_NAME_NOTES && collapsed) {
appClass += 'collapsed-notes';
}
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
appClass += ' collapsed-navigation';
}
this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) {
if (!(await this.application.isLocked())) {
this.application.sync();
}
}
}
addDragDropHandlers() {
/**
* Disable dragging and dropping of files (but allow text) into main SN interface.
* both 'dragover' and 'drop' are required to prevent dropping of files.
* This will not prevent extensions from receiving drop events.
*/
window.addEventListener('dragover', this.onDragOver, true);
window.addEventListener('drop', this.onDragDrop, true);
}
onDragOver(event: DragEvent) {
if (event.dataTransfer?.files.length) {
event.preventDefault();
}
}
onDragDrop(event: DragEvent) {
if (event.dataTransfer?.files.length) {
event.preventDefault();
void alertDialog({
text: STRING_DEFAULT_FILE_ERROR,
});
}
}
async handleDemoSignInFromParams() {
if (
window.location.href.includes('demo') &&
!this.application.hasAccount()
) {
await this.application.setCustomHost(
'https://syncing-server-demo.standardnotes.com'
);
this.application.signIn('demo@standardnotes.org', 'password');
}
}
presentPermissionsDialog = (dialog: PermissionDialog) => {
render(
<PermissionsModal
application={this.application}
callback={dialog.callback}
component={dialog.component}
permissionsString={dialog.permissionsString}
/>,
document.body.appendChild(document.createElement('div'))
);
};
render() {
if (this.application['dealloced'] === true) {
console.error('Attempting to render dealloced application');
return <div></div>;
}
const renderAppContents = !this.state.needsUnlock && this.state.launched;
return (
<PremiumModalProvider state={this.appState?.features}>
<div className={this.platformString + ' main-ui-view sn-component'}>
{renderAppContents && (
<div
id="app"
className={this.state.appClass + ' app app-column-container'}
>
<Navigation application={this.application} />
<NotesView
application={this.application}
appState={this.appState}
/>
<NoteGroupView application={this.application} />
</div>
)}
{renderAppContents && (
<>
<Footer
application={this.application}
applicationGroup={this.props.mainApplicationGroup}
/>
<SessionsModal
application={this.application}
appState={this.appState}
/>
<PreferencesViewWrapper
appState={this.appState}
application={this.application}
/>
</>
)}
{this.state.challenges.map((challenge) => {
return (
<div className="sk-modal">
<ChallengeModal
key={challenge.id}
application={this.application}
challenge={challenge}
onDismiss={this.removeChallenge}
/>
</div>
);
})}
{renderAppContents && (
<>
<NotesContextMenu
application={this.application}
appState={this.appState}
/>
<PurchaseFlowWrapper
application={this.application}
appState={this.appState}
/>
</>
)}
</div>
</PremiumModalProvider>
);
}
}

View File

@@ -0,0 +1,371 @@
import { WebApplication } from '@/ui_models/application';
import { Dialog } from '@reach/dialog';
import {
ChallengeValue,
removeFromArray,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { createRef } from 'preact';
import { PureComponent } from '@/components/Abstract/PureComponent';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
type Values = Record<number, InputValue>;
type State = {
prompts: ChallengePrompt[];
values: Partial<Values>;
processing: boolean;
forgotPasscode: boolean;
showForgotPasscodeLink: boolean;
processingPrompts: ChallengePrompt[];
hasAccount: boolean;
protectedNoteAccessDuration: number;
};
type Props = {
challenge: Challenge;
application: WebApplication;
onDismiss: (challenge: Challenge) => void;
};
export class ChallengeModal extends PureComponent<Props, State> {
submitting = false;
protectionsSessionDurations = ProtectionSessionDurations;
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
private initialFocusRef = createRef<HTMLInputElement>();
constructor(props: Props) {
super(props, props.application);
const values = {} as Values;
const prompts = this.props.challenge.prompts;
for (const prompt of prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
const showForgotPasscodeLink = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(this.props.challenge.reason);
this.state = {
prompts,
values,
processing: false,
forgotPasscode: false,
showForgotPasscodeLink,
hasAccount: this.application.hasAccount(),
processingPrompts: [],
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
};
}
componentDidMount(): void {
super.componentDidMount();
this.application.addChallengeObserver(this.props.challenge, {
onValidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = false;
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
this.afterStateChange();
},
onInvalidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = true;
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
this.setState({ processingPrompts: [], processing: false });
} else {
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
}
this.afterStateChange();
},
onComplete: () => {
this.dismiss();
},
onCancel: () => {
this.dismiss();
},
});
}
deinit() {
(this.application as unknown) = undefined;
(this.props.challenge as unknown) = undefined;
super.deinit();
}
reloadProcessingStatus() {
return this.setState({
processing: this.state.processingPrompts.length > 0,
});
}
destroyLocalData = async () => {
if (
await confirmDialog({
text: STRING_SIGN_OUT_CONFIRMATION,
confirmButtonStyle: 'danger',
})
) {
this.dismiss();
this.application.signOut();
}
};
cancel = () => {
if (this.props.challenge.cancelable) {
this.application!.cancelChallenge(this.props.challenge);
}
};
onForgotPasscodeClick = () => {
this.setState({
forgotPasscode: true,
});
};
onTextValueChange = (prompt: ChallengePrompt) => {
const values = this.state.values;
values[prompt.id]!.invalid = false;
this.setState({ values });
};
onNumberValueChange(prompt: ChallengePrompt, value: number) {
const values = this.state.values;
values[prompt.id]!.invalid = false;
values[prompt.id]!.value = value;
this.setState({ values });
}
validate() {
let failed = 0;
for (const prompt of this.state.prompts) {
const value = this.state.values[prompt.id]!;
if (typeof value.value === 'string' && value.value.length === 0) {
this.state.values[prompt.id]!.invalid = true;
failed++;
}
}
return failed === 0;
}
submit = async () => {
if (!this.validate()) {
return;
}
if (this.submitting || this.state.processing) {
return;
}
this.submitting = true;
await this.setState({ processing: true });
const values: ChallengeValue[] = [];
for (const inputValue of Object.values(this.state.values)) {
const rawValue = inputValue!.value;
const value = new ChallengeValue(inputValue!.prompt, rawValue);
values.push(value);
}
const processingPrompts = values.map((v) => v.prompt);
await this.setState({
processingPrompts: processingPrompts,
processing: processingPrompts.length > 0,
});
/**
* Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation
* (crypto key generation)
*/
setTimeout(() => {
if (values.length > 0) {
this.application.submitValuesForChallenge(this.props.challenge, values);
} else {
this.setState({ processing: false });
}
this.submitting = false;
}, 50);
};
afterStateChange() {
this.render();
}
dismiss = () => {
this.props.onDismiss(this.props.challenge);
};
private renderChallengePrompts() {
return this.state.prompts.map((prompt, index) => (
<>
{/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
'sk-a info ' +
(option.valueInSeconds ===
this.state.values[prompt.id]!.value
? 'boxed'
: '')
}
onClick={(event) => {
event.preventDefault();
this.onNumberValueChange(prompt, option.valueInSeconds);
}}
>
{option.label}
</a>
))}
</div>
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
this.submit();
}}
>
<input
className="sk-input contrast"
value={this.state.values[prompt.id]!.value as string | number}
onChange={(event) => {
const value = (event.target as HTMLInputElement).value;
this.state.values[prompt.id]!.value = value;
this.onTextValueChange(prompt);
}}
ref={index === 0 ? this.initialFocusRef : undefined}
placeholder={prompt.title}
type={prompt.secureTextEntry ? 'password' : 'text'}
/>
</form>
</div>
)}
{this.state.values[prompt.id]!.invalid && (
<div className="sk-panel-row centered">
<label className="sk-label danger">
Invalid authentication. Please try again.
</label>
</div>
)}
</>
));
}
render() {
if (!this.state.prompts) {
return <></>;
}
return (
<Dialog
initialFocusRef={this.initialFocusRef}
onDismiss={() => {
if (this.props.challenge.cancelable) {
this.cancel();
}
}}
>
<div className="challenge-modal sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{this.props.challenge.modalTitle}
</div>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-p sk-panel-row centered prompt">
<strong>{this.props.challenge.heading}</strong>
</div>
{this.props.challenge.subheading && (
<div className="sk-p sk-panel-row centered subprompt">
{this.props.challenge.subheading}
</div>
)}
</div>
<div className="sk-panel-section">
{this.renderChallengePrompts()}
</div>
</div>
<div className="sk-panel-footer extra-padding">
<button
className={
'sn-button w-full ' +
(this.state.processing ? 'neutral' : 'info')
}
disabled={this.state.processing}
onClick={() => this.submit()}
>
{this.state.processing ? 'Generating Keys…' : 'Submit'}
</button>
{this.props.challenge.cancelable && (
<>
<div className="sk-panel-row"></div>
<a
className="sk-panel-row sk-a info centered text-sm"
onClick={() => this.cancel()}
>
Cancel
</a>
</>
)}
</div>
{this.state.showForgotPasscodeLink && (
<div className="sk-panel-footer">
{this.state.forgotPasscode ? (
<>
<p className="sk-panel-row sk-p text-center">
{this.state.hasAccount
? 'If you forgot your application passcode, your ' +
'only option is to clear your local data from this ' +
'device and sign back in to your account.'
: 'If you forgot your application passcode, your ' +
'only option is to delete your data.'}
</p>
<a
className="sk-panel-row sk-a danger centered"
onClick={() => {
this.destroyLocalData();
}}
>
Delete Local Data
</a>
</>
) : (
<a
className="sk-panel-row sk-a info centered"
onClick={() => this.onForgotPasscodeClick()}
>
Forgot your passcode?
</a>
)}
<div className="sk-panel-row"></div>
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}
}

View File

@@ -9,8 +9,13 @@ import {
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
import { toDirective } from '@/components/utils'; import {
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted'; import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted';
import { UrlMissing } from '@/components/ComponentView/UrlMissing'; import { UrlMissing } from '@/components/ComponentView/UrlMissing';
@@ -24,7 +29,7 @@ interface IProps {
application: WebApplication; application: WebApplication;
appState: AppState; appState: AppState;
componentViewer: ComponentViewer; componentViewer: ComponentViewer;
requestReload?: (viewer: ComponentViewer) => void; requestReload?: (viewer: ComponentViewer, force?: boolean) => void;
onLoad?: (component: SNComponent) => void; onLoad?: (component: SNComponent) => void;
manualDealloc?: boolean; manualDealloc?: boolean;
} }
@@ -66,20 +71,6 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
openSubscriptionDashboard(application); openSubscriptionDashboard(application);
}, [application]); }, [application]);
useEffect(() => {
const loadTimeout = setTimeout(() => {
handleIframeTakingTooLongToLoad();
}, MaxLoadThreshold);
excessiveLoadingTimeout.current = loadTimeout;
return () => {
excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reloadValidityStatus = useCallback(() => { const reloadValidityStatus = useCallback(() => {
setFeatureStatus(componentViewer.getFeatureStatus()); setFeatureStatus(componentViewer.getFeatureStatus());
if (!componentViewer.lockReadonly) { if (!componentViewer.lockReadonly) {
@@ -128,28 +119,35 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
} else { } else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange); document.addEventListener(VisibilityChangeKey, onVisibilityChange);
} }
}, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]); }, [didAttemptReload, onVisibilityChange, componentViewer, requestReload]);
useEffect(() => { useMemo(() => {
if (!iframeRef.current) { const loadTimeout = setTimeout(() => {
return; handleIframeTakingTooLongToLoad();
} }, MaxLoadThreshold);
const iframe = iframeRef.current as HTMLIFrameElement; excessiveLoadingTimeout.current = loadTimeout;
iframe.onload = () => {
const contentWindow = iframe.contentWindow as Window; return () => {
excessiveLoadingTimeout.current && excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current); clearTimeout(excessiveLoadingTimeout.current);
componentViewer.setWindow(contentWindow);
setTimeout(() => {
setIsLoading(false);
setHasIssueLoading(false);
onLoad?.(component);
}, MSToWaitAfterIframeLoadToAvoidFlicker);
}; };
}, [onLoad, component, componentViewer]); }, [handleIframeTakingTooLongToLoad]);
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current as HTMLIFrameElement;
const contentWindow = iframe.contentWindow as Window;
excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current);
componentViewer.setWindow(contentWindow);
setTimeout(() => {
setIsLoading(false);
setHasIssueLoading(false);
onLoad?.(component);
}, MSToWaitAfterIframeLoadToAvoidFlicker);
}, [componentViewer, onLoad, component, excessiveLoadingTimeout]);
useEffect(() => { useEffect(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver( const removeFeaturesChangedObserver = componentViewer.addEventObserver(
@@ -208,7 +206,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
<IssueOnLoading <IssueOnLoading
componentName={component.name} componentName={component.name}
reloadIframe={() => { reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer); reloadValidityStatus(), requestReload?.(componentViewer, true);
}} }}
/> />
)} )}
@@ -236,6 +234,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
{component.uuid && isComponentValid && ( {component.uuid && isComponentValid && (
<iframe <iframe
ref={iframeRef} ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier} data-component-viewer-id={componentViewer.identifier}
frameBorder={0} frameBorder={0}
src={componentViewer.url || ''} src={componentViewer.url || ''}
@@ -249,10 +248,3 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
); );
} }
); );
export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
onLoad: '=',
componentViewer: '=',
requestReload: '=',
manualDealloc: '=',
});

View File

@@ -6,7 +6,6 @@ import {
} from '@reach/alert-dialog'; } from '@reach/alert-dialog';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings'; import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { toDirective } from './utils';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@@ -22,96 +21,94 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
return <ConfirmSignoutModal {...props} />; return <ConfirmSignoutModal {...props} />;
}); });
const ConfirmSignoutModal = observer(({ application, appState }: Props) => { export const ConfirmSignoutModal = observer(
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false); ({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
const cancelRef = useRef<HTMLButtonElement>(null); const cancelRef = useRef<HTMLButtonElement>(null);
function closeDialog() { function closeDialog() {
appState.accountMenu.setSigningOut(false); appState.accountMenu.setSigningOut(false);
} }
const [localBackupsCount, setLocalBackupsCount] = useState(0); const [localBackupsCount, setLocalBackupsCount] = useState(0);
useEffect(() => { useEffect(() => {
application.bridge.localBackupsCount().then(setLocalBackupsCount); application.bridge.localBackupsCount().then(setLocalBackupsCount);
}, [appState.accountMenu.signingOut, application.bridge]); }, [appState.accountMenu.signingOut, application.bridge]);
return ( return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}> <AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content"> <div className="sk-modal-content">
<div className="sn-component"> <div className="sn-component">
<div className="sk-panel"> <div className="sk-panel">
<div className="sk-panel-content"> <div className="sk-panel-content">
<div className="sk-panel-section"> <div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize"> <AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
End your session? End your session?
</AlertDialogLabel> </AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row"> <AlertDialogDescription className="sk-panel-row">
<p className="color-foreground"> <p className="color-foreground">
{STRING_SIGN_OUT_CONFIRMATION} {STRING_SIGN_OUT_CONFIRMATION}
</p> </p>
</AlertDialogDescription> </AlertDialogDescription>
{localBackupsCount > 0 && ( {localBackupsCount > 0 && (
<div className="flex"> <div className="flex">
<div className="sk-panel-row"></div> <div className="sk-panel-row"></div>
<label className="flex items-center"> <label className="flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={deleteLocalBackups} checked={deleteLocalBackups}
onChange={(event) => { onChange={(event) => {
setDeleteLocalBackups( setDeleteLocalBackups(
(event.target as HTMLInputElement).checked (event.target as HTMLInputElement).checked
); );
}}
/>
<span className="ml-2">
Delete {localBackupsCount} local backup file
{localBackupsCount > 1 ? 's' : ''}
</span>
</label>
<button
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
onClick={() => {
application.bridge.viewlocalBackups();
}} }}
/> >
<span className="ml-2"> View backup files
Delete {localBackupsCount} local backup file </button>
{localBackupsCount > 1 ? 's' : ''} </div>
</span> )}
</label> <div className="flex my-1 mt-4">
<button <button
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer" className="sn-button small neutral"
ref={cancelRef}
onClick={closeDialog}
>
Cancel
</button>
<button
className="sn-button small danger ml-2"
onClick={() => { onClick={() => {
application.bridge.viewlocalBackups(); if (deleteLocalBackups) {
application.signOutAndDeleteLocalBackups();
} else {
application.signOut();
}
closeDialog();
}} }}
> >
View backup files {application.hasAccount()
? 'Sign Out'
: 'Clear Session Data'}
</button> </button>
</div> </div>
)}
<div className="flex my-1 mt-4">
<button
className="sn-button small neutral"
ref={cancelRef}
onClick={closeDialog}
>
Cancel
</button>
<button
className="sn-button small danger ml-2"
onClick={() => {
if (deleteLocalBackups) {
application.signOutAndDeleteLocalBackups();
} else {
application.signOut();
}
closeDialog();
}}
>
{application.hasAccount()
? 'Sign Out'
: 'Clear Session Data'}
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </AlertDialog>
</AlertDialog> );
); }
});
export const ConfirmSignoutDirective = toDirective<Props>(
ConfirmSignoutContainer
); );

View File

@@ -8,7 +8,8 @@ import {
} from '@reach/listbox'; } from '@reach/listbox';
import VisuallyHidden from '@reach/visually-hidden'; import VisuallyHidden from '@reach/visually-hidden';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { IconType, Icon } from './Icon'; import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
export type DropdownItem = { export type DropdownItem = {
icon?: IconType; icon?: IconType;

View File

@@ -0,0 +1,560 @@
import { WebAppEvent, WebApplication } from '@/ui_models/application';
import { ApplicationGroup } from '@/ui_models/application_group';
import { PureComponent } from './Abstract/PureComponent';
import { preventRefreshing } from '@/utils';
import {
ApplicationEvent,
ContentType,
SNTheme,
CollectionSort,
ApplicationDescriptor,
} from '@standardnotes/snjs';
import {
STRING_NEW_UPDATE_READY,
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/strings';
import { alertDialog, confirmDialog } from '@/services/alertService';
import { AccountMenu, AccountMenuPane } from '@/components/AccountMenu';
import { AppStateEvent, EventSource } from '@/ui_models/app_state';
import { Icon } from './Icon';
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
import { SyncResolutionMenu } from './SyncResolutionMenu';
import { Fragment, render } from 'preact';
import { AccountSwitcher } from './AccountSwitcher';
/**
* Disable before production release.
* Anyone who used the beta will still have access to
* the account switcher in production via local storage flag
*/
const ACCOUNT_SWITCHER_ENABLED = false;
const ACCOUNT_SWITCHER_FEATURE_KEY = 'account_switcher';
type Props = {
application: WebApplication;
applicationGroup: ApplicationGroup;
};
type State = {
outOfSync: boolean;
dataUpgradeAvailable: boolean;
hasPasscode: boolean;
descriptors: ApplicationDescriptor[];
hasAccountSwitcher: boolean;
showBetaWarning: boolean;
showSyncResolution: boolean;
newUpdateAvailable: boolean;
showAccountMenu: boolean;
showQuickSettingsMenu: boolean;
offline: boolean;
hasError: boolean;
arbitraryStatusMessage?: string;
};
export class Footer extends PureComponent<Props, State> {
public user?: unknown;
private didCheckForOffline = false;
private completedInitialSync = false;
private showingDownloadStatus = false;
private webEventListenerDestroyer: () => void;
constructor(props: Props) {
super(props, props.application);
this.state = {
hasError: false,
offline: true,
outOfSync: false,
dataUpgradeAvailable: false,
hasPasscode: false,
descriptors: props.applicationGroup.getDescriptors(),
hasAccountSwitcher: false,
showBetaWarning: false,
showSyncResolution: false,
newUpdateAvailable: false,
showAccountMenu: false,
showQuickSettingsMenu: false,
};
this.webEventListenerDestroyer = props.application.addWebEventObserver(
(event) => {
if (event === WebAppEvent.NewUpdateAvailable) {
this.onNewUpdateAvailable();
}
}
);
}
deinit() {
this.webEventListenerDestroyer();
(this.webEventListenerDestroyer as unknown) = undefined;
super.deinit();
}
componentDidMount(): void {
super.componentDidMount();
this.application.getStatusManager().onStatusChange((message) => {
this.setState({
arbitraryStatusMessage: message,
});
});
this.loadAccountSwitcherState();
this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning;
this.setState({
showBetaWarning: showBetaWarning,
showAccountMenu: this.appState.accountMenu.show,
showQuickSettingsMenu: this.appState.quickSettingsMenu.open,
});
});
}
loadAccountSwitcherState() {
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
/** Enable permanently for this user so they don't lose the feature after its disabled */
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
}
const hasAccountSwitcher = stringValue
? JSON.parse(stringValue)
: ACCOUNT_SWITCHER_ENABLED;
this.setState({ hasAccountSwitcher });
}
reloadUpgradeStatus() {
this.application.checkForSecurityUpdate().then((available) => {
this.setState({
dataUpgradeAvailable: available,
});
});
}
async onAppLaunch() {
super.onAppLaunch();
this.reloadPasscodeStatus();
this.reloadUser();
this.reloadUpgradeStatus();
this.updateOfflineStatus();
this.findErrors();
this.streamItems();
}
reloadUser() {
this.user = this.application.getUser();
}
async reloadPasscodeStatus() {
const hasPasscode = this.application.hasPasscode();
this.setState({
hasPasscode: hasPasscode,
});
}
/** @override */
onAppStateEvent(eventName: AppStateEvent, data: any) {
const statusService = this.application.getStatusManager();
switch (eventName) {
case AppStateEvent.EditorFocused:
if (data.eventSource === EventSource.UserInteraction) {
this.closeAccountMenu();
}
break;
case AppStateEvent.BeganBackupDownload:
statusService.setMessage('Saving local backup…');
break;
case AppStateEvent.EndedBackupDownload: {
const successMessage = 'Successfully saved backup.';
const errorMessage = 'Unable to save local backup.';
statusService.setMessage(data.success ? successMessage : errorMessage);
const twoSeconds = 2000;
setTimeout(() => {
if (
statusService.message === successMessage ||
statusService.message === errorMessage
) {
statusService.setMessage('');
}
}, twoSeconds);
break;
}
}
}
/** @override */
async onAppKeyChange() {
super.onAppKeyChange();
this.reloadPasscodeStatus();
}
/** @override */
onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.KeyStatusChanged:
this.reloadUpgradeStatus();
break;
case ApplicationEvent.EnteredOutOfSync:
this.setState({
outOfSync: true,
});
break;
case ApplicationEvent.ExitedOutOfSync:
this.setState({
outOfSync: false,
});
break;
case ApplicationEvent.CompletedFullSync:
if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage('');
this.completedInitialSync = true;
}
if (!this.didCheckForOffline) {
this.didCheckForOffline = true;
if (this.state.offline && this.application.getNoteCount() === 0) {
this.appState.accountMenu.setShow(true);
}
}
this.findErrors();
this.updateOfflineStatus();
break;
case ApplicationEvent.SyncStatusChanged:
this.updateSyncStatus();
break;
case ApplicationEvent.FailedSync:
this.updateSyncStatus();
this.findErrors();
this.updateOfflineStatus();
break;
case ApplicationEvent.LocalDataIncrementalLoad:
case ApplicationEvent.LocalDataLoaded:
this.updateLocalDataStatus();
break;
case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut:
this.reloadUser();
break;
case ApplicationEvent.WillSync:
if (!this.completedInitialSync) {
this.application.getStatusManager().setMessage('Syncing…');
}
break;
}
}
streamItems() {
this.application.setDisplayOptions(
ContentType.Theme,
CollectionSort.Title,
'asc',
(theme: SNTheme) => {
return !theme.errorDecrypting;
}
);
}
updateSyncStatus() {
const statusManager = this.application.getStatusManager();
const syncStatus = this.application.getSyncStatus();
const stats = syncStatus.getStats();
if (syncStatus.hasError()) {
statusManager.setMessage('Unable to Sync');
} else if (stats.downloadCount > 20) {
const text = `Downloading ${stats.downloadCount} items. Keep app open.`;
statusManager.setMessage(text);
this.showingDownloadStatus = true;
} else if (this.showingDownloadStatus) {
this.showingDownloadStatus = false;
statusManager.setMessage('Download Complete.');
setTimeout(() => {
statusManager.setMessage('');
}, 2000);
} else if (stats.uploadTotalCount > 20) {
const completionPercentage =
stats.uploadCompletionCount === 0
? 0
: stats.uploadCompletionCount / stats.uploadTotalCount;
const stringPercentage = completionPercentage.toLocaleString(undefined, {
style: 'percent',
});
statusManager.setMessage(
`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`
);
} else {
statusManager.setMessage('');
}
}
updateLocalDataStatus() {
const statusManager = this.application.getStatusManager();
const syncStatus = this.application.getSyncStatus();
const stats = syncStatus.getStats();
const encryption = this.application.isEncryptionAvailable();
if (stats.localDataDone) {
statusManager.setMessage('');
return;
}
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`;
const loadingStatus = encryption
? `Decrypting ${notesString}`
: `Loading ${notesString}`;
statusManager.setMessage(loadingStatus);
}
updateOfflineStatus() {
this.setState({
offline: this.application.noAccount(),
});
}
findErrors() {
this.setState({
hasError: this.application.getSyncStatus().hasError(),
});
}
securityUpdateClickHandler = async () => {
if (
await confirmDialog({
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
})
) {
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
await this.application.upgradeProtocolVersion();
});
}
};
accountSwitcherClickHandler = () => {
render(
<AccountSwitcher
application={this.application}
mainApplicationGroup={this.props.applicationGroup}
/>,
document.body.appendChild(document.createElement('div'))
);
};
accountMenuClickHandler = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
this.appState.accountMenu.toggleShow();
};
quickSettingsClickHandler = () => {
this.appState.accountMenu.closeAccountMenu();
this.appState.quickSettingsMenu.toggle();
};
syncResolutionClickHandler = () => {
this.setState({
showSyncResolution: !this.state.showSyncResolution,
});
};
closeAccountMenu = () => {
this.appState.accountMenu.setShow(false);
this.appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu);
};
lockClickHandler = () => {
this.application.lock();
};
onNewUpdateAvailable = () => {
this.setState({
newUpdateAvailable: true,
});
};
newUpdateClickHandler = () => {
this.setState({
newUpdateAvailable: false,
});
this.application.alertService.alert(STRING_NEW_UPDATE_READY);
};
betaMessageClickHandler = () => {
alertDialog({
title: 'You are using a beta version of the app',
text:
'If you wish to go back to a stable version, make sure to sign out ' +
'of this beta app first.',
});
};
clickOutsideAccountMenu = () => {
this.appState.accountMenu.closeAccountMenu();
};
clickOutsideQuickSettingsMenu = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
};
render() {
return (
<div className="sn-component">
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
<div className="left">
<div className="sk-app-bar-item ml-0">
<div
onClick={this.accountMenuClickHandler}
className={
(this.state.showAccountMenu ? 'bg-border' : '') +
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
}
>
<div
className={
this.state.hasError
? 'danger'
: (this.user ? 'info' : 'neutral') + ' w-5 h-5'
}
>
<Icon
type="account-circle"
className="hover:color-info w-5 h-5 max-h-5"
/>
</div>
</div>
{this.state.showAccountMenu && (
<AccountMenu
onClickOutside={this.clickOutsideAccountMenu}
appState={this.appState}
application={this.application}
/>
)}
</div>
<div className="sk-app-bar-item ml-0-important">
<div
onClick={this.quickSettingsClickHandler}
className="w-8 h-full flex items-center justify-center cursor-pointer"
>
<div className="h-5">
<Icon
type="tune"
className={
(this.state.showQuickSettingsMenu ? 'color-info' : '') +
' rounded hover:color-info'
}
/>
</div>
</div>
{this.state.showQuickSettingsMenu && (
<QuickSettingsMenu
onClickOutside={this.clickOutsideQuickSettingsMenu}
appState={this.appState}
application={this.application}
/>
)}
</div>
{this.state.showBetaWarning && (
<Fragment>
<div className="sk-app-bar-item border" />
<div className="sk-app-bar-item">
<a
onClick={this.betaMessageClickHandler}
className="no-decoration sk-label title"
>
You are using a beta version of the app
</a>
</div>
</Fragment>
)}
</div>
<div className="center">
{this.state.arbitraryStatusMessage && (
<div className="sk-app-bar-item">
<div className="sk-app-bar-item-column">
<span className="neutral sk-label">
{this.state.arbitraryStatusMessage}
</span>
</div>
</div>
)}
</div>
<div className="right">
{this.state.dataUpgradeAvailable && (
<div
onClick={this.securityUpdateClickHandler}
className="sk-app-bar-item"
>
<span className="success sk-label">
Encryption upgrade available.
</span>
</div>
)}
{this.state.newUpdateAvailable && (
<div
onClick={this.newUpdateClickHandler}
className="sk-app-bar-item"
>
<span className="info sk-label">New update available.</span>
</div>
)}
{(this.state.outOfSync || this.state.showSyncResolution) && (
<div className="sk-app-bar-item">
{this.state.outOfSync && (
<div
onClick={this.syncResolutionClickHandler}
className="sk-label warning"
>
Potentially Out of Sync
</div>
)}
{this.state.showSyncResolution && (
<SyncResolutionMenu
close={this.syncResolutionClickHandler}
application={this.application}
/>
)}
</div>
)}
{this.state.offline && (
<div className="sk-app-bar-item">
<div className="sk-label">Offline</div>
</div>
)}
{this.state.hasAccountSwitcher && (
<Fragment>
<div className="sk-app-bar-item border" />
<div
onClick={this.accountSwitcherClickHandler}
className="sk-app-bar-item"
>
<div
className={
(this.state.hasPasscode ? 'alone' : '') +
' flex items-center'
}
>
<Icon type="user-switch" />
</div>
</div>
</Fragment>
)}
{this.state.hasPasscode && (
<Fragment>
<div className="sk-app-bar-item border" />
<div
id="lock-item"
onClick={this.lockClickHandler}
title="Locks application and wipes unencrypted data from memory."
className="sk-app-bar-item"
>
<div className="sk-label">
<i id="footer-lock-icon" className="icon ion-locked" />
</div>
</div>
</Fragment>
)}
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,309 @@
import { WebApplication } from '@/ui_models/application';
import { NoteHistoryEntry, PayloadContent, SNNote } from '@standardnotes/snjs';
import { RevisionListEntry } from '@standardnotes/snjs';
import { alertDialog, confirmDialog } from '@/services/alertService';
import { PureComponent } from './Abstract/PureComponent';
import { MenuRow } from './MenuRow';
import { render } from 'preact';
import { RevisionPreviewModal } from './RevisionPreviewModal';
type HistoryState = {
sessionHistory?: NoteHistoryEntry[];
remoteHistory?: RevisionListEntry[];
fetchingRemoteHistory: boolean;
autoOptimize: boolean;
diskEnabled: boolean;
showRemoteOptions?: boolean;
showSessionOptions?: boolean;
};
type Props = {
application: WebApplication;
item: SNNote;
};
export class HistoryMenu extends PureComponent<Props, HistoryState> {
constructor(props: Props) {
super(props, props.application);
this.state = {
fetchingRemoteHistory: false,
autoOptimize: this.props.application.historyManager.autoOptimize,
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
sessionHistory:
this.props.application.historyManager.sessionHistoryForItem(
this.props.item
) as NoteHistoryEntry[],
};
}
reloadState() {
this.setState({
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
autoOptimize: this.props.application.historyManager.autoOptimize,
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
sessionHistory:
this.props.application.historyManager.sessionHistoryForItem(
this.props.item
) as NoteHistoryEntry[],
});
}
componentDidMount(): void {
super.componentDidMount();
this.fetchRemoteHistory();
}
fetchRemoteHistory = async () => {
this.setState({ fetchingRemoteHistory: true });
try {
const remoteHistory =
await this.props.application.historyManager.remoteHistoryForItem(
this.props.item
);
this.setState({ remoteHistory });
} finally {
this.setState({ fetchingRemoteHistory: false });
}
};
private presentRevisionPreviewModal = (
uuid: string,
content: PayloadContent,
title: string
) => {
render(
<RevisionPreviewModal
application={this.application}
uuid={uuid}
content={content}
title={title}
/>,
document.body.appendChild(document.createElement('div'))
);
};
openSessionRevision = (revision: NoteHistoryEntry) => {
this.presentRevisionPreviewModal(
revision.payload.uuid,
revision.payload.content,
revision.previewTitle()
);
};
openRemoteRevision = async (revision: RevisionListEntry) => {
this.setState({ fetchingRemoteHistory: true });
const remoteRevision =
await this.props.application.historyManager.fetchRemoteRevision(
this.props.item.uuid,
revision
);
this.setState({ fetchingRemoteHistory: false });
if (!remoteRevision) {
alertDialog({
text: 'The remote revision could not be loaded. Please try again later.',
});
return;
}
this.presentRevisionPreviewModal(
remoteRevision.payload.uuid,
remoteRevision.payload.content,
this.previewRemoteHistoryTitle(revision)
);
};
classForSessionRevision = (revision: NoteHistoryEntry) => {
const vector = revision.operationVector();
if (vector === 0) {
return 'default';
} else if (vector === 1) {
return 'success';
} else if (vector === -1) {
return 'danger';
}
};
clearItemSessionHistory = async () => {
if (
await confirmDialog({
text: 'Are you sure you want to delete the local session history for this note?',
confirmButtonStyle: 'danger',
})
) {
this.props.application.historyManager.clearHistoryForItem(
this.props.item
);
this.reloadState();
}
};
clearAllSessionHistory = async () => {
if (
await confirmDialog({
text: 'Are you sure you want to delete the local session history for all notes?',
confirmButtonStyle: 'danger',
})
) {
await this.props.application.historyManager.clearAllHistory();
this.reloadState();
}
};
toggleSessionHistoryDiskSaving = async () => {
if (!this.state.diskEnabled) {
if (
await confirmDialog({
text:
'Are you sure you want to save history to disk? This will decrease general ' +
'performance, especially as you type. You are advised to disable this feature ' +
'if you experience any lagging.',
confirmButtonStyle: 'danger',
})
) {
this.props.application.historyManager.toggleDiskSaving();
}
} else {
this.props.application.historyManager.toggleDiskSaving();
}
this.reloadState();
};
toggleSessionHistoryAutoOptimize = () => {
this.props.application.historyManager.toggleAutoOptimize();
this.reloadState();
};
previewRemoteHistoryTitle(revision: RevisionListEntry) {
return new Date(revision.created_at).toLocaleString();
}
toggleShowRemoteOptions = ($event: Event) => {
$event.stopPropagation();
this.setState({
showRemoteOptions: !this.state.showRemoteOptions,
});
};
toggleShowSessionOptions = ($event: Event) => {
$event.stopPropagation();
this.setState({
showSessionOptions: !this.state.showSessionOptions,
});
};
render() {
return (
<div id="history-menu" className="sn-component">
<div className="sk-menu-panel dropdown-menu">
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-header-title">
Session
<div className="sk-menu-panel-header-subtitle">
{this.state.sessionHistory?.length || 'No'} revisions
</div>
</div>
<a
className="sk-a info sk-h5"
onClick={this.toggleShowSessionOptions}
>
Options
</a>
</div>
{this.state.showSessionOptions && (
<div>
<MenuRow
action={this.clearItemSessionHistory}
label="Clear note local history"
/>
<MenuRow
action={this.clearAllSessionHistory}
label="Clear all local history"
/>
<MenuRow
action={this.toggleSessionHistoryAutoOptimize}
label={
(this.state.autoOptimize ? 'Disable' : 'Enable') +
' auto cleanup'
}
>
<div className="sk-sublabel">
Automatically cleans up small revisions to conserve space.
</div>
</MenuRow>
<MenuRow
action={this.toggleSessionHistoryDiskSaving}
label={
(this.state.diskEnabled ? 'Disable' : 'Enable') +
' saving history to disk'
}
>
<div className="sk-sublabel">
Saving to disk is not recommended. Decreases performance and
increases app loading time and memory footprint.
</div>
</MenuRow>
</div>
)}
{this.state.sessionHistory?.map((revision, index) => {
return (
<MenuRow
key={index}
action={() => this.openSessionRevision(revision)}
label={revision.previewTitle()}
>
<div
className={
this.classForSessionRevision(revision) +
' sk-sublabel opaque'
}
>
{revision.previewSubTitle()}
</div>
</MenuRow>
);
})}
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-header-title">
Remote
<div className="sk-menu-panel-header-subtitle">
{this.state.remoteHistory?.length || 'No'} revisions
</div>
</div>
<a
onClick={this.toggleShowRemoteOptions}
className="sk-a info sk-h5"
>
Options
</a>
</div>
{this.state.showRemoteOptions && (
<MenuRow
action={this.fetchRemoteHistory}
label="Refresh"
disabled={this.state.fetchingRemoteHistory}
spinnerClass={
this.state.fetchingRemoteHistory ? 'info' : undefined
}
>
<div className="sk-sublabel">Fetch history from server.</div>
</MenuRow>
)}
{this.state.remoteHistory?.map((revision, index) => {
return (
<MenuRow
key={index}
action={() => this.openRemoteRevision(revision)}
label={this.previewRemoteHistoryTitle(revision)}
/>
);
})}
</div>
</div>
);
}
}

View File

@@ -1,141 +1,143 @@
import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg';
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';
import HashtagIcon from '../../icons/ic-hashtag.svg';
import ChevronRightIcon from '../../icons/ic-chevron-right.svg';
import RestoreIcon from '../../icons/ic-restore.svg';
import CloseIcon from '../../icons/ic-close.svg';
import PasswordIcon from '../../icons/ic-textbox-password.svg';
import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
import MoreIcon from '../../icons/ic-more.svg';
import TuneIcon from '../../icons/ic-tune.svg';
import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg';
import MenuCloseIcon from '../../icons/ic-menu-close.svg';
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'; import AccessibilityIcon from '../../icons/ic-accessibility.svg';
import AccountCircleIcon from '../../icons/ic-account-circle.svg';
import AddIcon from '../../icons/ic-add.svg'; import AddIcon from '../../icons/ic-add.svg';
import HelpIcon from '../../icons/ic-help.svg'; import ArchiveIcon from '../../icons/ic-archive.svg';
import KeyboardIcon from '../../icons/ic-keyboard.svg'; import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
import ListBulleted from '../../icons/ic-list-bulleted.svg'; import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import ListedIcon from '../../icons/ic-listed.svg'; import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import SecurityIcon from '../../icons/ic-security.svg'; import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
import SettingsIcon from '../../icons/ic-settings.svg'; import CheckBoldIcon from '../../icons/ic-check-bold.svg';
import StarIcon from '../../icons/ic-star.svg'; import CheckCircleIcon from '../../icons/ic-check-circle.svg';
import ThemesIcon from '../../icons/ic-themes.svg'; import CheckIcon from '../../icons/ic-check.svg';
import UserIcon from '../../icons/ic-user.svg'; import ChevronDownIcon from '../../icons/ic-chevron-down.svg';
import ChevronRightIcon from '../../icons/ic-chevron-right.svg';
import CloseIcon from '../../icons/ic-close.svg';
import CloudOffIcon from '../../icons/ic-cloud-off.svg';
import CodeIcon from '../../icons/ic-code.svg';
import CopyIcon from '../../icons/ic-copy.svg'; import CopyIcon from '../../icons/ic-copy.svg';
import DownloadIcon from '../../icons/ic-download.svg'; import DownloadIcon from '../../icons/ic-download.svg';
import InfoIcon from '../../icons/ic-info.svg'; import EditorIcon from '../../icons/ic-editor.svg';
import CheckIcon from '../../icons/ic-check.svg';
import CheckBoldIcon from '../../icons/ic-check-bold.svg';
import AccountCircleIcon from '../../icons/ic-account-circle.svg';
import CloudOffIcon from '../../icons/ic-cloud-off.svg';
import SignInIcon from '../../icons/ic-signin.svg';
import SignOutIcon from '../../icons/ic-signout.svg';
import CheckCircleIcon from '../../icons/ic-check-circle.svg';
import SyncIcon from '../../icons/ic-sync.svg';
import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
import ChevronDownIcon from '../../icons/ic-chevron-down.svg';
import EmailIcon from '../../icons/ic-email.svg'; import EmailIcon from '../../icons/ic-email.svg';
import ServerIcon from '../../icons/ic-server.svg';
import EyeIcon from '../../icons/ic-eye.svg'; import EyeIcon from '../../icons/ic-eye.svg';
import EyeOffIcon from '../../icons/ic-eye-off.svg'; import EyeOffIcon from '../../icons/ic-eye-off.svg';
import LockIcon from '../../icons/ic-lock.svg'; import HashtagIcon from '../../icons/ic-hashtag.svg';
import LockFilledIcon from '../../icons/ic-lock-filled.svg'; import HelpIcon from '../../icons/ic-help.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg'; import InfoIcon from '../../icons/ic-info.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg'; import KeyboardIcon from '../../icons/ic-keyboard.svg';
import WindowIcon from '../../icons/ic-window.svg';
import LinkOffIcon from '../../icons/ic-link-off.svg'; import LinkOffIcon from '../../icons/ic-link-off.svg';
import ListBulleted from '../../icons/ic-list-bulleted.svg';
import ListedIcon from '../../icons/ic-listed.svg';
import LockFilledIcon from '../../icons/ic-lock-filled.svg';
import LockIcon from '../../icons/ic-lock.svg';
import MarkdownIcon from '../../icons/ic-markdown.svg';
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg'; import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
import MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.svg';
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg'; import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
import MenuCloseIcon from '../../icons/ic-menu-close.svg';
import MoreIcon from '../../icons/ic-more.svg';
import NotesIcon from '../../icons/ic-notes.svg';
import PasswordIcon from '../../icons/ic-textbox-password.svg';
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
import PinFilledIcon from '../../icons/ic-pin-filled.svg';
import PinIcon from '../../icons/ic-pin.svg';
import PlainTextIcon from '../../icons/ic-text-paragraph.svg';
import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg';
import RestoreIcon from '../../icons/ic-restore.svg';
import RichTextIcon from '../../icons/ic-text-rich.svg';
import SecurityIcon from '../../icons/ic-security.svg';
import ServerIcon from '../../icons/ic-server.svg';
import SettingsIcon from '../../icons/ic-settings.svg';
import SignInIcon from '../../icons/ic-signin.svg';
import SignOutIcon from '../../icons/ic-signout.svg';
import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg';
import StarIcon from '../../icons/ic-star.svg';
import SyncIcon from '../../icons/ic-sync.svg';
import TasksIcon from '../../icons/ic-tasks.svg';
import ThemesIcon from '../../icons/ic-themes.svg';
import TrashFilledIcon from '../../icons/ic-trash-filled.svg';
import TrashIcon from '../../icons/ic-trash.svg';
import TrashSweepIcon from '../../icons/ic-trash-sweep.svg';
import TuneIcon from '../../icons/ic-tune.svg';
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
import UnpinIcon from '../../icons/ic-pin-off.svg';
import UserIcon from '../../icons/ic-user.svg';
import UserSwitch from '../../icons/ic-user-switch.svg';
import WarningIcon from '../../icons/ic-warning.svg';
import WindowIcon from '../../icons/ic-window.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
const ICONS = { const ICONS = {
'menu-arrow-down-alt': MenuArrowDownAlt, 'account-circle': AccountCircleIcon,
'menu-arrow-right': MenuArrowRight,
notes: NotesIcon,
'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon,
'lock-filled': LockFilledIcon,
eye: EyeIcon,
'eye-off': EyeOffIcon,
server: ServerIcon,
email: EmailIcon,
'chevron-down': ChevronDownIcon,
'arrow-left': ArrowLeftIcon, 'arrow-left': ArrowLeftIcon,
sync: SyncIcon, 'arrows-sort-down': ArrowsSortDownIcon,
'arrows-sort-up': ArrowsSortUpIcon,
'check-bold': CheckBoldIcon,
'check-circle': CheckCircleIcon, 'check-circle': CheckCircleIcon,
signIn: SignInIcon, 'chevron-down': ChevronDownIcon,
signOut: SignOutIcon,
'cloud-off': CloudOffIcon,
'pencil-off': PencilOffIcon,
'plain-text': PlainTextIcon,
'rich-text': RichTextIcon,
code: CodeIcon,
markdown: MarkdownIcon,
authenticator: AuthenticatorIcon,
spreadsheets: SpreadsheetsIcon,
tasks: TasksIcon,
trash: TrashIcon,
'trash-filled': TrashFilledIcon,
pin: PinIcon,
'pin-filled': PinFilledIcon,
unpin: UnpinIcon,
archive: ArchiveIcon,
unarchive: UnarchiveIcon,
hashtag: HashtagIcon,
'chevron-right': ChevronRightIcon, 'chevron-right': ChevronRightIcon,
restore: RestoreIcon, 'cloud-off': CloudOffIcon,
close: CloseIcon, 'eye-off': EyeOffIcon,
password: PasswordIcon, 'link-off': LinkOffIcon,
'list-bulleted': ListBulleted,
'lock-filled': LockFilledIcon,
'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-down': MenuArrowDownIcon,
'menu-arrow-right': MenuArrowRight,
'menu-close': MenuCloseIcon,
'pencil-off': PencilOffIcon,
'pin-filled': PinFilledIcon,
'plain-text': PlainTextIcon,
'premium-feature': PremiumFeatureIcon,
'rich-text': RichTextIcon,
'trash-filled': TrashFilledIcon,
'trash-sweep': TrashSweepIcon, 'trash-sweep': TrashSweepIcon,
more: MoreIcon, 'user-switch': UserSwitch,
tune: TuneIcon,
accessibility: AccessibilityIcon, accessibility: AccessibilityIcon,
add: AddIcon, add: AddIcon,
help: HelpIcon, archive: ArchiveIcon,
keyboard: KeyboardIcon, authenticator: AuthenticatorIcon,
spellcheck: NotesIcon, check: CheckIcon,
'list-bulleted': ListBulleted, close: CloseIcon,
'link-off': LinkOffIcon, code: CodeIcon,
listed: ListedIcon,
security: SecurityIcon,
settings: SettingsIcon,
star: StarIcon,
themes: ThemesIcon,
user: UserIcon,
copy: CopyIcon, copy: CopyIcon,
download: DownloadIcon, download: DownloadIcon,
editor: EditorIcon,
email: EmailIcon,
eye: EyeIcon,
hashtag: HashtagIcon,
help: HelpIcon,
info: InfoIcon, info: InfoIcon,
check: CheckIcon, keyboard: KeyboardIcon,
'check-bold': CheckBoldIcon, listed: ListedIcon,
'account-circle': AccountCircleIcon, lock: LockIcon,
'menu-arrow-down': MenuArrowDownIcon, markdown: MarkdownIcon,
'menu-close': MenuCloseIcon, more: MoreIcon,
notes: NotesIcon,
password: PasswordIcon,
pin: PinIcon,
restore: RestoreIcon,
security: SecurityIcon,
server: ServerIcon,
settings: SettingsIcon,
signIn: SignInIcon,
signOut: SignOutIcon,
spellcheck: NotesIcon,
spreadsheets: SpreadsheetsIcon,
star: StarIcon,
sync: SyncIcon,
tasks: TasksIcon,
themes: ThemesIcon,
trash: TrashIcon,
tune: TuneIcon,
unarchive: UnarchiveIcon,
unpin: UnpinIcon,
user: UserIcon,
warning: WarningIcon,
window: WindowIcon, window: WindowIcon,
'premium-feature': PremiumFeatureIcon,
}; };
export type IconType = keyof typeof ICONS;
type Props = { type Props = {
type: IconType; type: IconType;
className?: string; className?: string;
@@ -147,7 +149,10 @@ export const Icon: FunctionalComponent<Props> = ({
className = '', className = '',
ariaLabel, ariaLabel,
}) => { }) => {
const IconComponent = ICONS[type]; const IconComponent = ICONS[type as keyof typeof ICONS];
if (!IconComponent) {
return null;
}
return ( return (
<IconComponent <IconComponent
className={`sn-icon ${className}`} className={`sn-icon ${className}`}
@@ -156,8 +161,3 @@ export const Icon: FunctionalComponent<Props> = ({
/> />
); );
}; };
export const IconDirective = toDirective<Props>(Icon, {
type: '@',
className: '@',
});

View File

@@ -1,5 +1,6 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { Icon, IconType } from './Icon'; import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
interface Props { interface Props {
/** /**

View File

@@ -1,8 +1,9 @@
import { FunctionComponent, Ref } from 'preact'; import { FunctionComponent, Ref } from 'preact';
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import { Icon, IconType } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { IconType } from '@standardnotes/snjs';
type ToggleProps = { type ToggleProps = {
toggleOnIcon: IconType; toggleOnIcon: IconType;

View File

@@ -0,0 +1,115 @@
import { Component } from 'preact';
export type MenuRowProps = {
action?: () => void;
buttonAction?: () => void;
buttonClass?: string;
buttonText?: string;
desc?: string;
disabled?: boolean;
circle?: string;
circleAlign?: string;
faded?: boolean;
hasButton?: boolean;
label: string;
spinnerClass?: string;
stylekitClass?: string;
subRows?: MenuRowProps[];
subtitle?: string;
};
type Props = MenuRowProps;
export class MenuRow extends Component<Props> {
onClick = ($event: Event) => {
if (this.props.disabled || !this.props.action) {
return;
}
$event.stopPropagation();
this.props.action();
};
clickAccessoryButton = ($event: Event) => {
if (this.props.disabled) {
return;
}
$event.stopPropagation();
this.props.buttonAction?.();
};
render() {
return (
<div
title={this.props.desc}
onClick={this.onClick}
className="sk-menu-panel-row row"
>
<div className="sk-menu-panel-column">
<div className="left">
{this.props.circle &&
(!this.props.circleAlign || this.props.circleAlign == 'left') && (
<div className="sk-menu-panel-column">
<div className={this.props.circle + ' sk-circle small'} />
</div>
)}
<div
className={
(this.props.faded || this.props.disabled ? 'faded' : '') +
' sk-menu-panel-column'
}
>
<div className={this.props.stylekitClass + ' sk-label'}>
{this.props.label}
</div>
{this.props.subtitle && (
<div className="sk-sublabel">{this.props.subtitle}</div>
)}
{this.props.children}
</div>
</div>
{this.props.subRows && this.props.subRows.length > 0 && (
<div className="sk-menu-panel-subrows">
{this.props.subRows.map((row) => {
return (
<MenuRow
action={row.action}
label={row.label}
spinnerClass={row.spinnerClass}
subtitle={row.subtitle}
/>
);
})}
</div>
)}
</div>
{this.props.circle && this.props.circleAlign == 'right' && (
<div className="sk-menu-panel-column">
<div className={this.props.circle + ' sk-circle small'} />
</div>
)}
{this.props.hasButton && (
<div className="sk-menu-panel-column">
<button
className={this.props.buttonClass + ' sn-button small'}
onClick={this.props.buttonAction!}
>
{this.props.buttonText}
</button>
</div>
)}
{this.props.spinnerClass && (
<div className="sk-menu-panel-column">
<div className={this.props.spinnerClass + ' sk-spinner small'} />
</div>
)}
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
import NotesIcon from '../../icons/il-notes.svg'; import NotesIcon from '../../icons/il-notes.svg';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { NotesOptionsPanel } from './NotesOptionsPanel'; import { NotesOptionsPanel } from './NotesOptionsPanel';
@@ -11,31 +10,31 @@ type Props = {
appState: AppState; appState: AppState;
}; };
const MultipleSelectedNotes = observer(({ application, appState }: Props) => { export const MultipleSelectedNotes = observer(
const count = appState.notes.selectedNotesCount; ({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount;
return ( return (
<div className="flex flex-col h-full items-center"> <div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full"> <div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1> <h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex"> <div className="flex">
<div className="mr-3"> <div className="mr-3">
<PinNoteButton appState={appState} /> <PinNoteButton appState={appState} />
</div>
<NotesOptionsPanel application={application} appState={appState} />
</div> </div>
<NotesOptionsPanel application={application} appState={appState} /> </div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<NotesIcon className="block" />
<h2 className="text-lg m-0 text-center mt-4">
{count} selected notes
</h2>
<p className="text-sm mt-2 text-center max-w-60">
Actions will be performed on all selected notes.
</p>
</div> </div>
</div> </div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md"> );
<NotesIcon className="block" /> }
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
<p className="text-sm mt-2 text-center max-w-60">
Actions will be performed on all selected notes.
</p>
</div>
</div>
);
});
export const MultipleSelectedNotesDirective = toDirective<Props>(
MultipleSelectedNotes
); );

View File

@@ -1,57 +1,48 @@
import { ComponentView } from '@/components/ComponentView';
import { PanelResizer } from '@/components/PanelResizer';
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection'; import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
import { TagsSection } from '@/components/Tags/TagsSection'; import { TagsSection } from '@/components/Tags/TagsSection';
import { toDirective } from '@/components/utils'; import { WebApplication } from '@/ui_models/application';
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { import {
PanelSide, PanelSide,
ResizeFinishCallback, ResizeFinishCallback,
} from '@/directives/views/panelResizer'; PanelResizer,
import { WebApplication } from '@/ui_models/application'; PanelResizeType,
import { PANEL_NAME_NAVIGATION } from '@/views/constants'; } from './PanelResizer';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { PremiumModalProvider } from './Premium';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
}; };
const NAVIGATION_SELECTOR = 'navigation';
const useNavigationPanelRef = (): [HTMLDivElement | null, () => void] => {
const [panelRef, setPanelRefInternal] = useState<HTMLDivElement | null>(null);
const setPanelRefPublic = useCallback(() => {
const elem = document.querySelector(
NAVIGATION_SELECTOR
) as HTMLDivElement | null;
setPanelRefInternal(elem);
}, [setPanelRefInternal]);
return [panelRef, setPanelRefPublic];
};
export const Navigation: FunctionComponent<Props> = observer( export const Navigation: FunctionComponent<Props> = observer(
({ application }) => { ({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]); const appState = useMemo(() => application.getAppState(), [application]);
const componentViewer = appState.foldersComponentViewer; const [ref, setRef] = useState<HTMLDivElement | null>();
const enableNativeSmartTagsFeature = const [panelWidth, setPanelWidth] = useState<number>(0);
appState.features.enableNativeSmartTagsFeature;
const [panelRef, setPanelRef] = useNavigationPanelRef();
const onCreateNewTag = useCallback(() => { useEffect(() => {
appState.tags.createNewTemplate(); const removeObserver = application.addEventObserver(async () => {
}, [appState]); const width = application.getPreference(PrefKey.TagsPanelWidth);
if (width) {
setPanelWidth(width);
}
}, ApplicationEvent.PreferencesChanged);
return () => {
removeObserver();
};
}, [application]);
const panelResizeFinishCallback: ResizeFinishCallback = useCallback( const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => { (width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.TagsPanelWidth, width);
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed); appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
}, },
[appState] [application, appState]
); );
const panelWidthEventCallback = useCallback(() => { const panelWidthEventCallback = useCallback(() => {
@@ -59,65 +50,40 @@ export const Navigation: FunctionComponent<Props> = observer(
}, [appState]); }, [appState]);
return ( return (
<PremiumModalProvider state={appState.features}> <div
<div id="navigation"
id="navigation" className="sn-component section app-column app-column-first"
className="sn-component section" data-aria-label="Navigation"
data-aria-label="Navigation" ref={setRef}
ref={setPanelRef} >
> <div id="navigation-content" className="content">
{componentViewer ? ( <div className="section-title-bar">
<div className="component-view-container"> <div className="section-title-bar-header">
<div className="component-view"> <div className="sk-h3 title">
<ComponentView <span className="sk-bold">Views</span>
componentViewer={componentViewer}
application={application}
appState={appState}
/>
</div> </div>
</div> </div>
) : ( </div>
<div id="navigation-content" className="content"> <div className="scrollable">
<div className="section-title-bar"> <SmartTagsSection appState={appState} />
<div className="section-title-bar-header"> <TagsSection appState={appState} />
<div className="sk-h3 title"> </div>
<span className="sk-bold">Views</span>
</div>
{!enableNativeSmartTagsFeature && (
<div
className="sk-button sk-secondary-contrast wide"
onClick={onCreateNewTag}
title="Create a new tag"
>
<div className="sk-label">
<i className="icon ion-plus add-button" />
</div>
</div>
)}
</div>
</div>
<div className="scrollable">
<SmartTagsSection appState={appState} />
<TagsSection appState={appState} />
</div>
</div>
)}
{panelRef && (
<PanelResizer
application={application}
collapsable={true}
defaultWidth={150}
panel={panelRef}
prefKey={PrefKey.TagsPanelWidth}
side={PanelSide.Right}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
/>
)}
</div> </div>
</PremiumModalProvider> {ref && (
<PanelResizer
collapsable={true}
defaultWidth={150}
panel={ref}
hoverable={true}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>
)}
</div>
); );
} }
); );
export const NavigationDirective = toDirective<Props>(Navigation);

View File

@@ -1,4 +1,3 @@
import { toDirective } from './utils';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@@ -39,5 +38,3 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
</div> </div>
); );
}); });
export const NoAccountWarningDirective = toDirective<Props>(NoAccountWarning);

View File

@@ -0,0 +1,70 @@
import { NoteViewController } from '@standardnotes/snjs';
import { PureComponent } from '@/components/Abstract/PureComponent';
import { WebApplication } from '@/ui_models/application';
import { MultipleSelectedNotes } from '@/components/MultipleSelectedNotes';
import { NoteView } from '@/components/NoteView/NoteView';
import { ElementIds } from '@/element_ids';
type State = {
showMultipleSelectedNotes: boolean;
controllers: NoteViewController[];
};
type Props = {
application: WebApplication;
};
export class NoteGroupView extends PureComponent<Props, State> {
constructor(props: Props) {
super(props, props.application);
this.state = {
showMultipleSelectedNotes: false,
controllers: [],
};
}
componentDidMount(): void {
super.componentDidMount();
this.application.noteControllerGroup.addActiveControllerChangeObserver(
() => {
this.setState({
controllers: this.application.noteControllerGroup.noteControllers,
});
}
);
this.autorun(() => {
this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
});
});
}
render() {
return (
<div
id={ElementIds.EditorColumn}
className="h-full app-column app-column-third"
>
{this.state.showMultipleSelectedNotes && (
<MultipleSelectedNotes
application={this.application}
appState={this.appState}
/>
)}
{!this.state.showMultipleSelectedNotes && (
<>
{this.state.controllers.map((controller) => {
return (
<NoteView
application={this.application}
controller={controller}
/>
);
})}
</>
)}
</div>
);
}
}

View File

@@ -1,6 +1,5 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toDirective } from './utils';
import { AutocompleteTagInput } from './AutocompleteTagInput'; import { AutocompleteTagInput } from './AutocompleteTagInput';
import { NoteTag } from './NoteTag'; import { NoteTag } from './NoteTag';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
@@ -9,33 +8,24 @@ type Props = {
appState: AppState; appState: AppState;
}; };
const NoteTagsContainer = observer(({ appState }: Props) => { export const NoteTagsContainer = observer(({ appState }: Props) => {
const { const { tags, tagsContainerMaxWidth } = appState.noteTags;
tags,
tagsContainerMaxWidth,
} = appState.noteTags;
useEffect(() => { useEffect(() => {
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth();
}, [appState.noteTags]); }, [appState.noteTags]);
return ( return (
<div <div
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2" className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
style={{ style={{
maxWidth: tagsContainerMaxWidth, maxWidth: tagsContainerMaxWidth,
}} }}
> >
{tags.map((tag) => ( {tags.map((tag) => (
<NoteTag <NoteTag key={tag.uuid} appState={appState} tag={tag} />
key={tag.uuid} ))}
appState={appState} <AutocompleteTagInput appState={appState} />
tag={tag} </div>
/>
))}
<AutocompleteTagInput appState={appState} />
</div>
); );
}); });
export const NoteTagsContainerDirective = toDirective<Props>(NoteTagsContainer);

View File

@@ -2,7 +2,7 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import { NoteView } from '@Views/note_view/note_view'; import { NoteView } from './NoteView';
import { import {
ApplicationEvent, ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction, ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
@@ -13,8 +13,7 @@ describe('editor-view', () => {
let setShowProtectedWarningSpy: jest.SpyInstance; let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
const $timeout = {} as jest.Mocked<ng.ITimeoutService>; ctrl = new NoteView({} as any);
ctrl = new NoteView($timeout);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay'); setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
@@ -162,7 +161,7 @@ describe('editor-view', () => {
describe('the note has protection sources', () => { describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => { it('should reveal note contents if the authorization has been passed', async () => {
jest jest
.spyOn(ctrl.application, 'authorizeNoteAccess') .spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true)); .mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning();
@@ -172,7 +171,7 @@ describe('editor-view', () => {
it('should not reveal note contents if the authorization has not been passed', async () => { it('should not reveal note contents if the authorization has not been passed', async () => {
jest jest
.spyOn(ctrl.application, 'authorizeNoteAccess') .spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false)); .mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning();
@@ -184,7 +183,7 @@ describe('editor-view', () => {
describe('the note does not have protection sources', () => { describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => { it('should reveal note contents', async () => {
jest jest
.spyOn(ctrl.application, 'hasProtectionSources') .spyOn(ctrl['application'], 'hasProtectionSources')
.mockImplementation(() => false); .mockImplementation(() => false);
await ctrl.dismissProtectedWarning(); await ctrl.dismissProtectedWarning();

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils'; import { useCloseOnBlur, useCloseOnClickOutside } from './utils';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions'; import { NotesOptions } from './NotesOptions/NotesOptions';
import { useCallback, useEffect, useRef } from 'preact/hooks'; import { useCallback, useEffect, useRef } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
@@ -10,22 +10,17 @@ type Props = {
appState: AppState; appState: AppState;
}; };
const NotesContextMenu = observer(({ application, appState }: Props) => { export const NotesContextMenu = observer(({ application, appState }: Props) => {
const { const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
contextMenuOpen, appState.notes;
contextMenuPosition,
contextMenuMaxHeight,
} = appState.notes;
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur( const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
contextMenuRef as any, appState.notes.setContextMenuOpen(open)
(open: boolean) => appState.notes.setContextMenuOpen(open)
); );
useCloseOnClickOutside( useCloseOnClickOutside(contextMenuRef, () =>
contextMenuRef as any, appState.notes.setContextMenuOpen(false)
(open: boolean) => appState.notes.setContextMenuOpen(open)
); );
const reloadContextMenuLayout = useCallback(() => { const reloadContextMenuLayout = useCallback(() => {
@@ -42,7 +37,7 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
return contextMenuOpen ? ( return contextMenuOpen ? (
<div <div
ref={contextMenuRef} ref={contextMenuRef}
className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed" className="sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
style={{ style={{
...contextMenuPosition, ...contextMenuPosition,
maxHeight: contextMenuMaxHeight, maxHeight: contextMenuMaxHeight,
@@ -56,5 +51,3 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
</div> </div>
) : null; ) : null;
}); });
export const NotesContextMenuDirective = toDirective<Props>(NotesContextMenu);

View File

@@ -6,6 +6,10 @@ import { SNNote } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { NotesListItem } from './NotesListItem'; import { NotesListItem } from './NotesListItem';
import {
FOCUSABLE_BUT_NOT_TABBABLE,
NOTES_LIST_SCROLL_THRESHOLD,
} from '@/views/constants';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
@@ -16,9 +20,6 @@ type Props = {
paginate: () => void; paginate: () => void;
}; };
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const NotesList: FunctionComponent<Props> = observer( export const NotesList: FunctionComponent<Props> = observer(
({ ({
application, application,
@@ -44,7 +45,7 @@ export const NotesList: FunctionComponent<Props> = observer(
if (!selectedTag.isSmartTag && tags.length === 1) { if (!selectedTag.isSmartTag && tags.length === 1) {
return []; return [];
} }
return tags.map((tag) => tag.title); return tags.map((tag) => tag.title).sort();
}; };
const openNoteContextMenu = (posX: number, posY: number) => { const openNoteContextMenu = (posX: number, posY: number) => {
@@ -84,7 +85,7 @@ export const NotesList: FunctionComponent<Props> = observer(
return ( return (
<div <div
className="infinite-scroll" className="infinite-scroll focus:shadow-none focus:outline-none"
id="notes-scrollable" id="notes-scrollable"
onScroll={onScroll} onScroll={onScroll}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}

View File

@@ -1,4 +1,3 @@
import { getIconAndTintForEditor } from '@/preferences/panes/general-segments';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { import {
CollectionSort, CollectionSort,
@@ -74,7 +73,9 @@ export const NotesListItem: FunctionComponent<Props> = ({
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt; const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
const editorForNote = application.componentManager.editorForNote(note); const editorForNote = application.componentManager.editorForNote(note);
const editorName = editorForNote?.name ?? 'Plain editor'; const editorName = editorForNote?.name ?? 'Plain editor';
const [icon, tint] = getIconAndTintForEditor(editorForNote?.identifier); const [icon, tint] = application.iconsController.getIconAndTintForEditor(
editorForNote?.identifier
);
return ( return (
<div <div
@@ -93,46 +94,8 @@ export const NotesListItem: FunctionComponent<Props> = ({
</div> </div>
)} )}
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}> <div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
<div className="name"> <div className="name-container">
<div>{note.title}</div> {note.title.length ? <div className="name">{note.title}</div> : null}
<div className="flag-icons">
{note.locked && (
<span title="Editing Disabled">
<Icon
ariaLabel="Editing Disabled"
type="pencil-off"
className="sn-icon--small color-info"
/>
</span>
)}
{note.trashed && (
<span title="Trashed">
<Icon
ariaLabel="Trashed"
type="trash-filled"
className="sn-icon--small color-danger"
/>
</span>
)}
{note.archived && (
<span title="Archived">
<Icon
ariaLabel="Archived"
type="archive"
className="sn-icon--mid color-accessory-tint-3"
/>
</span>
)}
{note.pinned && (
<span title="Pinned">
<Icon
ariaLabel="Pinned"
type="pin-filled"
className="sn-icon--small color-info"
/>
</span>
)}
</div>
</div> </div>
{!hidePreview && !note.hidePreview && !note.protected && ( {!hidePreview && !note.hidePreview && !note.protected && (
<div className="note-preview"> <div className="note-preview">
@@ -186,6 +149,44 @@ export const NotesListItem: FunctionComponent<Props> = ({
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flag-icons">
{note.locked && (
<span title="Editing Disabled">
<Icon
ariaLabel="Editing Disabled"
type="pencil-off"
className="sn-icon--small color-info"
/>
</span>
)}
{note.trashed && (
<span title="Trashed">
<Icon
ariaLabel="Trashed"
type="trash-filled"
className="sn-icon--small color-danger"
/>
</span>
)}
{note.archived && (
<span title="Archived">
<Icon
ariaLabel="Archived"
type="archive"
className="sn-icon--mid color-accessory-tint-3"
/>
</span>
)}
{note.pinned && (
<span title="Pinned">
<Icon
ariaLabel="Pinned"
type="pin-filled"
className="sn-icon--small color-info"
/>
</span>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -6,19 +6,19 @@ import { useRef, useState } from 'preact/hooks';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { Menu } from './menu/Menu'; import { Menu } from './menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem'; import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
import { toDirective, useCloseOnClickOutside } from './utils';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
closeDisplayOptionsMenu: () => void; closeDisplayOptionsMenu: () => void;
}; };
export const NotesListOptionsMenu: FunctionComponent<Props> = observer( export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ closeDisplayOptionsMenu, application }) => { ({ closeDisplayOptionsMenu, closeOnBlur, application }) => {
const menuClassName = const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \ 'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \ border-1 border-solid border-main text-sm z-index-dropdown-menu \
flex flex-col py-2 bottom-0 left-2 absolute'; flex flex-col py-2 top-full bottom-0 left-2 absolute';
const [sortBy, setSortBy] = useState(() => const [sortBy, setSortBy] = useState(() =>
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt) application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
); );
@@ -118,12 +118,6 @@ flex flex-col py-2 bottom-0 left-2 absolute';
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(menuRef, (open: boolean) => {
if (!open) {
closeDisplayOptionsMenu();
}
});
return ( return (
<div ref={menuRef} className={menuClassName}> <div ref={menuRef} className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}> <Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
@@ -135,6 +129,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified} onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt} checked={sortBy === CollectionSort.UpdatedAt}
onBlur={closeOnBlur}
> >
<div className="flex flex-grow items-center justify-between"> <div className="flex flex-grow items-center justify-between">
<span>Date modified</span> <span>Date modified</span>
@@ -152,6 +147,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate} onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt} checked={sortBy === CollectionSort.CreatedAt}
onBlur={closeOnBlur}
> >
<div className="flex flex-grow items-center justify-between"> <div className="flex flex-grow items-center justify-between">
<span>Creation date</span> <span>Creation date</span>
@@ -169,6 +165,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton} type={MenuItemType.RadioButton}
onClick={toggleSortByTitle} onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title} checked={sortBy === CollectionSort.Title}
onBlur={closeOnBlur}
> >
<div className="flex flex-grow items-center justify-between"> <div className="flex flex-grow items-center justify-between">
<span>Title</span> <span>Title</span>
@@ -190,6 +187,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePreview} checked={!hidePreview}
onChange={toggleHidePreview} onChange={toggleHidePreview}
onBlur={closeOnBlur}
> >
<div className="flex flex-col max-w-3/4">Show note preview</div> <div className="flex flex-col max-w-3/4">Show note preview</div>
</MenuItem> </MenuItem>
@@ -198,6 +196,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideDate} checked={!hideDate}
onChange={toggleHideDate} onChange={toggleHideDate}
onBlur={closeOnBlur}
> >
Show date Show date
</MenuItem> </MenuItem>
@@ -206,6 +205,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideTags} checked={!hideTags}
onChange={toggleHideTags} onChange={toggleHideTags}
onBlur={closeOnBlur}
> >
Show tags Show tags
</MenuItem> </MenuItem>
@@ -214,6 +214,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideEditorIcon} checked={!hideEditorIcon}
onChange={toggleEditorIcon} onChange={toggleEditorIcon}
onBlur={closeOnBlur}
> >
Show editor icon Show editor icon
</MenuItem> </MenuItem>
@@ -226,6 +227,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePinned} checked={!hidePinned}
onChange={toggleHidePinned} onChange={toggleHidePinned}
onBlur={closeOnBlur}
> >
Show pinned notes Show pinned notes
</MenuItem> </MenuItem>
@@ -234,6 +236,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideProtected} checked={!hideProtected}
onChange={toggleHideProtected} onChange={toggleHideProtected}
onBlur={closeOnBlur}
> >
Show protected notes Show protected notes
</MenuItem> </MenuItem>
@@ -242,6 +245,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showArchived} checked={showArchived}
onChange={toggleShowArchived} onChange={toggleShowArchived}
onBlur={closeOnBlur}
> >
Show archived notes Show archived notes
</MenuItem> </MenuItem>
@@ -250,6 +254,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showTrashed} checked={showTrashed}
onChange={toggleShowTrashed} onChange={toggleShowTrashed}
onBlur={closeOnBlur}
> >
Show trashed notes Show trashed notes
</MenuItem> </MenuItem>
@@ -258,11 +263,3 @@ flex flex-col py-2 bottom-0 left-2 absolute';
); );
} }
); );
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
closeDisplayOptionsMenu: '=',
state: '&',
}
);

View File

@@ -0,0 +1,317 @@
import { KeyboardKey } from '@/services/ioService';
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import {
MENU_MARGIN_FROM_APP_BORDER,
MAX_MENU_SIZE_MULTIPLIER,
} from '@/views/constants';
import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/components/NoteView/NoteView';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import {
ComponentArea,
IconType,
ItemMutator,
NoteMutator,
PrefKey,
SNComponent,
SNNote,
TransactionalMutation,
} from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups';
import { EditorAccordionMenu } from './changeEditor/EditorAccordionMenu';
type ChangeEditorOptionProps = {
appState: AppState;
application: WebApplication;
note: SNNote;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
};
type AccordionMenuGroup<T> = {
icon?: IconType;
iconClassName?: string;
title: string;
items: Array<T>;
};
export type EditorMenuItem = {
name: string;
component?: SNComponent;
isPremiumFeature?: boolean;
};
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>;
type MenuPositionStyle = {
top?: number | 'auto';
right?: number | 'auto';
bottom: number | 'auto';
left?: number | 'auto';
};
const calculateMenuPosition = (
button: HTMLButtonElement | null,
menu?: HTMLDivElement | null
): MenuPositionStyle | undefined => {
const defaultFontSize = window.getComputedStyle(
document.documentElement
).fontSize;
const maxChangeEditorMenuSize =
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = button?.getBoundingClientRect();
const buttonParentRect = button?.parentElement?.getBoundingClientRect();
const menuBoundingRect = menu?.getBoundingClientRect();
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height ?? 0;
let position: MenuPositionStyle = {
bottom: 'auto',
};
if (buttonRect && buttonParentRect) {
let positionBottom =
clientHeight - buttonRect.bottom - buttonRect.height / 2;
if (positionBottom < footerHeightInPx) {
positionBottom = footerHeightInPx + MENU_MARGIN_FROM_APP_BORDER;
}
if (buttonRect.right + maxChangeEditorMenuSize > clientWidth) {
position = {
bottom: positionBottom,
right: clientWidth - buttonRect.left,
};
} else {
position = {
bottom: positionBottom,
left: buttonRect.right,
};
}
}
if (menuBoundingRect && menuBoundingRect.height && buttonRect) {
if (menuBoundingRect.y < MENU_MARGIN_FROM_APP_BORDER) {
if (
buttonRect.right + maxChangeEditorMenuSize >
document.documentElement.clientWidth
) {
return {
...position,
top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.top - buttonRect.height,
bottom: 'auto',
};
} else {
return {
...position,
top: MENU_MARGIN_FROM_APP_BORDER,
bottom: 'auto',
};
}
}
}
return position;
};
const TIME_IN_MS_TO_WAIT_BEFORE_REPAINT = 1;
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
application,
appState,
closeOnBlur,
note,
}) => {
const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false);
const [changeEditorMenuPosition, setChangeEditorMenuPosition] =
useState<MenuPositionStyle>({
right: 0,
bottom: 0,
});
const changeEditorMenuRef = useRef<HTMLDivElement>(null);
const changeEditorButtonRef = useRef<HTMLButtonElement>(null);
const [editors] = useState<SNComponent[]>(() =>
application.componentManager
.componentsForArea(ComponentArea.Editor)
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
})
);
const [editorMenuGroups, setEditorMenuGroups] = useState<EditorMenuGroup[]>(
[]
);
const [selectedEditor, setSelectedEditor] = useState(() =>
application.componentManager.editorForNote(note)
);
useEffect(() => {
setEditorMenuGroups(createEditorMenuGroups(editors));
}, [editors]);
useEffect(() => {
setSelectedEditor(application.componentManager.editorForNote(note));
}, [application, note]);
const toggleChangeEditorMenu = () => {
if (!changeEditorMenuOpen) {
const menuPosition = calculateMenuPosition(changeEditorButtonRef.current);
if (menuPosition) {
setChangeEditorMenuPosition(menuPosition);
}
}
setChangeEditorMenuOpen(!changeEditorMenuOpen);
};
useEffect(() => {
if (changeEditorMenuOpen) {
setTimeout(() => {
const newMenuPosition = calculateMenuPosition(
changeEditorButtonRef.current,
changeEditorMenuRef.current
);
if (newMenuPosition) {
setChangeEditorMenuPosition(newMenuPosition);
}
}, TIME_IN_MS_TO_WAIT_BEFORE_REPAINT);
}
}, [changeEditorMenuOpen]);
const selectComponent = async (component: SNComponent | null) => {
if (component) {
if (component.conflictOf) {
application.changeAndSaveItem(component.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
}
const transactions: TransactionalMutation[] = [];
if (appState.getActiveNoteController()?.isTemplateNote) {
await appState.getActiveNoteController().insertTemplatedNote();
}
if (note.locked) {
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
return;
}
if (!component) {
if (!note.prefersPlainEditor) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = true;
},
});
}
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
} else if (component.area === ComponentArea.Editor) {
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
const prefersPlain = note.prefersPlainEditor;
if (prefersPlain) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = false;
},
});
}
transactions.push(
transactionForAssociateComponentWithCurrentNote(component, note)
);
}
await application.runTransactionalMutations(transactions);
/** Dirtying can happen above */
application.sync();
setSelectedEditor(application.componentManager.editorForNote(note));
};
return (
<Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setChangeEditorMenuOpen(false);
}
}}
onBlur={closeOnBlur}
ref={changeEditorButtonRef}
className="sn-dropdown-item justify-between"
>
<div className="flex items-center">
<Icon type="editor" className="color-neutral mr-2" />
Change editor
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={changeEditorMenuRef}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setChangeEditorMenuOpen(false);
changeEditorButtonRef.current?.focus();
}
}}
style={{
...changeEditorMenuPosition,
position: 'fixed',
}}
className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto"
>
<EditorAccordionMenu
application={application}
closeOnBlur={closeOnBlur}
currentEditor={selectedEditor}
groups={editorMenuGroups}
isOpen={changeEditorMenuOpen}
selectComponent={selectComponent}
/>
</DisclosurePanel>
</Disclosure>
);
};

View File

@@ -1,6 +1,6 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon'; import { Icon } from '../Icon';
import { Switch } from './Switch'; import { Switch } from '../Switch';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useRef, useState, useEffect, useMemo } from 'preact/hooks'; import { useRef, useState, useEffect, useMemo } from 'preact/hooks';
import { import {
@@ -8,12 +8,18 @@ import {
DisclosureButton, DisclosureButton,
DisclosurePanel, DisclosurePanel,
} from '@reach/disclosure'; } from '@reach/disclosure';
import { SNApplication, SNNote } from '@standardnotes/snjs/dist/@types'; import { SNApplication, SNNote } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { KeyboardModifier } from '@/services/ioService'; import { KeyboardModifier } from '@/services/ioService';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { ChangeEditorOption } from './ChangeEditorOption';
import {
MENU_MARGIN_FROM_APP_BORDER,
MAX_MENU_SIZE_MULTIPLIER,
BYTES_IN_ONE_MEGABYTE,
} from '@/views/constants';
type Props = { export type NotesOptionsProps = {
application: WebApplication; application: WebApplication;
appState: AppState; appState: AppState;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
@@ -21,7 +27,7 @@ type Props = {
}; };
type DeletePermanentlyButtonProps = { type DeletePermanentlyButtonProps = {
closeOnBlur: Props['closeOnBlur']; closeOnBlur: NotesOptionsProps['closeOnBlur'];
onClick: () => void; onClick: () => void;
}; };
@@ -86,7 +92,10 @@ const formatDate = (date: Date | undefined) => {
return `${date.toDateString()} ${date.toLocaleTimeString()}`; return `${date.toDateString()} ${date.toLocaleTimeString()}`;
}; };
const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNote }> = ({ application, note }) => { const NoteAttributes: FunctionComponent<{
application: SNApplication;
note: SNNote;
}> = ({ application, note }) => {
const { words, characters, paragraphs } = useMemo( const { words, characters, paragraphs } = useMemo(
() => countNoteAttributes(note.text), () => countNoteAttributes(note.text),
[note.text] [note.text]
@@ -111,7 +120,7 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
const format = editor?.package_info?.file_type || 'txt'; const format = editor?.package_info?.file_type || 'txt';
return ( return (
<div className="px-3 pt-1.5 pb-1 text-xs color-neutral font-medium"> <div className="px-3 pt-1.5 pb-2.5 text-xs color-neutral font-medium">
{typeof words === 'number' && (format === 'txt' || format === 'md') ? ( {typeof words === 'number' && (format === 'txt' || format === 'md') ? (
<> <>
<div className="mb-1"> <div className="mb-1">
@@ -136,40 +145,72 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
}; };
const SpellcheckOptions: FunctionComponent<{ const SpellcheckOptions: FunctionComponent<{
appState: AppState, note: SNNote appState: AppState;
note: SNNote;
}> = ({ appState, note }) => { }> = ({ appState, note }) => {
const editor = appState.application.componentManager.editorForNote(note); const editor = appState.application.componentManager.editorForNote(note);
const spellcheckControllable = Boolean( const spellcheckControllable = Boolean(
!editor || !editor || editor.package_info.spellcheckControl
appState.application.getFeature(editor.identifier)?.spellcheckControl
); );
const noteSpellcheck = !spellcheckControllable ? true : note ? appState.notes.getSpellcheckStateForNote(note) : undefined; const noteSpellcheck = !spellcheckControllable
? true
: note
? appState.notes.getSpellcheckStateForNote(note)
: undefined;
return ( return (
<div className="flex flex-col px-3 py-1.5"> <div className="flex flex-col">
<Switch <button
className="px-0 py-0" className="sn-dropdown-item justify-between px-3 py-1"
checked={noteSpellcheck} onClick={() => {
disabled={!spellcheckControllable}
onChange={() => {
appState.notes.toggleGlobalSpellcheckForNote(note); appState.notes.toggleGlobalSpellcheckForNote(note);
}} }}
disabled={!spellcheckControllable}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type='spellcheck' className={iconClass} /> <Icon type="spellcheck" className={iconClass} />
Spellcheck Spellcheck
</span> </span>
</Switch> <Switch
className="px-0"
checked={noteSpellcheck}
disabled={!spellcheckControllable}
/>
</button>
{!spellcheckControllable && ( {!spellcheckControllable && (
<p className="text-xs pt-1.5">Spellcheck cannot be controlled for this editor.</p> <p className="text-xs px-3 py-1.5">
Spellcheck cannot be controlled for this editor.
</p>
)} )}
</div> </div>
); );
}; };
const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE;
const NoteSizeWarning: FunctionComponent<{
note: SNNote;
}> = ({ note }) =>
new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
<div className="flex items-center px-3 py-3.5 relative bg-note-size-warning">
<Icon
type="warning"
className="color-accessory-tint-3 flex-shrink-0 mr-3"
/>
<div className="color-grey-0 select-none leading-140% max-w-80%">
This note may have trouble syncing to the mobile application due to its
size.
</div>
</div>
) : null;
export const NotesOptions = observer( export const NotesOptions = observer(
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => { ({
application,
appState,
closeOnBlur,
onSubmenuChange,
}: NotesOptionsProps) => {
const [tagsMenuOpen, setTagsMenuOpen] = useState(false); const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
const [tagsMenuPosition, setTagsMenuPosition] = useState<{ const [tagsMenuPosition, setTagsMenuPosition] = useState<{
top: number; top: number;
@@ -232,25 +273,39 @@ export const NotesOptions = observer(
const defaultFontSize = window.getComputedStyle( const defaultFontSize = window.getComputedStyle(
document.documentElement document.documentElement
).fontSize; ).fontSize;
const maxTagsMenuSize = parseFloat(defaultFontSize) * 30; const maxTagsMenuSize =
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
const { clientWidth, clientHeight } = document.documentElement; const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = tagsButtonRef.current!.getBoundingClientRect(); const buttonRect = tagsButtonRef.current?.getBoundingClientRect();
const footerHeight = 32; const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) { if (buttonRect && footerHeightInPx) {
setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2); if (
} buttonRect.top + maxTagsMenuSize >
clientHeight - footerHeightInPx
) {
setTagsMenuMaxHeight(
clientHeight -
buttonRect.top -
footerHeightInPx -
MENU_MARGIN_FROM_APP_BORDER
);
}
if (buttonRect.right + maxTagsMenuSize > clientWidth) { if (buttonRect.right + maxTagsMenuSize > clientWidth) {
setTagsMenuPosition({ setTagsMenuPosition({
top: buttonRect.top, top: buttonRect.top,
right: clientWidth - buttonRect.left, right: clientWidth - buttonRect.left,
}); });
} else { } else {
setTagsMenuPosition({ setTagsMenuPosition({
top: buttonRect.top, top: buttonRect.top,
left: buttonRect.right, left: buttonRect.right,
}); });
}
} }
setTagsMenuOpen(!tagsMenuOpen); setTagsMenuOpen(!tagsMenuOpen);
@@ -298,45 +353,56 @@ export const NotesOptions = observer(
return ( return (
<> <>
<Switch <button
onBlur={closeOnBlur} className="sn-dropdown-item justify-between"
className="px-3 py-1.5" onClick={() => {
checked={locked}
onChange={() => {
appState.notes.setLockSelectedNotes(!locked); appState.notes.setLockSelectedNotes(!locked);
}} }}
onBlur={closeOnBlur}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type="pencil-off" className={iconClass} /> <Icon type="pencil-off" className={iconClass} />
Prevent editing Prevent editing
</span> </span>
</Switch> <Switch className="px-0" checked={locked} />
<Switch </button>
onBlur={closeOnBlur} <button
className="px-3 py-1.5" className="sn-dropdown-item justify-between"
checked={!hidePreviews} onClick={() => {
onChange={() => {
appState.notes.setHideSelectedNotePreviews(!hidePreviews); appState.notes.setHideSelectedNotePreviews(!hidePreviews);
}} }}
onBlur={closeOnBlur}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type="rich-text" className={iconClass} /> <Icon type="rich-text" className={iconClass} />
Show preview Show preview
</span> </span>
</Switch> <Switch className="px-0" checked={!hidePreviews} />
<Switch </button>
onBlur={closeOnBlur} <button
className="px-3 py-1.5" className="sn-dropdown-item justify-between"
checked={protect} onClick={() => {
onChange={() => {
appState.notes.setProtectSelectedNotes(!protect); appState.notes.setProtectSelectedNotes(!protect);
}} }}
onBlur={closeOnBlur}
> >
<span className="flex items-center"> <span className="flex items-center">
<Icon type="password" className={iconClass} /> <Icon type="password" className={iconClass} />
Protect Protect
</span> </span>
</Switch> <Switch className="px-0" checked={protect} />
</button>
{notes.length === 1 && (
<>
<div className="min-h-1px my-2 bg-border"></div>
<ChangeEditorOption
appState={appState}
application={application}
closeOnBlur={closeOnBlur}
note={notes[0]}
/>
</>
)}
<div className="min-h-1px my-2 bg-border"></div> <div className="min-h-1px my-2 bg-border"></div>
{appState.tags.tagsCount > 0 && ( {appState.tags.tagsCount > 0 && (
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}> <Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
@@ -360,7 +426,7 @@ export const NotesOptions = observer(
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setTagsMenuOpen(false); setTagsMenuOpen(false);
tagsButtonRef.current!.focus(); tagsButtonRef.current?.focus();
} }
}} }}
style={{ style={{
@@ -383,12 +449,13 @@ export const NotesOptions = observer(
> >
<span <span
className={`whitespace-nowrap overflow-hidden overflow-ellipsis className={`whitespace-nowrap overflow-hidden overflow-ellipsis
${appState.notes.isTagInSelectedNotes(tag) ${
? 'font-bold' appState.notes.isTagInSelectedNotes(tag)
: '' ? 'font-bold'
: ''
}`} }`}
> >
{tag.title} {appState.noteTags.getLongTitle(tag)}
</span> </span>
</button> </button>
))} ))}
@@ -516,17 +583,13 @@ export const NotesOptions = observer(
</button> </button>
</> </>
)} )}
{notes.length === 1 ? ( {notes.length === 1 ? (
<> <>
<div className="min-h-1px my-2 bg-border"></div> <div className="min-h-1px my-2 bg-border"></div>
<SpellcheckOptions appState={appState} note={notes[0]} /> <SpellcheckOptions appState={appState} note={notes[0]} />
<div className="min-h-1px my-2 bg-border"></div> <div className="min-h-1px my-2 bg-border"></div>
<NoteAttributes application={application} note={notes[0]} /> <NoteAttributes application={application} note={notes[0]} />
<NoteSizeWarning note={notes[0]} />
</> </>
) : null} ) : null}
</> </>

View File

@@ -0,0 +1,285 @@
import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium';
import { KeyboardKey } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
import { SNComponent } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
import { PLAIN_EDITOR_NAME } from './createEditorMenuGroups';
type EditorAccordionMenuProps = {
application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
groups: EditorMenuGroup[];
isOpen: boolean;
selectComponent: (component: SNComponent | null) => Promise<void>;
currentEditor: SNComponent | undefined;
};
const getGroupId = (group: EditorMenuGroup) =>
group.title.toLowerCase().replace(/\s/, '-');
const getGroupBtnId = (groupId: string) => groupId + '-button';
const isElementHidden = (element: Element) => !element.clientHeight;
const isElementFocused = (element: Element | null) =>
element === document.activeElement;
export const EditorAccordionMenu: FunctionComponent<
EditorAccordionMenuProps
> = ({
application,
closeOnBlur,
groups,
isOpen,
selectComponent,
currentEditor,
}) => {
const [activeGroupId, setActiveGroupId] = useState('');
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const premiumModal = usePremiumModal();
const addRefToMenuItems = (button: HTMLButtonElement | null) => {
if (!menuItemRefs.current?.includes(button) && button) {
menuItemRefs.current.push(button);
}
};
const isSelectedEditor = useCallback(
(item: EditorMenuItem) => {
if (currentEditor) {
if (item?.component?.identifier === currentEditor.identifier) {
return true;
}
} else if (item.name === PLAIN_EDITOR_NAME) {
return true;
}
return false;
},
[currentEditor]
);
useEffect(() => {
const activeGroup = groups.find((group) => {
return group.items.some(isSelectedEditor);
});
if (activeGroup) {
const newActiveGroupId = getGroupId(activeGroup);
setActiveGroupId(newActiveGroupId);
}
}, [groups, currentEditor, isSelectedEditor]);
useEffect(() => {
if (isOpen && !menuItemRefs.current.some(isElementFocused)) {
const selectedEditor = groups
.map((group) => group.items)
.flat()
.find((item) => isSelectedEditor(item));
if (selectedEditor) {
const editorButton = menuItemRefs.current.find(
(btn) => btn?.dataset.itemName === selectedEditor.name
);
editorButton?.focus();
}
}
}, [groups, isOpen, isSelectedEditor]);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Down || e.key === KeyboardKey.Up) {
e.preventDefault();
} else {
return;
}
let items = menuItemRefs.current;
if (!activeGroupId) {
items = items.filter((btn) => btn?.id);
}
const currentItemIndex = items.findIndex(isElementFocused) ?? 0;
if (e.key === KeyboardKey.Up) {
let previousItemIndex = currentItemIndex - 1;
if (previousItemIndex < 0) {
previousItemIndex = items.length - 1;
}
const previousItem = items[previousItemIndex];
if (previousItem) {
if (isElementHidden(previousItem)) {
const previousItemGroupId = previousItem.closest(
'[data-accordion-group]'
)?.id;
if (previousItemGroupId) {
setActiveGroupId(previousItemGroupId);
}
setTimeout(() => {
previousItem.focus();
}, 10);
}
previousItem.focus();
}
}
if (e.key === KeyboardKey.Down) {
let nextItemIndex = currentItemIndex + 1;
if (nextItemIndex > items.length - 1) {
nextItemIndex = 0;
}
const nextItem = items[nextItemIndex];
if (nextItem) {
if (isElementHidden(nextItem)) {
const nextItemGroupId = nextItem.closest(
'[data-accordion-group]'
)?.id;
if (nextItemGroupId) {
setActiveGroupId(nextItemGroupId);
}
setTimeout(() => {
nextItem.focus();
}, 10);
}
nextItem?.focus();
}
}
};
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
let shouldSelectEditor = true;
if (itemToBeSelected.component) {
const changeRequiresAlert =
application.componentManager.doesEditorChangeRequireAlert(
currentEditor,
itemToBeSelected.component
);
if (changeRequiresAlert) {
shouldSelectEditor =
await application.componentManager.showEditorChangeAlert();
}
}
if (itemToBeSelected.isPremiumFeature) {
premiumModal.activate(itemToBeSelected.name);
shouldSelectEditor = false;
}
if (shouldSelectEditor) {
selectComponent(itemToBeSelected.component ?? null);
}
};
return (
<>
{groups.map((group) => {
if (!group.items || !group.items.length) {
return null;
}
const groupId = getGroupId(group);
const buttonId = getGroupBtnId(groupId);
const contentId = `${groupId}-content`;
const toggleGroup = () => {
if (activeGroupId !== groupId) {
setActiveGroupId(groupId);
} else {
setActiveGroupId('');
}
};
return (
<Fragment key={groupId}>
<div
id={groupId}
data-accordion-group
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onKeyDown={handleKeyDown}
>
<h3 className="m-0">
<button
aria-controls={contentId}
aria-expanded={activeGroupId === groupId}
className="sn-dropdown-item focus:bg-info-backdrop justify-between py-3"
id={buttonId}
type="button"
onClick={toggleGroup}
onBlur={closeOnBlur}
ref={addRefToMenuItems}
>
<div className="flex items-center">
{group.icon && (
<Icon
type={group.icon}
className={`mr-2 ${group.iconClassName}`}
/>
)}
<div className="font-semibold text-input">
{group.title}
</div>
</div>
<Icon
type="chevron-down"
className={`sn-dropdown-arrow color-grey-1 ${
activeGroupId === groupId && 'sn-dropdown-arrow-flipped'
}`}
/>
</button>
</h3>
<div
id={contentId}
aria-labelledby={buttonId}
className={activeGroupId !== groupId ? 'hidden' : ''}
>
<div role="radiogroup">
{group.items.map((item) => {
const onClickEditorItem = () => {
selectEditor(item);
};
return (
<button
role="radio"
data-item-name={item.name}
onClick={onClickEditorItem}
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none ${
item.isPremiumFeature && 'justify-between'
}`}
aria-checked={false}
onBlur={closeOnBlur}
ref={addRefToMenuItems}
>
<div className="flex items-center">
<div
className={`pseudo-radio-btn ${
isSelectedEditor(item)
? 'pseudo-radio-btn--checked'
: ''
} ml-0.5 mr-2`}
></div>
{item.name}
</div>
{item.isPremiumFeature && (
<Icon type="premium-feature" />
)}
</button>
);
})}
</div>
</div>
</div>
<div className="min-h-1px bg-border hide-if-last-child"></div>
</Fragment>
);
})}
</>
);
};

View File

@@ -0,0 +1,132 @@
import {
ComponentArea,
FeatureDescription,
GetFeatures,
NoteType,
} from '@standardnotes/features';
import { ContentType, SNComponent } from '@standardnotes/snjs';
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
/** @todo Implement interchangeable alert */
export const PLAIN_EDITOR_NAME = 'Plain Editor';
type EditorGroup = NoteType | 'plain' | 'others';
const getEditorGroup = (
featureDescription: FeatureDescription
): EditorGroup => {
if (featureDescription.note_type) {
return featureDescription.note_type;
} else if (featureDescription.file_type) {
switch (featureDescription.file_type) {
case 'txt':
return 'plain';
case 'html':
return NoteType.RichText;
case 'md':
return NoteType.Markdown;
default:
return 'others';
}
}
return 'others';
};
export const createEditorMenuGroups = (editors: SNComponent[]) => {
const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
plain: [
{
name: PLAIN_EDITOR_NAME,
},
],
'rich-text': [],
markdown: [],
task: [],
code: [],
spreadsheet: [],
authentication: [],
others: [],
};
GetFeatures()
.filter(
(feature) =>
feature.content_type === ContentType.Component &&
feature.area === ComponentArea.Editor
)
.forEach((editorFeature) => {
if (
!editors.find(
(editor) => editor.identifier === editorFeature.identifier
)
) {
editorItems[getEditorGroup(editorFeature)].push({
name: editorFeature.name as string,
isPremiumFeature: true,
});
}
});
editors.forEach((editor) => {
const editorItem: EditorMenuItem = {
name: editor.name,
component: editor,
};
editorItems[getEditorGroup(editor.package_info)].push(editorItem);
});
const editorMenuGroups: EditorMenuGroup[] = [
{
icon: 'plain-text',
iconClassName: 'color-accessory-tint-1',
title: 'Plain text',
items: editorItems.plain,
},
{
icon: 'rich-text',
iconClassName: 'color-accessory-tint-1',
title: 'Rich text',
items: editorItems['rich-text'],
},
{
icon: 'markdown',
iconClassName: 'color-accessory-tint-2',
title: 'Markdown text',
items: editorItems.markdown,
},
{
icon: 'tasks',
iconClassName: 'color-accessory-tint-3',
title: 'Todo',
items: editorItems.task,
},
{
icon: 'code',
iconClassName: 'color-accessory-tint-4',
title: 'Code',
items: editorItems.code,
},
{
icon: 'spreadsheets',
iconClassName: 'color-accessory-tint-5',
title: 'Spreadsheet',
items: editorItems.spreadsheet,
},
{
icon: 'authenticator',
iconClassName: 'color-accessory-tint-6',
title: 'Authentication',
items: editorItems.authentication,
},
{
icon: 'editor',
iconClassName: 'color-neutral',
title: 'Others',
items: editorItems.others,
},
];
return editorMenuGroups;
};

View File

@@ -1,7 +1,7 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon'; import { Icon } from './Icon';
import VisuallyHidden from '@reach/visually-hidden'; import VisuallyHidden from '@reach/visually-hidden';
import { toDirective, useCloseOnBlur } from './utils'; import { useCloseOnBlur } from './utils';
import { import {
Disclosure, Disclosure,
DisclosureButton, DisclosureButton,
@@ -9,84 +9,96 @@ import {
} from '@reach/disclosure'; } from '@reach/disclosure';
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions'; import { NotesOptions } from './NotesOptions/NotesOptions';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
appState: AppState; appState: AppState;
onClickPreprocessing?: () => Promise<void>;
}; };
export const NotesOptionsPanel = observer(({ application, appState }: Props) => { export const NotesOptionsPanel = observer(
const [open, setOpen] = useState(false); ({ application, appState, onClickPreprocessing }: Props) => {
const [position, setPosition] = useState({ const [open, setOpen] = useState(false);
top: 0, const [position, setPosition] = useState({
right: 0, top: 0,
}); right: 0,
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto'); });
const buttonRef = useRef<HTMLButtonElement>(null); const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const panelRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef as any, setOpen); const panelRef = useRef<HTMLDivElement>(null);
const [submenuOpen, setSubmenuOpen] = useState(false); const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
const onSubmenuChange = (open: boolean) => { const onSubmenuChange = (open: boolean) => {
setSubmenuOpen(open); setSubmenuOpen(open);
}; };
return ( return (
<Disclosure <Disclosure
open={open} open={open}
onChange={() => { onChange={async () => {
const rect = buttonRef.current!.getBoundingClientRect(); const rect = buttonRef.current?.getBoundingClientRect();
const { clientHeight } = document.documentElement; if (rect) {
const footerHeight = 32; const { clientHeight } = document.documentElement;
setMaxHeight(clientHeight - rect.bottom - footerHeight - 2); const footerElementRect = document
setPosition({ .getElementById('footer-bar')
top: rect.bottom, ?.getBoundingClientRect();
right: document.body.clientWidth - rect.right, const footerHeightInPx = footerElementRect?.height;
}); if (footerHeightInPx) {
setOpen(!open); setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2);
}} }
> setPosition({
<DisclosureButton top: rect.bottom,
onKeyDown={(event) => { right: document.body.clientWidth - rect.right,
if (event.key === 'Escape' && !submenuOpen) { });
setOpen(false); const newOpenState = !open;
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing();
}
setOpen(newOpenState);
} }
}} }}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button"
> >
<VisuallyHidden>Actions</VisuallyHidden> <DisclosureButton
<Icon type="more" className="block" /> onKeyDown={(event) => {
</DisclosureButton> if (event.key === 'Escape' && !submenuOpen) {
<DisclosurePanel setOpen(false);
onKeyDown={(event) => { }
if (event.key === 'Escape' && !submenuOpen) { }}
setOpen(false); onBlur={closeOnBlur}
buttonRef.current!.focus(); ref={buttonRef}
} className="sn-icon-button"
}} >
ref={panelRef} <VisuallyHidden>Actions</VisuallyHidden>
style={{ <Icon type="more" className="block" />
...position, </DisclosureButton>
maxHeight, <DisclosurePanel
}} onKeyDown={(event) => {
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed" if (event.key === 'Escape' && !submenuOpen) {
onBlur={closeOnBlur} setOpen(false);
> buttonRef.current?.focus();
{open && ( }
<NotesOptions }}
application={application} ref={panelRef}
appState={appState} style={{
closeOnBlur={closeOnBlur} ...position,
onSubmenuChange={onSubmenuChange} maxHeight,
/> }}
)} className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col pt-2 overflow-y-auto fixed"
</DisclosurePanel> onBlur={closeOnBlur}
</Disclosure> >
); {open && (
}); <NotesOptions
application={application}
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel); appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
</Disclosure>
);
}
);

View File

@@ -1,7 +1,3 @@
import {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { KeyboardKey, KeyboardModifier } from '@/services/ioService'; import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
@@ -9,22 +5,33 @@ import { PANEL_NAME_NOTES } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs'; import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { NoAccountWarning } from './NoAccountWarning'; import { NoAccountWarning } from './NoAccountWarning';
import { NotesList } from './NotesList'; import { NotesList } from './NotesList';
import { NotesListOptionsMenu } from './NotesListOptionsMenu'; import { NotesListOptionsMenu } from './NotesListOptionsMenu';
import { PanelResizer } from './PanelResizer';
import { SearchOptions } from './SearchOptions'; import { SearchOptions } from './SearchOptions';
import { toDirective } from './utils'; import {
PanelSide,
ResizeFinishCallback,
PanelResizer,
PanelResizeType,
} from './PanelResizer';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { useCloseOnBlur } from './utils';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
appState: AppState; appState: AppState;
}; };
const NotesView: FunctionComponent<Props> = observer( export const NotesView: FunctionComponent<Props> = observer(
({ application, appState }) => { ({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null); const notesViewPanelRef = useRef<HTMLDivElement>(null);
const displayOptionsMenuRef = useRef<HTMLDivElement>(null);
const { const {
completedFullSync, completedFullSync,
@@ -36,8 +43,6 @@ const NotesView: FunctionComponent<Props> = observer(
renderedNotes, renderedNotes,
selectedNotes, selectedNotes,
setNoteFilterText, setNoteFilterText,
showDisplayOptionsMenu,
toggleDisplayOptionsMenu,
searchBarElement, searchBarElement,
selectNextNote, selectNextNote,
selectPreviousNote, selectPreviousNote,
@@ -46,8 +51,16 @@ const NotesView: FunctionComponent<Props> = observer(
onSearchInputBlur, onSearchInputBlur,
clearFilterText, clearFilterText,
paginate, paginate,
panelWidth,
} = appState.notesView; } = appState.notesView;
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
displayOptionsMenuRef,
setShowDisplayOptionsMenu
);
useEffect(() => { useEffect(() => {
handleFilterTextChanged(); handleFilterTextChanged();
}, [noteFilterText, handleFilterTextChanged]); }, [noteFilterText, handleFilterTextChanged]);
@@ -124,11 +137,12 @@ const NotesView: FunctionComponent<Props> = observer(
}; };
const panelResizeFinishCallback: ResizeFinishCallback = ( const panelResizeFinishCallback: ResizeFinishCallback = (
_lastWidth, width,
_lastLeft, _lastLeft,
_isMaxWidth, _isMaxWidth,
isCollapsed isCollapsed
) => { ) => {
application.setPreference(PrefKey.NotesPanelWidth, width);
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed); appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
}; };
@@ -137,16 +151,20 @@ const NotesView: FunctionComponent<Props> = observer(
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth();
}; };
const toggleDisplayOptionsMenu = () => {
setShowDisplayOptionsMenu(!showDisplayOptionsMenu);
};
return ( return (
<div <div
id="notes-column" id="notes-column"
className="sn-component section notes" className="sn-component section notes app-column app-column-second"
aria-label="Notes" aria-label="Notes"
ref={notesViewPanelRef} ref={notesViewPanelRef}
> >
<div className="content"> <div className="content">
<div id="notes-title-bar" className="section-title-bar"> <div id="notes-title-bar" className="section-title-bar">
<div className="p-4"> <div id="notes-title-bar-container">
<div className="section-title-bar-header"> <div className="section-title-bar-header">
<div className="sk-h2 font-semibold title">{panelTitle}</div> <div className="sk-h2 font-semibold title">{panelTitle}</div>
<button <button
@@ -190,34 +208,42 @@ const NotesView: FunctionComponent<Props> = observer(
</div> </div>
<NoAccountWarning appState={appState} /> <NoAccountWarning appState={appState} />
</div> </div>
<div id="notes-menu-bar" className="sn-component"> <div
id="notes-menu-bar"
className="sn-component"
ref={displayOptionsMenuRef}
>
<div className="sk-app-bar no-edges"> <div className="sk-app-bar no-edges">
<div className="left"> <div className="left">
<div <Disclosure
className={`sk-app-bar-item ${ open={showDisplayOptionsMenu}
showDisplayOptionsMenu ? 'selected' : '' onChange={toggleDisplayOptionsMenu}
}`}
onClick={() =>
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
}
> >
<div className="sk-app-bar-item-column"> <DisclosureButton
<div className="sk-label">Options</div> className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
</div> showDisplayOptionsMenu ? 'selected' : ''
<div className="sk-app-bar-item-column"> }`}
<div className="sk-sublabel">{optionsSubtitle}</div> onBlur={closeDisplayOptMenuOnBlur}
</div> >
</div> <div className="sk-app-bar-item-column">
<div className="sk-label">Options</div>
</div>
<div className="sk-app-bar-item-column">
<div className="sk-sublabel">{optionsSubtitle}</div>
</div>
</DisclosureButton>
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
closeOnBlur={closeDisplayOptMenuOnBlur}
/>
)}
</DisclosurePanel>
</Disclosure>
</div> </div>
</div> </div>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={() =>
toggleDisplayOptionsMenu(false)
}
/>
)}
</div> </div>
</div> </div>
{completedFullSync && !renderedNotes.length ? ( {completedFullSync && !renderedNotes.length ? (
@@ -239,19 +265,19 @@ const NotesView: FunctionComponent<Props> = observer(
</div> </div>
{notesViewPanelRef.current && ( {notesViewPanelRef.current && (
<PanelResizer <PanelResizer
application={application}
collapsable={true} collapsable={true}
hoverable={true}
defaultWidth={300} defaultWidth={300}
panel={document.querySelector('notes-view') as HTMLDivElement} panel={notesViewPanelRef.current}
prefKey={PrefKey.NotesPanelWidth}
side={PanelSide.Right} side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback} resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback} widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/> />
)} )}
</div> </div>
); );
} }
); );
export const NotesViewDirective = toDirective<Props>(NotesView);

View File

@@ -1,60 +1,340 @@
import { import { Component, createRef } from 'preact';
PanelResizerProps, import { debounce } from '@/utils';
PanelResizerState,
} from '@/ui_models/panel_resizer';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
export const PanelResizer: FunctionComponent<PanelResizerProps> = observer( export type ResizeFinishCallback = (
({ lastWidth: number,
alwaysVisible, lastLeft: number,
application, isMaxWidth: boolean,
defaultWidth, isCollapsed: boolean
hoverable, ) => void;
collapsable,
minWidth, export enum PanelSide {
panel, Right = 'right',
prefKey, Left = 'left',
resizeFinishCallback, }
side,
widthEventCallback, export enum PanelResizeType {
}) => { WidthOnly = 'WidthOnly',
const [panelResizerState] = useState( OffsetAndWidth = 'OffsetAndWidth',
() => }
new PanelResizerState({
alwaysVisible, type Props = {
application, width: number;
defaultWidth, left: number;
hoverable, alwaysVisible?: boolean;
collapsable, collapsable?: boolean;
minWidth, defaultWidth?: number;
panel, hoverable?: boolean;
prefKey, minWidth?: number;
resizeFinishCallback, panel: HTMLDivElement;
side, side: PanelSide;
widthEventCallback, type: PanelResizeType;
}) resizeFinishCallback?: ResizeFinishCallback;
widthEventCallback?: () => void;
};
type State = {
collapsed: boolean;
pressed: boolean;
};
export class PanelResizer extends Component<Props, State> {
private overlay?: HTMLDivElement;
private resizerElementRef = createRef<HTMLDivElement>();
private debouncedResizeHandler: () => void;
private startLeft: number;
private startWidth: number;
private lastDownX: number;
private lastLeft: number;
private lastWidth: number;
private widthBeforeLastDblClick: number;
private minWidth: number;
constructor(props: Props) {
super(props);
this.state = {
collapsed: false,
pressed: false,
};
this.minWidth = props.minWidth || 5;
this.startLeft = props.panel.offsetLeft;
this.startWidth = props.panel.scrollWidth;
this.lastDownX = 0;
this.lastLeft = props.panel.offsetLeft;
this.lastWidth = props.panel.scrollWidth;
this.widthBeforeLastDblClick = 0;
this.setWidth(this.props.width);
this.setLeft(this.props.left);
document.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('mousemove', this.onMouseMove);
this.debouncedResizeHandler = debounce(this.handleResize, 250);
if (this.props.type === PanelResizeType.OffsetAndWidth) {
window.addEventListener('resize', this.debouncedResizeHandler);
}
}
componentDidUpdate(prevProps: Props) {
if (this.props.width != prevProps.width) {
this.setWidth(this.props.width);
}
if (this.props.left !== prevProps.left) {
this.setLeft(this.props.left);
this.setWidth(this.props.width);
}
const isCollapsed = this.isCollapsed();
if (isCollapsed !== this.state.collapsed) {
this.setState({ collapsed: isCollapsed });
}
}
componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('resize', this.debouncedResizeHandler);
}
get appFrame() {
return document.getElementById('app')?.getBoundingClientRect() as DOMRect;
}
getParentRect() {
return (this.props.panel.parentNode as HTMLElement).getBoundingClientRect();
}
isAtMaxWidth = () => {
const marginOfError = 5;
const difference = Math.abs(
Math.round(this.lastWidth + this.lastLeft) -
Math.round(this.getParentRect().width)
); );
const panelResizerRef = useRef<HTMLDivElement>(null); return difference < marginOfError;
};
useEffect(() => { isCollapsed() {
if (panelResizerRef.current) { return this.lastWidth <= this.minWidth;
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2); }
finishSettingWidth = () => {
if (!this.props.collapsable) {
return;
}
this.setState({
collapsed: this.isCollapsed(),
});
};
setWidth = (width: number, finish = false): void => {
if (width === 0) {
width = this.computeMaxWidth();
}
if (width < this.minWidth) {
width = this.minWidth;
}
const parentRect = this.getParentRect();
if (width > parentRect.width) {
width = parentRect.width;
}
const maxWidth =
this.appFrame.width - this.props.panel.getBoundingClientRect().x;
if (width > maxWidth) {
width = maxWidth;
}
const isFullWidth =
Math.round(width + this.lastLeft) === Math.round(parentRect.width);
if (isFullWidth) {
if (this.props.type === PanelResizeType.WidthOnly) {
this.props.panel.style.removeProperty('width');
} else {
this.props.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
} }
}, [panelResizerState]); } else {
this.props.panel.style.width = width + 'px';
}
this.lastWidth = width;
if (finish) {
this.finishSettingWidth();
if (this.props.resizeFinishCallback) {
this.props.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed()
);
}
}
};
setLeft = (left: number) => {
this.props.panel.style.left = left + 'px';
this.lastLeft = left;
};
onDblClick = () => {
const collapsed = this.isCollapsed();
if (collapsed) {
this.setWidth(
this.widthBeforeLastDblClick || this.props.defaultWidth || 0
);
} else {
this.widthBeforeLastDblClick = this.lastWidth;
this.setWidth(this.minWidth);
}
this.finishSettingWidth();
this.props.resizeFinishCallback?.(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
this.isCollapsed()
);
};
handleWidthEvent(event?: MouseEvent) {
if (this.props.widthEventCallback) {
this.props.widthEventCallback();
}
let x;
if (event) {
x = event.clientX;
} else {
/** Coming from resize event */
x = 0;
this.lastDownX = 0;
}
const deltaX = x - this.lastDownX;
const newWidth = this.startWidth + deltaX;
this.setWidth(newWidth, false);
}
handleLeftEvent(event: MouseEvent) {
const panelRect = this.props.panel.getBoundingClientRect();
const x = event.clientX || panelRect.x;
let deltaX = x - this.lastDownX;
let newLeft = this.startLeft + deltaX;
if (newLeft < 0) {
newLeft = 0;
deltaX = -this.startLeft;
}
const parentRect = this.getParentRect();
let newWidth = this.startWidth - deltaX;
if (newWidth < this.minWidth) {
newWidth = this.minWidth;
}
if (newWidth > parentRect.width) {
newWidth = parentRect.width;
}
if (newLeft + newWidth > parentRect.width) {
newLeft = parentRect.width - newWidth;
}
this.setLeft(newLeft);
this.setWidth(newWidth, false);
}
computeMaxWidth(): number {
const parentRect = this.getParentRect();
let width = parentRect.width - this.props.left;
if (width < this.minWidth) {
width = this.minWidth;
}
return width;
}
handleResize = () => {
const startWidth = this.isAtMaxWidth()
? this.computeMaxWidth()
: this.props.panel.scrollWidth;
this.startWidth = startWidth;
this.lastWidth = startWidth;
this.handleWidthEvent();
this.finishSettingWidth();
};
onMouseDown = (event: MouseEvent) => {
this.addInvisibleOverlay();
this.lastDownX = event.clientX;
this.startWidth = this.props.panel.scrollWidth;
this.startLeft = this.props.panel.offsetLeft;
this.setState({
pressed: true,
});
};
onMouseUp = () => {
this.removeInvisibleOverlay();
if (!this.state.pressed) {
return;
}
this.setState({ pressed: false });
const isMaxWidth = this.isAtMaxWidth();
if (this.props.resizeFinishCallback) {
this.props.resizeFinishCallback(
this.lastWidth,
this.lastLeft,
isMaxWidth,
this.isCollapsed()
);
}
this.finishSettingWidth();
};
onMouseMove = (event: MouseEvent) => {
if (!this.state.pressed) {
return;
}
event.preventDefault();
if (this.props.side === PanelSide.Left) {
this.handleLeftEvent(event);
} else {
this.handleWidthEvent(event);
}
};
/**
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
* document[onmouseup] is not triggered because the document is no longer the same over
* the iframe. We add an invisible overlay while resizing so that the mouse context
* remains in our main document.
*/
addInvisibleOverlay = () => {
if (this.overlay) {
return;
}
const overlayElement = document.createElement('div');
overlayElement.id = 'resizer-overlay';
this.overlay = overlayElement;
document.body.prepend(this.overlay);
};
removeInvisibleOverlay = () => {
if (this.overlay) {
this.overlay.remove();
this.overlay = undefined;
}
};
render() {
return ( return (
<div <div
className={`panel-resizer ${panelResizerState.side} ${ className={`panel-resizer ${this.props.side} ${
panelResizerState.hoverable ? 'hoverable' : '' this.props.hoverable ? 'hoverable' : ''
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${ } ${this.props.alwaysVisible ? 'alwaysVisible' : ''} ${
panelResizerState.pressed ? 'dragging' : '' this.state.pressed ? 'dragging' : ''
} ${panelResizerState.collapsed ? 'collapsed' : ''}`} } ${this.state.collapsed ? 'collapsed' : ''}`}
onMouseDown={panelResizerState.onMouseDown} onMouseDown={this.onMouseDown}
onDblClick={panelResizerState.onDblClick} onDblClick={this.onDblClick}
ref={panelResizerRef} ref={this.resizerElementRef}
></div> ></div>
); );
} }
); }

View File

@@ -0,0 +1,344 @@
import { WebApplication } from '@/ui_models/application';
import { createRef, JSX } from 'preact';
import { PureComponent } from './Abstract/PureComponent';
interface Props {
application: WebApplication;
}
type State = {
continueTitle: string;
formData: FormData;
isContinuing?: boolean;
lockContinue?: boolean;
processing?: boolean;
showSpinner?: boolean;
step: Steps;
title: string;
};
const DEFAULT_CONTINUE_TITLE = 'Continue';
enum Steps {
PasswordStep = 1,
FinishStep = 2,
}
type FormData = {
currentPassword?: string;
newPassword?: string;
newPasswordConfirmation?: string;
status?: string;
};
export class PasswordWizard extends PureComponent<Props, State> {
private currentPasswordInput = createRef<HTMLInputElement>();
constructor(props: Props) {
super(props, props.application);
this.registerWindowUnloadStopper();
this.state = {
formData: {},
continueTitle: DEFAULT_CONTINUE_TITLE,
step: Steps.PasswordStep,
title: 'Change Password',
};
}
componentDidMount(): void {
super.componentDidMount();
this.currentPasswordInput.current?.focus();
}
componentWillUnmount(): void {
super.componentWillUnmount();
window.onbeforeunload = null;
}
registerWindowUnloadStopper() {
window.onbeforeunload = () => {
return true;
};
}
resetContinueState() {
this.setState({
showSpinner: false,
continueTitle: DEFAULT_CONTINUE_TITLE,
isContinuing: false,
});
}
nextStep = async () => {
if (this.state.lockContinue || this.state.isContinuing) {
return;
}
if (this.state.step === Steps.FinishStep) {
this.dismiss();
return;
}
this.setState({
isContinuing: true,
showSpinner: true,
continueTitle: 'Generating Keys...',
});
const valid = await this.validateCurrentPassword();
if (!valid) {
this.resetContinueState();
return;
}
const success = await this.processPasswordChange();
if (!success) {
this.resetContinueState();
return;
}
this.setState({
isContinuing: false,
showSpinner: false,
continueTitle: 'Finish',
step: Steps.FinishStep,
});
};
async validateCurrentPassword() {
const currentPassword = this.state.formData.currentPassword;
const newPass = this.state.formData.newPassword;
if (!currentPassword || currentPassword.length === 0) {
this.application.alertService.alert(
'Please enter your current password.'
);
return false;
}
if (!newPass || newPass.length === 0) {
this.application.alertService.alert('Please enter a new password.');
return false;
}
if (newPass !== this.state.formData.newPasswordConfirmation) {
this.application.alertService.alert(
'Your new password does not match its confirmation.'
);
this.setFormDataState({
status: undefined,
});
return false;
}
if (!this.application.getUser()?.email) {
this.application.alertService.alert(
"We don't have your email stored. Please sign out then log back in to fix this issue."
);
this.setFormDataState({
status: undefined,
});
return false;
}
/** Validate current password */
const success = await this.application.validateAccountPassword(
this.state.formData.currentPassword!
);
if (!success) {
this.application.alertService.alert(
'The current password you entered is not correct. Please try again.'
);
}
return success;
}
async processPasswordChange() {
await this.application.downloadBackup();
this.setState({
lockContinue: true,
processing: true,
});
await this.setFormDataState({
status: 'Processing encryption keys…',
});
const newPassword = this.state.formData.newPassword;
const response = await this.application.changePassword(
this.state.formData.currentPassword!,
newPassword!
);
const success = !response.error;
this.setState({
processing: false,
lockContinue: false,
});
if (!success) {
this.setFormDataState({
status: 'Unable to process your password. Please try again.',
});
} else {
this.setState({
formData: {
...this.state.formData,
status: 'Successfully changed password.',
},
});
}
return success;
}
dismiss = () => {
if (this.state.lockContinue) {
this.application.alertService.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
this.dismissModal();
}
};
async setFormDataState(formData: Partial<FormData>) {
return this.setState({
formData: {
...this.state.formData,
...formData,
},
});
}
handleCurrentPasswordInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
currentPassword: currentTarget.value,
});
};
handleNewPasswordInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
newPassword: currentTarget.value,
});
};
handleNewPasswordConfirmationInputChange = ({
currentTarget,
}: JSX.TargetedEvent<HTMLInputElement, Event>) => {
this.setFormDataState({
newPasswordConfirmation: currentTarget.value,
});
};
render() {
return (
<div className="sn-component">
<div id="password-wizard" className="sk-modal small auto-height">
<div className="sk-modal-background" />
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{this.state.title}
</div>
<a onClick={this.dismiss} className="sk-a info close-button">
Close
</a>
</div>
<div className="sk-panel-content">
{this.state.step === Steps.PasswordStep && (
<div className="sk-panel-section">
<div className="sk-panel-row">
<div className="sk-panel-column stretch">
<form className="sk-panel-form">
<label
htmlFor="password-wiz-current-password"
className="block mb-1"
>
Current Password
</label>
<input
ref={this.currentPasswordInput}
id="password-wiz-current-password"
value={this.state.formData.currentPassword}
onChange={this.handleCurrentPasswordInputChange}
type="password"
className="sk-input contrast"
/>
<div className="sk-panel-row" />
<label
htmlFor="password-wiz-new-password"
className="block mb-1"
>
New Password
</label>
<input
id="password-wiz-new-password"
value={this.state.formData.newPassword}
onChange={this.handleNewPasswordInputChange}
type="password"
className="sk-input contrast"
/>
<div className="sk-panel-row" />
<label
htmlFor="password-wiz-confirm-new-password"
className="block mb-1"
>
Confirm New Password
</label>
<input
id="password-wiz-confirm-new-password"
value={
this.state.formData.newPasswordConfirmation
}
onChange={
this.handleNewPasswordConfirmationInputChange
}
type="password"
className="sk-input contrast"
/>
</form>
</div>
</div>
</div>
)}
{this.state.step === Steps.FinishStep && (
<div className="sk-panel-section">
<div className="sk-label sk-bold info">
Your password has been successfully changed.
</div>
<p className="sk-p">
Please ensure you are running the latest version of
Standard Notes on all platforms to ensure maximum
compatibility.
</p>
</div>
)}
</div>
<div className="sk-panel-footer">
<button
onClick={this.nextStep}
disabled={this.state.lockContinue}
className="sn-button min-w-20 info"
>
{this.state.continueTitle}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,94 @@
import { WebApplication } from '@/ui_models/application';
import { SNComponent } from '@standardnotes/snjs';
import { Component } from 'preact';
import { findDOMNode, unmountComponentAtNode } from 'preact/compat';
interface Props {
application: WebApplication;
callback: (approved: boolean) => void;
component: SNComponent;
permissionsString: string;
}
export class PermissionsModal extends Component<Props> {
getElement(): Element | null {
return findDOMNode(this);
}
dismiss = () => {
const elem = this.getElement();
if (!elem) {
return;
}
const parent = elem.parentElement;
if (!parent) {
return;
}
parent.remove();
unmountComponentAtNode(parent);
};
accept = () => {
this.props.callback(true);
this.dismiss();
};
deny = () => {
this.props.callback(false);
this.dismiss();
};
render() {
return (
<div className="sk-modal">
<div onClick={this.deny} className="sk-modal-background" />
<div id="permissions-modal" className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">Activate Component</div>
<a onClick={this.deny} className="sk-a info close-button">
Cancel
</a>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-panel-row">
<div className="sk-h2">
<strong>{this.props.component.name}</strong>
{' would like to interact with your '}
{this.props.permissionsString}
</div>
</div>
<div className="sk-panel-row">
<p className="sk-p">
Components use an offline messaging system to communicate.
Learn more at{' '}
<a
href="https://standardnotes.com/permissions"
rel="noopener"
target="_blank"
className="sk-a info"
>
https://standardnotes.com/permissions.
</a>
</p>
</div>
</div>
</div>
<div className="sk-panel-footer">
<button
onClick={this.accept}
className="sn-button info block w-full text-base py-3"
>
Continue
</button>
</div>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -3,19 +3,22 @@ import VisuallyHidden from '@reach/visually-hidden';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { toDirective } from './utils';
type Props = { type Props = {
appState: AppState; appState: AppState;
className?: string; className?: string;
onClickPreprocessing?: () => Promise<void>;
}; };
export const PinNoteButton: FunctionComponent<Props> = observer( export const PinNoteButton: FunctionComponent<Props> = observer(
({ appState, className = '' }) => { ({ appState, className = '', onClickPreprocessing }) => {
const notes = Object.values(appState.notes.selectedNotes); const notes = Object.values(appState.notes.selectedNotes);
const pinned = notes.some((note) => note.pinned); const pinned = notes.some((note) => note.pinned);
const togglePinned = () => { const togglePinned = async () => {
if (onClickPreprocessing) {
await onClickPreprocessing();
}
if (!pinned) { if (!pinned) {
appState.notes.setPinSelectedNotes(true); appState.notes.setPinSelectedNotes(true);
} else { } else {
@@ -34,5 +37,3 @@ export const PinNoteButton: FunctionComponent<Props> = observer(
); );
} }
); );
export const PinNoteButtonDirective = toDirective<Props>(PinNoteButton);

View File

@@ -1,7 +1,7 @@
import { FeaturesState } from '@/ui_models/app_state/features_state'; import { FeaturesState } from '@/ui_models/app_state/features_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
import { useCallback, useContext, useState } from 'preact/hooks'; import { useContext } from 'preact/hooks';
import { createContext } from 'react'; import { createContext } from 'react';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal'; import { PremiumFeaturesModal } from '../PremiumFeaturesModal';

View File

@@ -51,7 +51,7 @@ export const PremiumFeaturesModal: FunctionalComponent<Props> = ({
<PremiumIllustration className="mb-2" /> <PremiumIllustration className="mb-2" />
</div> </div>
<div className="text-lg text-center font-bold mb-1"> <div className="text-lg text-center font-bold mb-1">
Enable premium features Enable Premium Features
</div> </div>
</AlertDialogLabel> </AlertDialogLabel>
<AlertDialogDescription className="text-sm text-center color-grey-1 px-4.5 mb-2"> <AlertDialogDescription className="text-sm text-center color-grey-1 px-4.5 mb-2">
@@ -65,7 +65,7 @@ export const PremiumFeaturesModal: FunctionalComponent<Props> = ({
className="w-full rounded no-border py-2 font-bold bg-info color-info-contrast hover:brightness-130 focus:brightness-130 cursor-pointer" className="w-full rounded no-border py-2 font-bold bg-info color-info-contrast hover:brightness-130 focus:brightness-130 cursor-pointer"
ref={plansButtonRef} ref={plansButtonRef}
> >
See our plans See Plans
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,4 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { toDirective } from './utils';
type Props = { type Props = {
appState: AppState; appState: AppState;
@@ -7,7 +6,7 @@ type Props = {
hasProtectionSources: boolean; hasProtectionSources: boolean;
}; };
function ProtectedNoteOverlay({ export function ProtectedNoteOverlay({
appState, appState,
onViewNote, onViewNote,
hasProtectionSources, hasProtectionSources,
@@ -41,11 +40,3 @@ function ProtectedNoteOverlay({
</div> </div>
); );
} }
export const ProtectedNoteOverlayDirective = toDirective<Props>(
ProtectedNoteOverlay,
{
onViewNote: '&',
hasProtectionSources: '=',
}
);

View File

@@ -1,10 +1,10 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs'; import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useCallback, useState } from 'preact/hooks'; import { useCallback } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal'; import { usePremiumModal } from '../Premium';
import { Switch } from '../Switch'; import { Switch } from '../Switch';
type Props = { type Props = {
@@ -20,7 +20,7 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
onClose, onClose,
isEnabled, isEnabled,
}) => { }) => {
const [showUpgradeModal, setShowUpgradeModal] = useState(false); const premiumModal = usePremiumModal();
const isEntitled = const isEntitled =
application.getFeatureStatus(FeatureIdentifier.FocusMode) === application.getFeatureStatus(FeatureIdentifier.FocusMode) ===
FeatureStatus.Entitled; FeatureStatus.Entitled;
@@ -33,10 +33,10 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
onToggle(!isEnabled); onToggle(!isEnabled);
onClose(); onClose();
} else { } else {
setShowUpgradeModal(true); premiumModal.activate('Focused Writing');
} }
}, },
[isEntitled, isEnabled, onToggle, setShowUpgradeModal, onClose] [isEntitled, onToggle, isEnabled, onClose, premiumModal]
); );
return ( return (
@@ -57,11 +57,6 @@ export const FocusModeSwitch: FunctionComponent<Props> = ({
</div> </div>
)} )}
</button> </button>
<PremiumFeaturesModal
showModal={showUpgradeModal}
featureName="Focus Mode"
onClose={() => setShowUpgradeModal(false)}
/>
</> </>
); );
}; };

View File

@@ -8,6 +8,8 @@ import {
import { import {
ComponentArea, ComponentArea,
ContentType, ContentType,
FeatureIdentifier,
GetFeatures,
SNComponent, SNComponent,
SNTheme, SNTheme,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
@@ -17,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { Switch } from '../Switch'; import { Switch } from '../Switch';
import { toDirective, useCloseOnBlur } from '../utils'; import { useCloseOnBlur, useCloseOnClickOutside } from '../utils';
import { import {
quickSettingsKeyDownHandler, quickSettingsKeyDownHandler,
themesMenuKeyDownHandler, themesMenuKeyDownHandler,
@@ -30,9 +32,16 @@ const focusModeAnimationDuration = 1255;
const MENU_CLASSNAME = const MENU_CLASSNAME =
'sn-menu-border sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto'; 'sn-menu-border sn-dropdown min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto';
export type ThemeItem = {
name: string;
identifier: FeatureIdentifier;
component?: SNTheme;
};
type MenuProps = { type MenuProps = {
appState: AppState; appState: AppState;
application: WebApplication; application: WebApplication;
onClickOutside: () => void;
}; };
const toggleFocusMode = (enabled: boolean) => { const toggleFocusMode = (enabled: boolean) => {
@@ -62,15 +71,15 @@ export const sortThemes = (a: SNTheme, b: SNTheme) => {
} }
}; };
const QuickSettingsMenu: FunctionComponent<MenuProps> = observer( export const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
({ application, appState }) => { ({ application, appState, onClickOutside }) => {
const { const {
closeQuickSettingsMenu, closeQuickSettingsMenu,
shouldAnimateCloseMenu, shouldAnimateCloseMenu,
focusModeEnabled, focusModeEnabled,
setFocusModeEnabled, setFocusModeEnabled,
} = appState.quickSettingsMenu; } = appState.quickSettingsMenu;
const [themes, setThemes] = useState<SNTheme[]>([]); const [themes, setThemes] = useState<ThemeItem[]>([]);
const [toggleableComponents, setToggleableComponents] = useState< const [toggleableComponents, setToggleableComponents] = useState<
SNComponent[] SNComponent[]
>([]); >([]);
@@ -84,31 +93,72 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
const quickSettingsMenuRef = useRef<HTMLDivElement>(null); const quickSettingsMenuRef = useRef<HTMLDivElement>(null);
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null); const defaultThemeButtonRef = useRef<HTMLButtonElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(mainRef, () => {
onClickOutside();
});
useEffect(() => { useEffect(() => {
toggleFocusMode(focusModeEnabled); toggleFocusMode(focusModeEnabled);
}, [focusModeEnabled]); }, [focusModeEnabled]);
const reloadThemes = useCallback(() => { const reloadThemes = useCallback(() => {
const themes = application.getDisplayableItems( const themes = (
ContentType.Theme application.getDisplayableItems(ContentType.Theme) as SNTheme[]
) as SNTheme[]; )
setThemes(themes.sort(sortThemes)); .sort(sortThemes)
.map((item) => {
return {
name: item.name,
identifier: item.identifier,
component: item,
};
}) as ThemeItem[];
GetFeatures()
.filter(
(feature) =>
feature.content_type === ContentType.Theme && !feature.layerable
)
.forEach((theme) => {
if (
themes.findIndex((item) => item.identifier === theme.identifier) ===
-1
) {
themes.push({
name: theme.name as string,
identifier: theme.identifier,
});
}
});
setThemes(themes);
setDefaultThemeOn( setDefaultThemeOn(
!themes.find((theme) => theme.active && !theme.isLayerable()) !themes
.map((item) => item?.component)
.find((theme) => theme?.active && !theme.isLayerable())
); );
}, [application]); }, [application]);
const reloadToggleableComponents = useCallback(() => { const reloadToggleableComponents = useCallback(() => {
const toggleableComponents = ( const toggleableComponents = (
application.getDisplayableItems(ContentType.Component) as SNComponent[] application.getDisplayableItems(ContentType.Component) as SNComponent[]
).filter((component) => ).filter(
[ComponentArea.EditorStack, ComponentArea.TagsList].includes( (component) =>
component.area [ComponentArea.EditorStack, ComponentArea.TagsList].includes(
) component.area
) && component.identifier !== FeatureIdentifier.FoldersComponent
); );
setToggleableComponents(toggleableComponents); setToggleableComponents(toggleableComponents);
}, [application]); }, [application]);
useEffect(() => {
if (!themes.length) {
reloadThemes();
}
}, [reloadThemes, themes.length]);
useEffect(() => { useEffect(() => {
const cleanupItemStream = application.streamItems( const cleanupItemStream = application.streamItems(
ContentType.Theme, ContentType.Theme,
@@ -145,10 +195,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
prefsButtonRef.current?.focus(); prefsButtonRef.current?.focus();
}, []); }, []);
const [closeOnBlur] = useCloseOnBlur( const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen);
themesMenuRef as any,
setThemesMenuOpen
);
const toggleThemesMenu = () => { const toggleThemesMenu = () => {
if (!themesMenuOpen && themesButtonRef.current) { if (!themesMenuOpen && themesButtonRef.current) {
@@ -216,14 +263,14 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
}; };
const toggleDefaultTheme = () => { const toggleDefaultTheme = () => {
const activeTheme = themes.find( const activeTheme = themes
(theme) => theme.active && !theme.isLayerable() .map((item) => item.component)
); .find((theme) => theme?.active && !theme.isLayerable());
if (activeTheme) application.toggleTheme(activeTheme); if (activeTheme) application.toggleTheme(activeTheme);
}; };
return ( return (
<div className="sn-component"> <div ref={mainRef} className="sn-component">
<div <div
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${ className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
shouldAnimateCloseMenu shouldAnimateCloseMenu
@@ -236,56 +283,54 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
<div className="px-3 mt-1 mb-2 font-semibold color-text uppercase"> <div className="px-3 mt-1 mb-2 font-semibold color-text uppercase">
Quick Settings Quick Settings
</div> </div>
{themes && themes.length ? ( <Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}> <DisclosureButton
<DisclosureButton onKeyDown={handleBtnKeyDown}
onKeyDown={handleBtnKeyDown} onBlur={closeOnBlur}
ref={themesButtonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="themes" className="color-neutral mr-2" />
Themes
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
onBlur={closeOnBlur}
ref={themesMenuRef}
onKeyDown={handlePanelKeyDown}
style={{
...themesMenuPosition,
}}
className={`${MENU_CLASSNAME} fixed sn-dropdown--animated`}
>
<div className="px-3 my-1 font-semibold color-text uppercase">
Themes
</div>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
onClick={toggleDefaultTheme}
onBlur={closeOnBlur} onBlur={closeOnBlur}
ref={themesButtonRef} ref={defaultThemeButtonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
> >
<div className="flex items-center"> <div
<Icon type="themes" className="color-neutral mr-2" /> className={`pseudo-radio-btn ${
Themes defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
</div> } mr-2`}
<Icon type="chevron-right" className="color-neutral" /> ></div>
</DisclosureButton> Default
<DisclosurePanel </button>
onBlur={closeOnBlur} {themes.map((theme) => (
ref={themesMenuRef} <ThemesMenuButton
onKeyDown={handlePanelKeyDown} item={theme}
style={{ application={application}
...themesMenuPosition, key={theme.component?.uuid ?? theme.identifier}
}}
className={`${MENU_CLASSNAME} fixed sn-dropdown--animated`}
>
<div className="px-3 my-1 font-semibold color-text uppercase">
Themes
</div>
<button
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none"
onClick={toggleDefaultTheme}
onBlur={closeOnBlur} onBlur={closeOnBlur}
ref={defaultThemeButtonRef} />
> ))}
<div </DisclosurePanel>
className={`pseudo-radio-btn ${ </Disclosure>
defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
Default
</button>
{themes.map((theme) => (
<ThemesMenuButton
theme={theme}
application={application}
key={theme.uuid}
onBlur={closeOnBlur}
/>
))}
</DisclosurePanel>
</Disclosure>
) : null}
{toggleableComponents.map((component) => ( {toggleableComponents.map((component) => (
<button <button
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none" className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
@@ -320,6 +365,3 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
); );
} }
); );
export const QuickSettingsMenuDirective =
toDirective<MenuProps>(QuickSettingsMenu);

View File

@@ -1,58 +1,94 @@
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { SNTheme } from '@standardnotes/snjs'; import { FeatureStatus } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon';
import { usePremiumModal } from '../Premium';
import { Switch } from '../Switch'; import { Switch } from '../Switch';
import { ThemeItem } from './QuickSettingsMenu';
type Props = { type Props = {
theme: SNTheme; item: ThemeItem;
application: WebApplication; application: WebApplication;
onBlur: (event: { relatedTarget: EventTarget | null }) => void; onBlur: (event: { relatedTarget: EventTarget | null }) => void;
}; };
export const ThemesMenuButton: FunctionComponent<Props> = ({ export const ThemesMenuButton: FunctionComponent<Props> = ({
application, application,
theme, item,
onBlur, onBlur,
}) => { }) => {
const premiumModal = usePremiumModal();
const isThirdPartyTheme = useMemo(
() => application.isThirdPartyFeature(item.identifier),
[application, item.identifier]
);
const isEntitledToTheme = useMemo(
() =>
application.getFeatureStatus(item.identifier) === FeatureStatus.Entitled,
[application, item.identifier]
);
const canActivateTheme = useMemo(
() => isEntitledToTheme || isThirdPartyTheme,
[isEntitledToTheme, isThirdPartyTheme]
);
const toggleTheme: JSXInternal.MouseEventHandler<HTMLButtonElement> = (e) => { const toggleTheme: JSXInternal.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault(); e.preventDefault();
if (theme.isLayerable() || !theme.active) {
application.toggleTheme(theme); if (item.component && canActivateTheme) {
const themeIsLayerableOrNotActive =
item.component.isLayerable() || !item.component.active;
if (themeIsLayerableOrNotActive) {
application.toggleTheme(item.component);
}
} else {
premiumModal.activate(`${item.name} theme`);
} }
}; };
return ( return (
<button <button
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${ className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between`}
theme.isLayerable() ? `justify-start` : `justify-between`
}`}
onClick={toggleTheme} onClick={toggleTheme}
onBlur={onBlur} onBlur={onBlur}
> >
{theme.isLayerable() ? ( {item.component?.isLayerable() ? (
<> <>
<Switch className="px-0 mr-2" checked={theme.active} /> <div className="flex items-center">
{theme.package_info.name} <Switch className="px-0 mr-2" checked={item.component?.active} />
{item.name}
</div>
{!canActivateTheme && <Icon type="premium-feature" />}
</> </>
) : ( ) : (
<> <>
<div className="flex items-center"> <div className="flex items-center">
<div <div
className={`pseudo-radio-btn ${ className={`pseudo-radio-btn ${
theme.active ? 'pseudo-radio-btn--checked' : '' item.component?.active ? 'pseudo-radio-btn--checked' : ''
} mr-2`} } mr-2`}
></div> ></div>
<span className={theme.active ? 'font-semibold' : undefined}> <span
{theme.package_info.name} className={item.component?.active ? 'font-semibold' : undefined}
>
{item.name}
</span> </span>
</div> </div>
<div {item.component && canActivateTheme ? (
className="w-5 h-5 rounded-full" <div
style={{ className="w-5 h-5 rounded-full"
backgroundColor: theme.package_info?.dock_icon?.background_color, style={{
}} backgroundColor:
></div> item.component.package_info?.dock_icon?.background_color,
}}
></div>
) : (
<Icon type="premium-feature" />
)}
</> </>
)} )}
</button> </button>

View File

@@ -0,0 +1,181 @@
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
import { WebApplication } from '@/ui_models/application';
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
import { PayloadContent } from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
import { PureComponent } from './Abstract/PureComponent';
import { ComponentView } from './ComponentView';
interface Props {
application: WebApplication;
content: PayloadContent;
title?: string;
uuid: string;
}
type State = {
componentViewer?: ComponentViewer;
};
export class RevisionPreviewModal extends PureComponent<Props, State> {
private originalNote!: SNNote;
constructor(props: Props) {
super(props, props.application);
}
async componentDidMount(): Promise<void> {
super.componentDidMount();
const templateNote = (await this.application.createTemplateItem(
ContentType.Note,
this.props.content
)) as SNNote;
this.originalNote = this.application.findItem(this.props.uuid) as SNNote;
const component = this.application.componentManager.editorForNote(
this.originalNote
);
if (component) {
const componentViewer =
this.application.componentManager.createComponentViewer(component);
componentViewer.setReadonly(true);
componentViewer.lockReadonly = true;
componentViewer.overrideContextItem = templateNote;
this.setState({ componentViewer });
}
}
componentWillUnmount(): void {
if (this.state.componentViewer) {
this.application.componentManager.destroyComponentViewer(
this.state.componentViewer
);
}
super.componentWillUnmount();
}
restore = (asCopy: boolean) => {
const run = async () => {
if (asCopy) {
await this.application.duplicateItem(this.originalNote, {
...this.props.content,
title: this.props.content.title
? this.props.content.title + ' (copy)'
: undefined,
});
} else {
this.application.changeAndSaveItem(
this.props.uuid,
(mutator) => {
mutator.unsafe_setCustomContent(this.props.content);
},
true,
PayloadSource.RemoteActionRetrieved
);
}
this.dismiss();
};
if (!asCopy) {
if (this.originalNote.locked) {
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
return;
}
confirmDialog({
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
confirmButtonStyle: 'danger',
}).then((confirmed) => {
if (confirmed) {
run();
}
});
} else {
run();
}
};
dismiss = ($event?: Event) => {
$event?.stopPropagation();
this.dismissModal();
};
render() {
return (
<div className="sn-component">
<div id="item-preview-modal" className="sk-modal medium">
<div className="sk-modal-background" />
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div>
<div className="sk-panel-header-title">Preview</div>
{this.props.title && (
<div className="sk-subtitle neutral mt-1">
{this.props.title}
</div>
)}
</div>
<div className="sk-horizontal-group">
<a
onClick={() => this.restore(false)}
className="sk-a info close-button"
>
Restore
</a>
<a
onClick={() => this.restore(true)}
className="sk-a info close-button"
>
Restore as copy
</a>
<a
onClick={this.dismiss}
className="sk-a info close-button"
>
Close
</a>
</div>
</div>
{!this.state.componentViewer && (
<div className="sk-panel-content selectable">
<div className="sk-h2">{this.props.content.title}</div>
<p
style="white-space: pre-wrap; font-size: 16px;"
className="normal sk-p"
>
{this.props.content.text}
</p>
</div>
)}
{this.state.componentViewer && (
<>
<div
style="height: auto; flex-grow: 0"
className="sk-panel-content sk-h2"
>
{this.props.content.title}
</div>
<div className="component-view">
<ComponentView
componentViewer={this.state.componentViewer}
application={this.application}
appState={this.appState}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,5 +1,6 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { Icon, IconType } from './Icon'; import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
type ButtonType = 'normal' | 'primary'; type ButtonType = 'normal' | 'primary';

View File

@@ -1,6 +1,6 @@
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { toDirective, useCloseOnBlur } from './utils'; import { useCloseOnBlur } from './utils';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden'; import VisuallyHidden from '@reach/visually-hidden';
@@ -114,5 +114,3 @@ export const SearchOptions = observer(({ appState }: Props) => {
</Disclosure> </Disclosure>
); );
}); });
export const SearchOptionsDirective = toDirective<Props>(SearchOptions);

View File

@@ -15,7 +15,6 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogLabel, AlertDialogLabel,
} from '@reach/alert-dialog'; } from '@reach/alert-dialog';
import { toDirective } from './utils';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@@ -26,12 +25,12 @@ type Session = RemoteSession & {
function useSessions( function useSessions(
application: SNApplication application: SNApplication
): [ ): [
Session[], Session[],
() => void, () => void,
boolean, boolean,
(uuid: UuidString) => Promise<void>, (uuid: UuidString) => Promise<void>,
string string
] { ] {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [lastRefreshDate, setLastRefreshDate] = useState(Date.now()); const [lastRefreshDate, setLastRefreshDate] = useState(Date.now());
const [refreshing, setRefreshing] = useState(true); const [refreshing, setRefreshing] = useState(true);
@@ -93,19 +92,14 @@ function useSessions(
return [sessions, refresh, refreshing, revokeSession, errorMessage]; return [sessions, refresh, refreshing, revokeSession, errorMessage];
} }
const SessionsModal: FunctionComponent<{ const SessionsModalContent: FunctionComponent<{
appState: AppState; appState: AppState;
application: SNApplication; application: SNApplication;
}> = ({ appState, application }) => { }> = ({ appState, application }) => {
const close = () => appState.closeSessionsModal(); const close = () => appState.closeSessionsModal();
const [ const [sessions, refresh, refreshing, revokeSession, errorMessage] =
sessions, useSessions(application);
refresh,
refreshing,
revokeSession,
errorMessage,
] = useSessions(application);
const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState(''); const [confirmRevokingSessionUuid, setRevokingSessionUuid] = useState('');
const closeRevokeSessionAlert = () => setRevokingSessionUuid(''); const closeRevokeSessionAlert = () => setRevokingSessionUuid('');
@@ -240,15 +234,15 @@ const SessionsModal: FunctionComponent<{
); );
}; };
export const Sessions: FunctionComponent<{ export const SessionsModal: FunctionComponent<{
appState: AppState; appState: AppState;
application: WebApplication; application: WebApplication;
}> = observer(({ appState, application }) => { }> = observer(({ appState, application }) => {
if (appState.isSessionsModalVisible) { if (appState.isSessionsModalVisible) {
return <SessionsModal application={application} appState={appState} />; return (
<SessionsModalContent application={application} appState={appState} />
);
} else { } else {
return null; return null;
} }
}); });
export const SessionsModalDirective = toDirective(Sessions);

View File

@@ -29,7 +29,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
return ( return (
<label <label
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className} ${isDisabled ? 'faded' : ''}`} className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className} ${
isDisabled ? 'faded' : ''
}`}
{...(props.role ? { role: props.role } : {})} {...(props.role ? { role: props.role } : {})}
> >
{props.children} {props.children}
@@ -51,8 +53,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
/> />
<span <span
aria-hidden aria-hidden
className={`sn-switch-handle ${checked ? 'sn-switch-handle--right' : '' className={`sn-switch-handle ${
}`} checked ? 'sn-switch-handle--right' : ''
}`}
/> />
</CustomCheckboxContainer> </CustomCheckboxContainer>
</label> </label>

View File

@@ -0,0 +1,187 @@
import { WebApplication } from '@/ui_models/application';
import { PureComponent } from './Abstract/PureComponent';
import { Fragment } from 'preact';
type Props = {
application: WebApplication;
close: () => void;
};
export class SyncResolutionMenu extends PureComponent<Props> {
private status: Partial<{
backupFinished: boolean;
resolving: boolean;
attemptedResolution: boolean;
success: boolean;
fail: boolean;
}> = {};
constructor(props: Props) {
super(props, props.application);
}
downloadBackup(encrypted: boolean) {
this.props.application.getArchiveService().downloadBackup(encrypted);
this.status.backupFinished = true;
}
skipBackup() {
this.status.backupFinished = true;
}
async performSyncResolution() {
this.status.resolving = true;
await this.props.application.resolveOutOfSync();
this.status.resolving = false;
this.status.attemptedResolution = true;
if (this.props.application.isOutOfSync()) {
this.status.fail = true;
} else {
this.status.success = true;
}
}
close() {
this.props.close();
}
render() {
return (
<div className="sn-component">
<div id="sync-resolution-menu" className="sk-panel sk-panel-right">
<div className="sk-panel-header">
<div className="sk-panel-header-title">Out of Sync</div>
<a onClick={this.close} className="sk-a info close-button">
Close
</a>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-panel-row sk-p">
We've detected that the data on the server may not match the
data in the current application session.
</div>
<div className="sk-p sk-panel-row">
<div className="sk-panel-column">
<strong className="sk-panel-row">
Option 1 Restart App:
</strong>
<div className="sk-p">
Quit the application and re-open it. Sometimes, this may
resolve the issue.
</div>
</div>
</div>
<div className="sk-p sk-panel-row">
<div className="sk-panel-column">
<strong className="sk-panel-row">
Option 2 (recommended) Sign Out:
</strong>
<div className="sk-p">
Sign out of your account, then sign back in. This will
ensure your data is consistent with the server.
</div>
Be sure to download a backup of your data before doing so.
</div>
</div>
<div className="sk-p sk-panel-row">
<div className="sk-panel-column">
<strong className="sk-panel-row">
Option 3 Sync Resolution:
</strong>
<div className="sk-p">
We can attempt to reconcile changes by downloading all data
from the server. No existing data will be overwritten. If
the local contents of an item differ from what the server
has, a conflicted copy will be created.
</div>
</div>
</div>
{!this.status.backupFinished && (
<Fragment>
<div className="sk-p sk-panel-row">
Please download a backup before we attempt to perform a full
account sync resolution.
</div>
<div className="sk-panel-row">
<div className="flex gap-2">
<button
onClick={() => this.downloadBackup(true)}
className="sn-button small info"
>
Encrypted
</button>
<button
onClick={() => this.downloadBackup(false)}
className="sn-button small info"
>
Decrypted
</button>
<button
onClick={this.skipBackup}
className="sn-button small danger"
>
Skip
</button>
</div>
</div>
</Fragment>
)}
{this.status.backupFinished && (
<div>
{!this.status.resolving && !this.status.attemptedResolution && (
<div className="sk-panel-row">
<button
onClick={this.performSyncResolution}
className="sn-button small info"
>
Perform Sync Resolution
</button>
</div>
)}
{this.status.resolving && (
<div className="sk-panel-row justify-left">
<div className="sk-horizontal-group">
<div className="sk-spinner small info" />
<div className="sk-label">
Attempting sync resolution...
</div>
</div>
</div>
)}
{this.status.fail && (
<div className="sk-panel-column">
<div className="sk-panel-row sk-label danger">
Sync Resolution Failed
</div>
<div className="sk-p sk-panel-row">
We attempted to reconcile local content and server
content, but were unable to do so. At this point, we
recommend signing out of your account and signing back
in. You may wish to download a data backup before doing
so.
</div>
</div>
)}
{this.status.success && (
<div className="sk-panel-column">
<div className="sk-panel-row sk-label success">
Sync Resolution Success
</div>
<div className="sk-p sk-panel-row">
Your local data is now in sync with the server. You may
close this window.
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -1,9 +1,6 @@
import { Icon } from '@/components/Icon'; import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium'; import { usePremiumModal } from '@/components/Premium';
import { import { FeaturesState } from '@/ui_models/app_state/features_state';
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state'; import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
@@ -14,51 +11,38 @@ type Props = {
featuresState: FeaturesState; featuresState: FeaturesState;
}; };
export const RootTagDropZone: React.FC<Props> = observer( export const RootTagDropZone: React.FC<Props> = observer(({ tagsState }) => {
({ tagsState, featuresState }) => { const premiumModal = usePremiumModal();
const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
const hasFolders = featuresState.hasFolders;
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>( const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({ () => ({
accept: ItemTypes.TAG, accept: ItemTypes.TAG,
canDrop: () => { canDrop: (item) => {
return true; return tagsState.hasParent(item.uuid);
}, },
drop: (item) => { drop: (item) => {
if (!hasFolders) { tagsState.assignParent(item.uuid, undefined);
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME); },
return; collect: (monitor) => ({
} isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
tagsState.assignParent(item.uuid, undefined);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}), }),
[tagsState, hasFolders, premiumModal] }),
); [tagsState, premiumModal]
);
if (!isNativeFoldersEnabled || !hasFolders) { return (
return null; <div
} ref={dropRef}
className={`root-drop ${canDrop ? 'active' : ''} ${
return ( isOver ? 'is-drag-over' : ''
<div }`}
ref={dropRef} >
className={`root-drop ${canDrop ? 'active' : ''} ${ <Icon className="color-neutral" type="link-off" />
isOver ? 'is-drag-over' : '' <p className="content">
}`} Move the tag here to <br />
> remove it from its folder.
<Icon className="color-neutral" type="link-off" /> </p>
<p className="content"> </div>
Move the tag here to <br /> );
remove it from its folder. });
</p>
</div>
);
}
);

View File

@@ -1,8 +1,8 @@
import { Icon, IconType } from '@/components/Icon'; import { Icon } from '@/components/Icon';
import { FeaturesState } from '@/ui_models/app_state/features_state'; import { FeaturesState } from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state'; import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css'; import '@reach/tooltip/styles.css';
import { SNSmartTag } from '@standardnotes/snjs'; import { SNSmartTag, IconType } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
@@ -37,7 +37,6 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
const level = 0; const level = 0;
const isSelected = tagsState.selected === tag; const isSelected = tagsState.selected === tag;
const isEditing = tagsState.editingTag === tag; const isEditing = tagsState.editingTag === tag;
const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
useEffect(() => { useEffect(() => {
setTitle(tag.title || ''); setTitle(tag.title || '');
@@ -88,7 +87,7 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
tagsState.remove(tag); tagsState.remove(tag);
}, [tagsState, tag]); }, [tagsState, tag]);
const isFaded = !isSmartTagsEnabled && !tag.isAllTag; const isFaded = !tag.isAllTag;
const iconType = smartTagIconType(tag); const iconType = smartTagIconType(tag);
return ( return (
@@ -104,14 +103,12 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
> >
{!tag.errorDecrypting ? ( {!tag.errorDecrypting ? (
<div className="tag-info"> <div className="tag-info">
{isSmartTagsEnabled && ( <div className={`tag-icon mr-1`}>
<div className={`tag-icon mr-1`}> <Icon
<Icon type={iconType}
type={iconType} className={`${isSelected ? 'color-info' : 'color-neutral'}`}
className={`${isSelected ? 'color-info' : 'color-neutral'}`} />
/> </div>
</div>
)}
<input <input
className={`title ${isEditing ? 'editing' : ''}`} className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`} id={`react-tag-${tag.uuid}`}

View File

@@ -22,7 +22,7 @@ export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
<DndProvider backend={backend}> <DndProvider backend={backend}>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<div className="no-tags-placeholder"> <div className="no-tags-placeholder">
No tags. Create one using the add button above. No tags or folders. Create one using the add button above.
</div> </div>
) : ( ) : (
<> <>

View File

@@ -37,12 +37,11 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const hasChildren = childrenTags.length > 0; const hasChildren = childrenTags.length > 0;
const hasFolders = features.hasFolders; const hasFolders = features.hasFolders;
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder; const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
const premiumModal = usePremiumModal(); const premiumModal = usePremiumModal();
const [showChildren, setShowChildren] = useState(hasChildren); const [showChildren, setShowChildren] = useState(tag.expanded);
const [hadChildren, setHadChildren] = useState(hasChildren); const [hadChildren, setHadChildren] = useState(hasChildren);
useEffect(() => { useEffect(() => {
@@ -59,9 +58,12 @@ export const TagsListItem: FunctionComponent<Props> = observer(
const toggleChildren = useCallback( const toggleChildren = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setShowChildren((x) => !x); setShowChildren((x) => {
tagsState.setExpanded(tag, !x);
return !x;
});
}, },
[setShowChildren] [setShowChildren, tag, tagsState]
); );
const selectCurrentTag = useCallback(() => { const selectCurrentTag = useCallback(() => {
@@ -114,13 +116,13 @@ export const TagsListItem: FunctionComponent<Props> = observer(
type: ItemTypes.TAG, type: ItemTypes.TAG,
item: { uuid: tag.uuid }, item: { uuid: tag.uuid },
canDrag: () => { canDrag: () => {
return isNativeFoldersEnabled; return true;
}, },
collect: (monitor) => ({ collect: (monitor) => ({
isDragging: !!monitor.isDragging(), isDragging: !!monitor.isDragging(),
}), }),
}), }),
[tag, hasFolders] [tag]
); );
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>( const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
@@ -160,7 +162,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
> >
{!tag.errorDecrypting ? ( {!tag.errorDecrypting ? (
<div className="tag-info" title={title} ref={dropRef}> <div className="tag-info" title={title} ref={dropRef}>
{hasFolders && isNativeFoldersEnabled && hasAtLeastOneFolder && ( {hasAtLeastOneFolder && (
<div <div
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`} className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
onClick={hasChildren ? toggleChildren : undefined} onClick={hasChildren ? toggleChildren : undefined}
@@ -173,12 +175,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
/> />
</div> </div>
)} )}
<div <div className={`tag-icon ${'draggable'} mr-1`} ref={dragRef}>
className={`tag-icon ${
isNativeFoldersEnabled ? 'draggable' : ''
} mr-1`}
ref={dragRef}
>
<Icon <Icon
type="hashtag" type="hashtag"
className={`${isSelected ? 'color-info' : 'color-neutral'}`} className={`${isSelected ? 'color-info' : 'color-neutral'}`}

View File

@@ -1,7 +1,9 @@
import { TagsList } from '@/components/Tags/TagsList'; import { TagsList } from '@/components/Tags/TagsList';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { ApplicationEvent } from '@/__mocks__/@standardnotes/snjs';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { TagsSectionAddButton } from './TagsSectionAddButton'; import { TagsSectionAddButton } from './TagsSectionAddButton';
import { TagsSectionTitle } from './TagsSectionTitle'; import { TagsSectionTitle } from './TagsSectionTitle';
@@ -11,11 +13,51 @@ type Props = {
export const TagsSection: FunctionComponent<Props> = observer( export const TagsSection: FunctionComponent<Props> = observer(
({ appState }) => { ({ appState }) => {
const [hasMigration, setHasMigration] = useState<boolean>(false);
const checkIfMigrationNeeded = useCallback(() => {
setHasMigration(appState.application.hasTagsNeedingFoldersMigration());
}, [appState.application]);
useEffect(() => {
appState.application.addEventObserver(async (event) => {
const events = [
ApplicationEvent.CompletedInitialSync,
ApplicationEvent.SignedIn,
];
if (events.includes(event)) {
checkIfMigrationNeeded();
}
});
}, [appState.application, checkIfMigrationNeeded]);
const runMigration = useCallback(async () => {
if (
await appState.application.alertService.confirm(
'<i>Introducing native, built-in nested tags without requiring the legacy Folders extension.</i><br/></br> ' +
" To get started, we'll need to migrate any tags containing a dot character to the new system.<br/></br> " +
' This migration will convert any tags with dots appearing in their name into a natural' +
' hierarchy that is compatible with the new nested tags feature.' +
' Running this migration will remove any "." characters appearing in tag names.',
'New: Folders to Nested Tags',
'Run Migration'
)
) {
appState.application.migrateTagsToFolders().then(() => {
checkIfMigrationNeeded();
});
}
}, [appState.application, checkIfMigrationNeeded]);
return ( return (
<section> <section>
<div className="section-title-bar"> <div className="section-title-bar">
<div className="section-title-bar-header"> <div className="section-title-bar-header">
<TagsSectionTitle features={appState.features} /> <TagsSectionTitle
features={appState.features}
hasMigration={hasMigration}
onClickMigration={runMigration}
/>
<TagsSectionAddButton <TagsSectionAddButton
tags={appState.tags} tags={appState.tags}
features={appState.features} features={appState.features}

View File

@@ -10,13 +10,7 @@ type Props = {
}; };
export const TagsSectionAddButton: FunctionComponent<Props> = observer( export const TagsSectionAddButton: FunctionComponent<Props> = observer(
({ tags, features }) => { ({ tags }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
if (!isNativeFoldersEnabled) {
return null;
}
return ( return (
<IconButton <IconButton
focusable={true} focusable={true}

View File

@@ -11,33 +11,32 @@ import { useCallback } from 'preact/hooks';
type Props = { type Props = {
features: FeaturesState; features: FeaturesState;
hasMigration: boolean;
onClickMigration: () => void;
}; };
export const TagsSectionTitle: FunctionComponent<Props> = observer( export const TagsSectionTitle: FunctionComponent<Props> = observer(
({ features }) => { ({ features, hasMigration, onClickMigration }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature; const entitledToFolders = features.hasFolders;
const hasFolders = features.hasFolders;
const modal = usePremiumModal(); const modal = usePremiumModal();
const showPremiumAlert = useCallback(() => { const showPremiumAlert = useCallback(() => {
modal.activate(TAG_FOLDERS_FEATURE_NAME); modal.activate(TAG_FOLDERS_FEATURE_NAME);
}, [modal]); }, [modal]);
if (!isNativeFoldersEnabled) { if (entitledToFolders) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
</>
);
}
if (hasFolders) {
return ( return (
<> <>
<div className="sk-h3 title"> <div className="sk-h3 title">
<span className="sk-bold">Folders</span> <span className="sk-bold">Folders</span>
{hasMigration && (
<label
className="ml-1 sk-bold color-info cursor-pointer"
onClick={onClickMigration}
>
Migration Available
</label>
)}
</div> </div>
</> </>
); );

View File

@@ -1,13 +1,10 @@
import { import { ComponentChildren, FunctionComponent, VNode } from 'preact';
ComponentChild,
ComponentChildren,
FunctionComponent,
VNode,
} from 'preact';
import { forwardRef, Ref } from 'preact/compat'; import { forwardRef, Ref } from 'preact/compat';
import { JSXInternal } from 'preact/src/jsx'; import { JSXInternal } from 'preact/src/jsx';
import { Icon, IconType } from '../Icon'; import { Icon } from '../Icon';
import { Switch, SwitchProps } from '../Switch'; import { Switch, SwitchProps } from '../Switch';
import { IconType } from '@standardnotes/snjs';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
export enum MenuItemType { export enum MenuItemType {
IconButton, IconButton,
@@ -20,6 +17,7 @@ type MenuItemProps = {
children: ComponentChildren; children: ComponentChildren;
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>; onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
onChange?: SwitchProps['onChange']; onChange?: SwitchProps['onChange'];
onBlur?: (event: { relatedTarget: EventTarget | null }) => void;
className?: string; className?: string;
checked?: boolean; checked?: boolean;
icon?: IconType; icon?: IconType;
@@ -33,6 +31,7 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
children, children,
onClick, onClick,
onChange, onChange,
onBlur,
className = '', className = '',
type, type,
checked, checked,
@@ -44,22 +43,31 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
) => { ) => {
return type === MenuItemType.SwitchButton && return type === MenuItemType.SwitchButton &&
typeof onChange === 'function' ? ( typeof onChange === 'function' ? (
<Switch <button
className="py-1 hover:bg-contrast focus:bg-info-backdrop" className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
checked={checked} onClick={() => {
onChange={onChange} onChange(!checked);
}}
onBlur={onBlur}
tabIndex={
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
}
role="menuitemcheckbox" role="menuitemcheckbox"
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1} aria-checked={checked}
> >
{children} <span className="flex flex-grow items-center">{children}</span>
</Switch> <Switch className="px-0" checked={checked} />
</button>
) : ( ) : (
<button <button
ref={ref} ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'} role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1} tabIndex={
typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE
}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`} className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onClick={onClick} onClick={onClick}
onBlur={onBlur}
{...(type === MenuItemType.RadioButton {...(type === MenuItemType.RadioButton
? { 'aria-checked': checked } ? { 'aria-checked': checked }
: {})} : {})}
@@ -90,22 +98,27 @@ type ListElementProps = {
}; };
export const MenuItemListElement: FunctionComponent<ListElementProps> = export const MenuItemListElement: FunctionComponent<ListElementProps> =
forwardRef(({ children, isFirstMenuItem }: ListElementProps, ref: Ref<HTMLLIElement>) => { forwardRef(
const child = children as VNode<unknown>; (
{ children, isFirstMenuItem }: ListElementProps,
ref: Ref<HTMLLIElement>
) => {
const child = children as VNode<unknown>;
return ( return (
<li className="list-style-none" role="none" ref={ref}> <li className="list-style-none" role="none" ref={ref}>
{{ {{
...child, ...child,
props: { props: {
...(child.props ? { ...child.props } : {}), ...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem ...(child.type === MenuItem
? { ? {
tabIndex: isFirstMenuItem ? 0 : -1, tabIndex: isFirstMenuItem ? 0 : -1,
} }
: {}), : {}),
}, },
}} }}
</li> </li>
); );
}); }
);

View File

@@ -8,7 +8,7 @@ import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
* monitored. * monitored.
*/ */
export function useCloseOnBlur( export function useCloseOnBlur(
container: { current?: HTMLDivElement }, container: { current?: HTMLDivElement | null },
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
): [ ): [
(event: { relatedTarget: EventTarget | null }) => void, (event: { relatedTarget: EventTarget | null }) => void,
@@ -33,55 +33,27 @@ export function useCloseOnBlur(
export function useCloseOnClickOutside( export function useCloseOnClickOutside(
container: { current: HTMLDivElement | null }, container: { current: HTMLDivElement | null },
setOpen: (open: boolean) => void callback: () => void
): void { ): void {
const closeOnClickOutside = useCallback( const closeOnClickOutside = useCallback(
(event: { target: EventTarget | null }) => { (event: { target: EventTarget | null }) => {
if (!container.current?.contains(event.target as Node)) { if (!container.current) {
setOpen(false); return;
}
const isDescendant = container.current.contains(event.target as Node);
if (!isDescendant) {
callback();
} }
}, },
[container, setOpen] [container, callback]
); );
useEffect(() => { useEffect(() => {
document.addEventListener('click', closeOnClickOutside); document.addEventListener('click', closeOnClickOutside, { capture: true });
return () => { return () => {
document.removeEventListener('click', closeOnClickOutside); document.removeEventListener('click', closeOnClickOutside, {
capture: true,
});
}; };
}, [closeOnClickOutside]); }, [closeOnClickOutside]);
} }
export function toDirective<Props>(
component: FunctionComponent<Props>,
scope: Record<string, '=' | '&' | '@'> = {}
) {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
return function () {
return {
controller: [
'$element',
'$scope',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
($element: JQLite, $scope: any) => {
if ($scope.class) {
$element.addClass($scope.class);
}
return {
$onChanges() {
render(h(component, $scope), $element[0]);
},
$onDestroy() {
unmountComponentAtNode($element[0]);
},
};
},
],
scope: {
application: '=',
appState: '=',
...scope,
},
};
};
}

View File

@@ -1,19 +0,0 @@
/* @ngInject */
export function autofocus($timeout: ng.ITimeoutService) {
return {
restrict: 'A',
scope: {
shouldFocus: '='
},
link: function (
$scope: ng.IScope,
$element: JQLite
) {
$timeout(() => {
if (($scope as any).shouldFocus) {
$element[0].focus();
}
});
}
};
}

View File

@@ -1,37 +0,0 @@
/* @ngInject */
export function clickOutside($document: ng.IDocumentService) {
return {
restrict: 'A',
replace: false,
link($scope: ng.IScope, $element: JQLite, attrs: any) {
let didApplyClickOutside = false;
function onElementClick(event: JQueryEventObject) {
didApplyClickOutside = false;
if (attrs.isOpen) {
event.stopPropagation();
}
}
function onDocumentClick(event: JQueryEventObject) {
/** Ignore click if on SKAlert */
if (event.target.closest('.sk-modal')) {
return;
}
if (!didApplyClickOutside) {
$scope.$apply(attrs.clickOutside);
didApplyClickOutside = true;
}
}
$scope.$on('$destroy', () => {
attrs.clickOutside = undefined;
$element.unbind('click', onElementClick);
$document.unbind('click', onDocumentClick);
});
$element.bind('click', onElementClick);
$document.bind('click', onDocumentClick);
}
};
}

View File

@@ -1,45 +0,0 @@
import angular from 'angular';
/* @ngInject */
export function delayHide($timeout: ng.ITimeoutService) {
return {
restrict: 'A',
scope: {
show: '=',
delay: '@'
},
link: function (scope: ng.IScope, elem: JQLite) {
const scopeAny = scope as any;
const showSpinner = () => {
if (scopeAny.hidePromise) {
$timeout.cancel(scopeAny.hidePromise);
scopeAny.hidePromise = null;
}
showElement(true);
};
const hideSpinner = () => {
scopeAny.hidePromise = $timeout(
showElement.bind(this as any, false),
getDelay()
);
};
const showElement = (show: boolean) => {
show ? elem.css({ display: '' }) : elem.css({ display: 'none' });
};
const getDelay = () => {
const delay = parseInt(scopeAny.delay);
return angular.isNumber(delay) ? delay : 200;
};
showElement(false);
// Whenever the scope variable updates we simply
// show if it evaluates to 'true' and hide if 'false'
scope.$watch('show', function (newVal) {
newVal ? showSpinner() : hideSpinner();
});
}
};
}

View File

@@ -1,14 +0,0 @@
/* @ngInject */
export function elemReady($parse: ng.IParseService) {
return {
restrict: 'A',
link: function($scope: ng.IScope, elem: JQLite, attrs: any) {
elem.ready(function() {
$scope.$apply(function() {
const func = $parse(attrs.elemReady);
func($scope);
});
});
}
};
}

View File

@@ -1,19 +0,0 @@
/* @ngInject */
export function fileChange() {
return {
restrict: 'A',
scope: {
handler: '&'
},
link: function (scope: ng.IScope, element: JQLite) {
element.on('change', (event) => {
scope.$apply(() => {
const files = (event.target as HTMLInputElement).files;
(scope as any).handler({
files: files
});
});
});
}
};
}

View File

@@ -1,8 +0,0 @@
export { autofocus } from './autofocus';
export { clickOutside } from './click-outside';
export { delayHide } from './delay-hide';
export { elemReady } from './elemReady';
export { fileChange } from './file-change';
export { lowercase } from './lowercase';
export { selectOnFocus } from './selectOnFocus';
export { snEnter } from './snEnter';

View File

@@ -1,24 +0,0 @@
/* @ngInject */
export function lowercase() {
return {
require: 'ngModel',
link: function (
scope: ng.IScope,
_: JQLite,
attrs: any,
ctrl: any
) {
const lowercase = (inputValue: string) => {
if (inputValue === undefined) inputValue = '';
const lowercased = inputValue.toLowerCase();
if (lowercased !== inputValue) {
ctrl.$setViewValue(lowercased);
ctrl.$render();
}
return lowercased;
};
ctrl.$parsers.push(lowercase);
lowercase((scope as any)[attrs.ngModel]);
}
};
}

View File

@@ -1,17 +0,0 @@
/* @ngInject */
export function selectOnFocus($window: ng.IWindowService) {
return {
restrict: 'A',
link: function (scope: ng.IScope, element: JQLite) {
element.on('focus', () => {
if (!$window.getSelection()!.toString()) {
const input = element[0] as HTMLInputElement;
/** Allow text to populate */
setTimeout(() => {
input.setSelectionRange(0, input.value.length);
}, 0);
}
});
}
};
}

View File

@@ -1,18 +0,0 @@
/* @ngInject */
export function snEnter() {
return function (
scope: ng.IScope,
element: JQLite,
attrs: any
) {
element.bind('keydown keypress', function (event) {
if (event.which === 13) {
scope.$apply(function () {
scope.$eval(attrs.snEnter, { event: event });
});
event.preventDefault();
}
});
};
}

View File

@@ -1,286 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { WebDirective } from './../../types';
import template from '%/directives/actions-menu.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { SNItem, Action, SNActionsExtension, UuidString, CopyPayload } from '@standardnotes/snjs';
import { ActionResponse } from '@standardnotes/snjs';
import { ActionsExtensionMutator } from '@standardnotes/snjs';
type ActionsMenuScope = {
application: WebApplication
item: SNItem
}
type ActionSubRow = {
onClick: () => void
label: string
subtitle: string
spinnerClass?: string
}
type ExtensionState = {
loading: boolean
error: boolean
}
type ActionsMenuState = {
extensions: SNActionsExtension[]
extensionsState: Record<UuidString, ExtensionState>
selectedActionId?: number
menuItems: {
uuid: UuidString,
name: string,
loading: boolean,
error: boolean,
hidden: boolean,
actions: (Action & {
subrows?: ActionSubRow[]
})[]
}[]
}
class ActionsMenuCtrl extends PureViewCtrl<unknown, ActionsMenuState> implements ActionsMenuScope {
application!: WebApplication
item!: SNItem
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService
) {
super($timeout);
}
$onInit() {
super.$onInit();
this.initProps({
item: this.item
});
this.loadExtensions();
this.autorun(() => {
this.rebuildMenuState({
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions
});
});
}
/** @override */
getInitialState() {
const extensions = this.application.actionsManager.getExtensions().sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
}).map((extension) => {
return new SNActionsExtension(CopyPayload(extension.payload, {
content: {
...extension.payload.safeContent,
actions: []
}
}));
});
const extensionsState: Record<UuidString, ExtensionState> = {};
extensions.map((extension) => {
extensionsState[extension.uuid] = {
loading: true,
error: false,
};
});
return {
extensions,
extensionsState,
hiddenExtensions: {},
menuItems: [],
};
}
rebuildMenuState({
extensions = this.state.extensions,
extensionsState = this.state.extensionsState,
selectedActionId = this.state.selectedActionId,
hiddenExtensions = this.appState.actionsMenu.hiddenExtensions,
} = {}) {
return this.setState({
extensions,
extensionsState,
selectedActionId,
menuItems: extensions.map(extension => {
const state = extensionsState[extension.uuid];
const hidden = hiddenExtensions[extension.uuid];
return {
uuid: extension.uuid,
name: extension.name,
loading: state?.loading ?? false,
error: state?.error ?? false,
hidden: hidden ?? false,
deprecation: extension.deprecation!,
actions: extension.actionsWithContextForItem(this.item).map(action => {
if (action.id === selectedActionId) {
return {
...action,
subrows: this.subRowsForAction(action, extension)
};
} else {
return action;
}
})
};
})
});
}
async loadExtensions() {
await Promise.all(this.state.extensions.map(async (extension: SNActionsExtension) => {
this.setLoadingExtension(extension.uuid, true);
const updatedExtension = await this.application.actionsManager!.loadExtensionInContextOfItem(
extension,
this.item
);
if (updatedExtension) {
await this.updateExtension(updatedExtension!);
} else {
this.setErrorExtension(extension.uuid, true);
}
this.setLoadingExtension(extension.uuid, false);
}));
}
async executeAction(action: Action, extensionUuid: UuidString) {
if (action.verb === 'nested') {
this.rebuildMenuState({
selectedActionId: action.id
});
return;
}
const extension = this.application.findItem(extensionUuid) as SNActionsExtension;
await this.updateAction(action, extension, { running: true });
const response = await this.application.actionsManager!.runAction(
action,
this.item,
async () => {
/** @todo */
return '';
}
);
if (response.error) {
await this.updateAction(action, extension, { error: true });
return;
}
await this.updateAction(action, extension, { running: false });
this.handleActionResponse(action, response);
await this.reloadExtension(extension);
}
handleActionResponse(action: Action, result: ActionResponse) {
switch (action.verb) {
case 'render': {
const item = result.item;
this.application.presentRevisionPreviewModal(
item.uuid,
item.content
);
}
}
}
private subRowsForAction(parentAction: Action, extension: Pick<SNActionsExtension, 'uuid'>): ActionSubRow[] | undefined {
if (!parentAction.subactions) {
return undefined;
}
return parentAction.subactions.map((subaction) => {
return {
id: subaction.id,
onClick: () => {
this.executeAction(subaction, extension.uuid);
},
label: subaction.label,
subtitle: subaction.desc,
spinnerClass: subaction.running ? 'info' : undefined
};
});
}
private async updateAction(
action: Action,
extension: SNActionsExtension,
params: {
running?: boolean
error?: boolean
}
) {
const updatedExtension = await this.application.changeItem(extension.uuid, (mutator) => {
const extensionMutator = mutator as ActionsExtensionMutator;
extensionMutator.actions = extension!.actions.map((act) => {
if (act && params && act.verb === action.verb && act.url === action.url) {
return {
...action,
running: params?.running,
error: params?.error,
} as Action;
}
return act;
});
}) as SNActionsExtension;
await this.updateExtension(updatedExtension);
}
private async updateExtension(extension: SNActionsExtension) {
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
if (extension.uuid === ext.uuid) {
return extension;
}
return ext;
});
await this.rebuildMenuState({
extensions
});
}
private async reloadExtension(extension: SNActionsExtension) {
const extensionInContext = await this.application.actionsManager!.loadExtensionInContextOfItem(
extension,
this.item
);
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
if (extension.uuid === ext.uuid) {
return extensionInContext!;
}
return ext;
});
this.rebuildMenuState({
extensions
});
}
public toggleExtensionVisibility(extensionUuid: UuidString) {
this.appState.actionsMenu.toggleExtensionVisibility(extensionUuid);
}
private setLoadingExtension(extensionUuid: UuidString, value = false) {
const { extensionsState } = this.state;
extensionsState[extensionUuid].loading = value;
this.rebuildMenuState({
extensionsState
});
}
private setErrorExtension(extensionUuid: UuidString, value = false) {
const { extensionsState } = this.state;
extensionsState[extensionUuid].error = value;
this.rebuildMenuState({
extensionsState
});
}
}
export class ActionsMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.replace = true;
this.controller = ActionsMenuCtrl;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
item: '=',
application: '='
};
}
}

View File

@@ -1,81 +0,0 @@
import { WebDirective } from './../../types';
import { WebApplication } from '@/ui_models/application';
import { SNComponent, SNItem, ComponentArea } from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils';
import template from '%/directives/editor-menu.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
interface EditorMenuScope {
callback: (component: SNComponent) => void;
selectedEditorUuid: string;
currentItem: SNItem;
application: WebApplication;
}
class EditorMenuCtrl extends PureViewCtrl implements EditorMenuScope {
callback!: () => (component: SNComponent) => void;
selectedEditorUuid!: string;
currentItem!: SNItem;
application!: WebApplication;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
this.state = {
isDesktop: isDesktopApplication(),
};
}
public isEditorSelected(editor: SNComponent) {
if (!this.selectedEditorUuid) {
return false;
}
return this.selectedEditorUuid === editor.uuid;
}
$onInit() {
super.$onInit();
const editors = this.application.componentManager
.componentsForArea(ComponentArea.Editor)
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
});
this.setState({
editors: editors,
});
}
selectComponent(component: SNComponent) {
if (component) {
if (component.conflictOf) {
this.application.changeAndSaveItem(component.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
}
this.$timeout(() => {
this.callback()(component);
});
}
offlineAvailableForComponent(component: SNComponent) {
return component.local_url && this.state.isDesktop;
}
}
export class EditorMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = EditorMenuCtrl;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
callback: '&',
selectedEditorUuid: '=',
currentItem: '=',
application: '=',
};
}
}

View File

@@ -1,177 +0,0 @@
import { WebDirective } from '../../types';
import { WebApplication } from '@/ui_models/application';
import template from '%/directives/history-menu.pug';
import { HistoryEntry, SNItem } from '@standardnotes/snjs';
import { PureViewCtrl } from '@/views';
import { RevisionListEntry, SingleRevision } from '@standardnotes/snjs';
import { alertDialog, confirmDialog } from '@/services/alertService';
type HistoryState = {
sessionHistory?: HistoryEntry[];
remoteHistory?: RevisionListEntry[];
fetchingRemoteHistory: boolean;
autoOptimize: boolean;
diskEnabled: boolean;
};
class HistoryMenuCtrl extends PureViewCtrl<unknown, HistoryState> {
application!: WebApplication;
item!: SNItem;
/** @template */
showSessionOptions = false;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
}
getInitialState() {
return {
fetchingRemoteHistory: false,
autoOptimize: this.application.historyManager.autoOptimize,
diskEnabled: this.application.historyManager.isDiskEnabled(),
sessionHistory: this.application.historyManager.sessionHistoryForItem(
this.item
),
};
}
reloadState() {
this.setState({
...this.getInitialState(),
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
});
}
$onInit() {
super.$onInit();
this.fetchRemoteHistory();
}
async fetchRemoteHistory() {
this.setState({ fetchingRemoteHistory: true });
try {
const remoteHistory = await this.application.historyManager.remoteHistoryForItem(
this.item
);
this.setState({ remoteHistory });
} finally {
this.setState({ fetchingRemoteHistory: false });
}
}
async openSessionRevision(revision: HistoryEntry & { previewTitle: () => string }) {
this.application.presentRevisionPreviewModal(
revision.payload.uuid,
revision.payload.content,
revision.previewTitle()
);
}
async openRemoteRevision(revision: RevisionListEntry) {
this.setState({ fetchingRemoteHistory: true });
const remoteRevision = await this.application.historyManager.fetchRemoteRevision(
this.item.uuid,
revision
);
this.setState({ fetchingRemoteHistory: false });
if (!remoteRevision) {
alertDialog({
text:
'The remote revision could not be loaded. Please try again later.',
});
return;
}
this.application.presentRevisionPreviewModal(
remoteRevision.payload.uuid,
remoteRevision.payload.content,
this.previewRemoteHistoryTitle(revision)
);
}
classForSessionRevision(revision: HistoryEntry) {
const vector = revision.operationVector();
if (vector === 0) {
return 'default';
} else if (vector === 1) {
return 'success';
} else if (vector === -1) {
return 'danger';
}
}
async clearItemSessionHistory() {
if (
await confirmDialog({
text:
'Are you sure you want to delete the local session history for this note?',
confirmButtonStyle: 'danger',
})
) {
this.application.historyManager.clearHistoryForItem(this.item);
this.reloadState();
}
}
async clearAllSessionHistory() {
if (
await confirmDialog({
text:
'Are you sure you want to delete the local session history for all notes?',
confirmButtonStyle: 'danger',
})
) {
await this.application.historyManager.clearAllHistory();
this.reloadState();
}
}
/** @entries */
get sessionHistoryEntries() {
return this.state.sessionHistory;
}
async toggleSessionHistoryDiskSaving() {
if (!this.state.diskEnabled) {
if (
await confirmDialog({
text:
'Are you sure you want to save history to disk? This will decrease general ' +
'performance, especially as you type. You are advised to disable this feature ' +
'if you experience any lagging.',
confirmButtonStyle: 'danger',
})
) {
this.application.historyManager.toggleDiskSaving();
}
} else {
this.application.historyManager.toggleDiskSaving();
}
this.reloadState();
}
toggleSessionHistoryAutoOptimize() {
this.application.historyManager.toggleAutoOptimize();
this.reloadState();
}
previewRemoteHistoryTitle(revision: RevisionListEntry) {
return new Date(revision.created_at).toLocaleString();
}
}
export class HistoryMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = HistoryMenuCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
item: '=',
application: '=',
};
}
}

View File

@@ -1,10 +0,0 @@
export { ActionsMenu } from './actionsMenu';
export { EditorMenu } from './editorMenu';
export { InputModal } from './inputModal';
export { MenuRow } from './menuRow';
export { PanelResizer } from './panelResizer';
export { PasswordWizard } from './passwordWizard';
export { PermissionsModal } from './permissionsModal';
export { RevisionPreviewModal } from './revisionPreviewModal';
export { HistoryMenu } from './historyMenu';
export { SyncResolutionMenu } from './syncResolutionMenu';

View File

@@ -1,53 +0,0 @@
import { WebDirective } from './../../types';
import template from '%/directives/input-modal.pug';
export interface InputModalScope extends Partial<ng.IScope> {
type: string
title: string
message: string
callback: (value: string) => void
}
class InputModalCtrl implements InputModalScope {
$element: JQLite
type!: string
title!: string
message!: string
callback!: (value: string) => void
formData = { input: '' }
/* @ngInject */
constructor($element: JQLite) {
this.$element = $element;
}
dismiss() {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
submit() {
this.callback(this.formData.input);
this.dismiss();
}
}
export class InputModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = InputModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
type: '=',
title: '=',
message: '=',
callback: '&'
};
}
}

View File

@@ -1,54 +0,0 @@
import { WebDirective } from './../../types';
import template from '%/directives/menu-row.pug';
class MenuRowCtrl {
disabled!: boolean
action!: () => void
buttonAction!: () => void
onClick($event: Event) {
if(this.disabled) {
return;
}
$event.stopPropagation();
this.action();
}
clickAccessoryButton($event: Event) {
if(this.disabled) {
return;
}
$event.stopPropagation();
this.buttonAction();
}
}
export class MenuRow extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.transclude = true;
this.template = template;
this.controller = MenuRowCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
action: '&',
buttonAction: '&',
buttonClass: '=',
buttonText: '=',
desc: '=',
disabled: '=',
circle: '=',
circleAlign: '=',
faded: '=',
hasButton: '=',
label: '=',
spinnerClass: '=',
stylekitClass: '=',
subRows: '=',
subtitle: '=',
};
}
}

View File

@@ -1,406 +0,0 @@
import { PanelPuppet, WebDirective } from './../../types';
import angular from 'angular';
import template from '%/directives/panel-resizer.pug';
import { debounce } from '@/utils';
export enum PanelSide {
Right = 'right',
Left = 'left',
}
enum MouseEventType {
Move = 'mousemove',
Down = 'mousedown',
Up = 'mouseup',
}
enum CssClass {
Hoverable = 'hoverable',
AlwaysVisible = 'always-visible',
Dragging = 'dragging',
NoSelection = 'no-selection',
Collapsed = 'collapsed',
AnimateOpacity = 'animate-opacity',
}
const WINDOW_EVENT_RESIZE = 'resize';
export type ResizeFinishCallback = (
lastWidth: number,
lastLeft: number,
isMaxWidth: boolean,
isCollapsed: boolean
) => void;
interface PanelResizerScope {
alwaysVisible: boolean;
collapsable: boolean;
control: PanelPuppet;
defaultWidth: number;
hoverable: boolean;
index: number;
minWidth: number;
onResizeFinish: () => ResizeFinishCallback;
onWidthEvent?: () => void;
panelId: string;
property: PanelSide;
}
class PanelResizerCtrl implements PanelResizerScope {
/** @scope */
alwaysVisible!: boolean;
collapsable!: boolean;
control!: PanelPuppet;
defaultWidth!: number;
hoverable!: boolean;
index!: number;
minWidth!: number;
onResizeFinish!: () => ResizeFinishCallback;
onWidthEvent?: () => () => void;
panelId!: string;
property!: PanelSide;
$compile: ng.ICompileService;
$element: JQLite;
$timeout: ng.ITimeoutService;
panel!: HTMLElement;
resizerColumn!: HTMLElement;
currentMinWidth = 0;
pressed = false;
startWidth = 0;
lastDownX = 0;
collapsed = false;
lastWidth = 0;
startLeft = 0;
lastLeft = 0;
appFrame?: DOMRect;
widthBeforeLastDblClick = 0;
overlay?: JQLite;
/* @ngInject */
constructor(
$compile: ng.ICompileService,
$element: JQLite,
$timeout: ng.ITimeoutService
) {
this.$compile = $compile;
this.$element = $element;
this.$timeout = $timeout;
/** To allow for registering events */
this.handleResize = debounce(this.handleResize.bind(this), 250);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
}
$onInit() {
this.configureDefaults();
this.reloadDefaultValues();
this.configureControl();
this.addDoubleClickHandler();
this.resizerColumn.addEventListener(MouseEventType.Down, this.onMouseDown);
document.addEventListener(MouseEventType.Move, this.onMouseMove);
document.addEventListener(MouseEventType.Up, this.onMouseUp);
}
$onDestroy() {
(this.onResizeFinish as any) = undefined;
(this.onWidthEvent as any) = undefined;
(this.control as any) = undefined;
window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
document.removeEventListener(MouseEventType.Move, this.onMouseMove);
document.removeEventListener(MouseEventType.Up, this.onMouseUp);
this.resizerColumn.removeEventListener(
MouseEventType.Down,
this.onMouseDown
);
(this.handleResize as any) = undefined;
(this.onMouseMove as any) = undefined;
(this.onMouseUp as any) = undefined;
(this.onMouseDown as any) = undefined;
}
configureControl() {
this.control.setWidth = (value) => {
this.setWidth(value, true);
};
this.control.setLeft = (value) => {
this.setLeft(value);
};
this.control.flash = () => {
this.flash();
};
this.control.isCollapsed = () => {
return this.isCollapsed();
};
this.control.ready = true;
this.control.onReady!();
}
configureDefaults() {
this.panel = document.getElementById(this.panelId)!;
if (!this.panel) {
console.error('Panel not found for', this.panelId);
return;
}
this.resizerColumn = this.$element[0];
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth + 2;
this.pressed = false;
this.startWidth = this.panel.scrollWidth;
this.lastDownX = 0;
this.collapsed = false;
this.lastWidth = this.startWidth;
this.startLeft = this.panel.offsetLeft;
this.lastLeft = this.startLeft;
this.appFrame = undefined;
this.widthBeforeLastDblClick = 0;
if (this.property === PanelSide.Right) {
this.configureRightPanel();
}
if (this.alwaysVisible) {
this.resizerColumn.classList.add(CssClass.AlwaysVisible);
}
if (this.hoverable) {
this.resizerColumn.classList.add(CssClass.Hoverable);
}
}
configureRightPanel() {
window.addEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
}
handleResize() {
this.reloadDefaultValues();
this.handleWidthEvent();
this.$timeout(() => {
this.finishSettingWidth();
});
}
getParentRect() {
const node = this.panel!.parentNode! as HTMLElement;
return node.getBoundingClientRect();
}
reloadDefaultValues() {
this.startWidth = this.isAtMaxWidth()
? this.getParentRect().width
: this.panel.scrollWidth;
this.lastWidth = this.startWidth;
this.appFrame = document.getElementById('app')!.getBoundingClientRect();
}
addDoubleClickHandler() {
this.resizerColumn.ondblclick = () => {
this.$timeout(() => {
const preClickCollapseState = this.isCollapsed();
if (preClickCollapseState) {
this.setWidth(this.widthBeforeLastDblClick || this.defaultWidth);
} else {
this.widthBeforeLastDblClick = this.lastWidth;
this.setWidth(this.currentMinWidth);
}
this.finishSettingWidth();
const newCollapseState = !preClickCollapseState;
this.onResizeFinish()(
this.lastWidth,
this.lastLeft,
this.isAtMaxWidth(),
newCollapseState
);
});
};
}
onMouseDown(event: MouseEvent) {
this.addInvisibleOverlay();
this.pressed = true;
this.lastDownX = event.clientX;
this.startWidth = this.panel.scrollWidth;
this.startLeft = this.panel.offsetLeft;
this.panel.classList.add(CssClass.NoSelection);
if (this.hoverable) {
this.resizerColumn.classList.add(CssClass.Dragging);
}
}
onMouseUp() {
this.removeInvisibleOverlay();
if (!this.pressed) {
return;
}
this.pressed = false;
this.resizerColumn.classList.remove(CssClass.Dragging);
this.panel.classList.remove(CssClass.NoSelection);
const isMaxWidth = this.isAtMaxWidth();
if (this.onResizeFinish) {
this.onResizeFinish()(
this.lastWidth,
this.lastLeft,
isMaxWidth,
this.isCollapsed()
);
}
this.finishSettingWidth();
}
onMouseMove(event: MouseEvent) {
if (!this.pressed) {
return;
}
event.preventDefault();
if (this.property && this.property === PanelSide.Left) {
this.handleLeftEvent(event);
} else {
this.handleWidthEvent(event);
}
}
handleWidthEvent(event?: MouseEvent) {
if (this.onWidthEvent && this.onWidthEvent()) {
this.onWidthEvent()();
}
let x;
if (event) {
x = event!.clientX;
} else {
/** Coming from resize event */
x = 0;
this.lastDownX = 0;
}
const deltaX = x - this.lastDownX;
const newWidth = this.startWidth + deltaX;
this.setWidth(newWidth, false);
}
handleLeftEvent(event: MouseEvent) {
const panelRect = this.panel.getBoundingClientRect();
const x = event.clientX || panelRect.x;
let deltaX = x - this.lastDownX;
let newLeft = this.startLeft + deltaX;
if (newLeft < 0) {
newLeft = 0;
deltaX = -this.startLeft;
}
const parentRect = this.getParentRect();
let newWidth = this.startWidth - deltaX;
if (newWidth < this.currentMinWidth) {
newWidth = this.currentMinWidth;
}
if (newWidth > parentRect.width) {
newWidth = parentRect.width;
}
if (newLeft + newWidth > parentRect.width) {
newLeft = parentRect.width - newWidth;
}
this.setLeft(newLeft);
this.setWidth(newWidth, false);
}
isAtMaxWidth() {
return (
Math.round(this.lastWidth + this.lastLeft) ===
Math.round(this.getParentRect().width)
);
}
isCollapsed() {
return this.lastWidth <= this.currentMinWidth;
}
setWidth(width: number, finish = false) {
if (width < this.currentMinWidth) {
width = this.currentMinWidth;
}
const parentRect = this.getParentRect();
if (width > parentRect.width) {
width = parentRect.width;
}
const maxWidth =
this.appFrame!.width - this.panel.getBoundingClientRect().x;
if (width > maxWidth) {
width = maxWidth;
}
if (Math.round(width + this.lastLeft) === Math.round(parentRect.width)) {
this.panel.style.width = `calc(100% - ${this.lastLeft}px)`;
} else {
this.panel.style.width = width + 'px';
}
this.lastWidth = width;
if (finish) {
this.finishSettingWidth();
}
}
setLeft(left: number) {
this.panel.style.left = left + 'px';
this.lastLeft = left;
}
finishSettingWidth() {
if (!this.collapsable) {
return;
}
this.collapsed = this.isCollapsed();
if (this.collapsed) {
this.resizerColumn.classList.add(CssClass.Collapsed);
} else {
this.resizerColumn.classList.remove(CssClass.Collapsed);
}
}
/**
* If an iframe is displayed adjacent to our panel, and the mouse exits over the iframe,
* document[onmouseup] is not triggered because the document is no longer the same over
* the iframe. We add an invisible overlay while resizing so that the mouse context
* remains in our main document.
*/
addInvisibleOverlay() {
if (this.overlay) {
return;
}
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(
this as any
);
angular.element(document.body).prepend(this.overlay);
}
removeInvisibleOverlay() {
if (this.overlay) {
this.overlay.remove();
this.overlay = undefined;
}
}
flash() {
const FLASH_DURATION = 3000;
this.resizerColumn.classList.add(CssClass.AnimateOpacity);
this.$timeout(() => {
this.resizerColumn.classList.remove(CssClass.AnimateOpacity);
}, FLASH_DURATION);
}
}
export class PanelResizer extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = PanelResizerCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
alwaysVisible: '=',
collapsable: '=',
control: '=',
defaultWidth: '=',
hoverable: '=',
index: '=',
minWidth: '=',
onResizeFinish: '&',
onWidthEvent: '&',
panelId: '=',
property: '=',
};
}
}

View File

@@ -1,244 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import {
PasswordWizardScope,
PasswordWizardType,
WebDirective,
} from './../../types';
import template from '%/directives/password-wizard.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
const DEFAULT_CONTINUE_TITLE = 'Continue';
enum Steps {
PasswordStep = 1,
FinishStep = 2,
}
type FormData = {
currentPassword?: string;
newPassword?: string;
newPasswordConfirmation?: string;
status?: string;
};
type State = {
lockContinue: boolean;
formData: FormData;
continueTitle: string;
step: Steps;
title: string;
showSpinner: boolean;
processing: boolean;
};
type Props = {
type: PasswordWizardType;
changePassword: boolean;
securityUpdate: boolean;
};
class PasswordWizardCtrl
extends PureViewCtrl<Props, State>
implements PasswordWizardScope
{
$element: JQLite;
application!: WebApplication;
type!: PasswordWizardType;
isContinuing = false;
/* @ngInject */
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
this.$element = $element;
this.registerWindowUnloadStopper();
}
$onInit() {
super.$onInit();
this.initProps({
type: this.type,
changePassword: this.type === PasswordWizardType.ChangePassword,
securityUpdate: this.type === PasswordWizardType.AccountUpgrade,
});
this.setState({
formData: {},
continueTitle: DEFAULT_CONTINUE_TITLE,
step: Steps.PasswordStep,
title: this.props.changePassword ? 'Change Password' : 'Account Update',
});
}
$onDestroy() {
super.$onDestroy();
window.onbeforeunload = null;
}
/** Confirms with user before closing tab */
registerWindowUnloadStopper() {
window.onbeforeunload = () => {
return true;
};
}
resetContinueState() {
this.setState({
showSpinner: false,
continueTitle: DEFAULT_CONTINUE_TITLE,
});
this.isContinuing = false;
}
async nextStep() {
if (this.state.lockContinue || this.isContinuing) {
return;
}
if (this.state.step === Steps.FinishStep) {
this.dismiss();
return;
}
this.isContinuing = true;
await this.setState({
showSpinner: true,
continueTitle: 'Generating Keys...',
});
const valid = await this.validateCurrentPassword();
if (!valid) {
this.resetContinueState();
return;
}
const success = await this.processPasswordChange();
if (!success) {
this.resetContinueState();
return;
}
this.isContinuing = false;
this.setState({
showSpinner: false,
continueTitle: 'Finish',
step: Steps.FinishStep,
});
}
async setFormDataState(formData: Partial<FormData>) {
return this.setState({
formData: {
...this.state.formData,
...formData,
},
});
}
async validateCurrentPassword() {
const currentPassword = this.state.formData.currentPassword;
const newPass = this.props.securityUpdate
? currentPassword
: this.state.formData.newPassword;
if (!currentPassword || currentPassword.length === 0) {
this.application.alertService!.alert(
'Please enter your current password.'
);
return false;
}
if (this.props.changePassword) {
if (!newPass || newPass.length === 0) {
this.application.alertService!.alert('Please enter a new password.');
return false;
}
if (newPass !== this.state.formData.newPasswordConfirmation) {
this.application.alertService!.alert(
'Your new password does not match its confirmation.'
);
this.setFormDataState({
status: undefined,
});
return false;
}
}
if (!this.application.getUser()?.email) {
this.application.alertService!.alert(
"We don't have your email stored. Please sign out then log back in to fix this issue."
);
this.setFormDataState({
status: undefined,
});
return false;
}
/** Validate current password */
const success = await this.application.validateAccountPassword(
this.state.formData.currentPassword!
);
if (!success) {
this.application.alertService!.alert(
'The current password you entered is not correct. Please try again.'
);
}
return success;
}
async processPasswordChange() {
await this.application.downloadBackup();
await this.setState({
lockContinue: true,
processing: true,
});
await this.setFormDataState({
status: 'Processing encryption keys…',
});
const newPassword = this.props.securityUpdate
? this.state.formData.currentPassword
: this.state.formData.newPassword;
const response = await this.application.changePassword(
this.state.formData.currentPassword!,
newPassword!
);
const success = !response.error;
await this.setState({
processing: false,
lockContinue: false,
});
if (!success) {
this.setFormDataState({
status: 'Unable to process your password. Please try again.',
});
} else {
this.setState({
formData: {
...this.state.formData,
status: this.props.changePassword
? 'Successfully changed password.'
: 'Successfully performed account update.',
},
});
}
return success;
}
dismiss() {
if (this.state.lockContinue) {
this.application.alertService!.alert(
'Cannot close window until pending tasks are complete.'
);
} else {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
}
}
export class PasswordWizard extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = PasswordWizardCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
type: '=',
application: '=',
};
}
}

View File

@@ -1,46 +0,0 @@
import { WebDirective } from './../../types';
import template from '%/directives/permissions-modal.pug';
class PermissionsModalCtrl {
$element: JQLite;
callback!: (success: boolean) => void;
/* @ngInject */
constructor($element: JQLite) {
this.$element = $element;
}
dismiss() {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
accept() {
this.callback(true);
this.dismiss();
}
deny() {
this.callback(false);
this.dismiss();
}
}
export class PermissionsModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = PermissionsModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
show: '=',
component: '=',
permissionsString: '=',
callback: '=',
};
}
}

View File

@@ -1,139 +0,0 @@
import { ComponentViewer } from '@standardnotes/snjs/dist/@types';
import { PureViewCtrl } from './../../views/abstract/pure_view_ctrl';
import { WebApplication } from '@/ui_models/application';
import { WebDirective } from './../../types';
import { ContentType, PayloadSource, SNNote } from '@standardnotes/snjs';
import template from '%/directives/revision-preview-modal.pug';
import { PayloadContent } from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
interface RevisionPreviewScope {
uuid: string;
content: PayloadContent;
application: WebApplication;
}
type State = {
componentViewer?: ComponentViewer;
};
class RevisionPreviewModalCtrl
extends PureViewCtrl<unknown, State>
implements RevisionPreviewScope
{
$element: JQLite;
$timeout: ng.ITimeoutService;
uuid!: string;
content!: PayloadContent;
title?: string;
application!: WebApplication;
note!: SNNote;
private originalNote!: SNNote;
/* @ngInject */
constructor($element: JQLite, $timeout: ng.ITimeoutService) {
super($timeout);
this.$element = $element;
this.$timeout = $timeout;
}
$onInit() {
this.configure();
super.$onInit();
}
$onDestroy() {
if (this.state.componentViewer) {
this.application.componentManager.destroyComponentViewer(
this.state.componentViewer
);
}
super.$onDestroy();
}
get componentManager() {
return this.application.componentManager;
}
async configure() {
this.note = (await this.application.createTemplateItem(
ContentType.Note,
this.content
)) as SNNote;
this.originalNote = this.application.findItem(this.uuid) as SNNote;
const component = this.componentManager.editorForNote(this.originalNote);
if (component) {
const componentViewer =
this.application.componentManager.createComponentViewer(component);
componentViewer.setReadonly(true);
componentViewer.lockReadonly = true;
componentViewer.overrideContextItem = this.note;
this.setState({ componentViewer });
}
}
restore(asCopy: boolean) {
const run = async () => {
if (asCopy) {
await this.application.duplicateItem(this.originalNote, {
...this.content,
title: this.content.title
? this.content.title + ' (copy)'
: undefined,
});
} else {
this.application.changeAndSaveItem(
this.uuid,
(mutator) => {
mutator.unsafe_setCustomContent(this.content);
},
true,
PayloadSource.RemoteActionRetrieved
);
}
this.dismiss();
};
if (!asCopy) {
if (this.originalNote.locked) {
this.application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
return;
}
confirmDialog({
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
confirmButtonStyle: 'danger',
}).then((confirmed) => {
if (confirmed) {
run();
}
});
} else {
run();
}
}
dismiss() {
const elem = this.$element;
const scope = elem.scope();
scope.$destroy();
elem.remove();
}
}
export class RevisionPreviewModal extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = RevisionPreviewModalCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
uuid: '=',
content: '=',
title: '=',
application: '=',
};
}
}

View File

@@ -1,69 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { WebDirective } from './../../types';
import template from '%/directives/sync-resolution-menu.pug';
class SyncResolutionMenuCtrl {
closeFunction!: () => void
application!: WebApplication
$timeout: ng.ITimeoutService
status: Partial<{
backupFinished: boolean,
resolving: boolean,
attemptedResolution: boolean,
success: boolean
fail: boolean
}> = {}
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService
) {
this.$timeout = $timeout;
}
downloadBackup(encrypted: boolean) {
this.application.getArchiveService().downloadBackup(encrypted);
this.status.backupFinished = true;
}
skipBackup() {
this.status.backupFinished = true;
}
async performSyncResolution() {
this.status.resolving = true;
await this.application.resolveOutOfSync();
this.$timeout(() => {
this.status.resolving = false;
this.status.attemptedResolution = true;
if (this.application.isOutOfSync()) {
this.status.fail = true;
} else {
this.status.success = true;
}
});
}
close() {
this.$timeout(() => {
this.closeFunction();
});
}
}
export class SyncResolutionMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = SyncResolutionMenuCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
closeFunction: '&',
application: '='
};
}
}

View File

@@ -0,0 +1,6 @@
export const ElementIds = {
NoteTextEditor: 'note-text-editor',
NoteTitleEditor: 'note-title-editor',
EditorContent: 'editor-content',
EditorColumn: 'editor-column',
};

View File

@@ -1 +0,0 @@
export { trusted } from './trusted';

View File

@@ -1,6 +0,0 @@
/* @ngInject */
export function trusted($sce: ng.ISCEService) {
return function(url: string) {
return $sce.trustAsResourceUrl(url);
};
}

View File

@@ -5,7 +5,6 @@ import '@reach/dialog/styles.css';
import '../stylesheets/index.css.scss'; import '../stylesheets/index.css.scss';
// Vendor // Vendor
import 'angular';
import '../../../vendor/assets/javascripts/zip/deflate'; import '../../../vendor/assets/javascripts/zip/deflate';
import '../../../vendor/assets/javascripts/zip/inflate'; import '../../../vendor/assets/javascripts/zip/inflate';
import '../../../vendor/assets/javascripts/zip/zip'; import '../../../vendor/assets/javascripts/zip/zip';

View File

@@ -1,3 +0,0 @@
export enum RootScopeMessages {
NewUpdateAvailable = 'new-update-available'
}

View File

@@ -1,4 +1,3 @@
import { IconType } from '@/components/Icon';
import { action, makeAutoObservable, observable } from 'mobx'; import { action, makeAutoObservable, observable } from 'mobx';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments'; import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import { import {
@@ -6,14 +5,15 @@ import {
ContentType, ContentType,
FeatureIdentifier, FeatureIdentifier,
SNComponent, SNComponent,
IconType,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
const PREFERENCE_IDS = [ const PREFERENCE_IDS = [
'general', 'general',
'account', 'account',
'appearance',
'security', 'security',
'appearance',
'backups', 'backups',
'listed', 'listed',
'shortcuts', 'shortcuts',
@@ -39,8 +39,8 @@ interface SelectableMenuItem extends PreferencesMenuItem {
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' }, { id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' }, { id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' }, { id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' }, { id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' }, { id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' }, { id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
@@ -52,8 +52,8 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' }, { id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' }, { id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' }, { id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' }, { id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' }, { id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' }, { id: 'help-feedback', label: 'Help & feedback', icon: 'help' },

View File

@@ -1,7 +1,10 @@
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
export const Title: FunctionComponent = ({ children }) => ( export const Title: FunctionComponent = ({ children }) => (
<h2 className="text-base m-0 mb-1">{children}</h2> <>
<h2 className="text-base m-0 mb-1">{children}</h2>
<div className="min-h-2" />
</>
); );
export const Subtitle: FunctionComponent<{ className?: string }> = ({ export const Subtitle: FunctionComponent<{ className?: string }> = ({

View File

@@ -1,5 +1,6 @@
import { Icon, IconType } from '@/components/Icon'; import { Icon } from '@/components/Icon';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
interface Props { interface Props {
iconType: IconType; iconType: IconType;
@@ -15,7 +16,9 @@ export const MenuItem: FunctionComponent<Props> = ({
onClick, onClick,
}) => ( }) => (
<div <div
className={`preferences-menu-item select-none ${selected ? 'selected' : ''}`} className={`preferences-menu-item select-none ${
selected ? 'selected' : ''
}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onClick(); onClick();

View File

@@ -1,9 +0,0 @@
import { toDirective } from '../components/utils';
import {
PreferencesViewWrapper,
PreferencesViewWrapperProps,
} from './PreferencesViewWrapper';
export const PreferencesDirective = toDirective<PreferencesViewWrapperProps>(
PreferencesViewWrapper
);

View File

@@ -1,10 +1,10 @@
import { Dropdown, DropdownItem } from '@/components/Dropdown'; import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { PremiumModalProvider, usePremiumModal } from '@/components/Premium'; import { usePremiumModal } from '@/components/Premium';
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu'; import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { Switch } from '@/components/Switch'; import { Switch } from '@/components/Switch';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { Features } from '@standardnotes/features'; import { GetFeatures } from '@standardnotes/features';
import { import {
ContentType, ContentType,
FeatureIdentifier, FeatureIdentifier,
@@ -28,169 +28,175 @@ type Props = {
application: WebApplication; application: WebApplication;
}; };
const AppearancePane: FunctionComponent<Props> = observer(({ application }) => { export const Appearance: FunctionComponent<Props> = observer(
const premiumModal = usePremiumModal(); ({ application }) => {
const isEntitledToMidnightTheme = const premiumModal = usePremiumModal();
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) === const isEntitledToMidnightTheme =
FeatureStatus.Entitled; application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled;
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]); const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
const [autoLightTheme, setAutoLightTheme] = useState<string>( const [autoLightTheme, setAutoLightTheme] = useState<string>(
() => () =>
application.getPreference( application.getPreference(
PrefKey.AutoLightThemeIdentifier, PrefKey.AutoLightThemeIdentifier,
'Default' 'Default'
) as string ) as string
); );
const [autoDarkTheme, setAutoDarkTheme] = useState<string>( const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() => () =>
application.getPreference( application.getPreference(
PrefKey.AutoDarkThemeIdentifier, PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default' isEntitledToMidnightTheme
) as string ? FeatureIdentifier.MidnightTheme
); : 'Default'
const [useDeviceSettings, setUseDeviceSettings] = useState( ) as string
() => );
application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean const [useDeviceSettings, setUseDeviceSettings] = useState(
); () =>
application.getPreference(
PrefKey.UseSystemColorScheme,
false
) as boolean
);
useEffect(() => { useEffect(() => {
const themesAsItems: DropdownItem[] = ( const themesAsItems: DropdownItem[] = (
application.getDisplayableItems(ContentType.Theme) as SNTheme[] application.getDisplayableItems(ContentType.Theme) as SNTheme[]
) )
.filter((theme) => !theme.isLayerable()) .filter((theme) => !theme.isLayerable())
.sort(sortThemes) .sort(sortThemes)
.map((theme) => { .map((theme) => {
return { return {
label: theme.name, label: theme.name,
value: theme.identifier as string, value: theme.identifier as string,
}; };
});
GetFeatures()
.filter(
(feature) =>
feature.content_type === ContentType.Theme && !feature.layerable
)
.forEach((theme) => {
if (
themesAsItems.findIndex(
(item) => item.value === theme.identifier
) === -1
) {
themesAsItems.push({
label: theme.name as string,
value: theme.identifier,
icon: 'premium-feature',
});
}
});
themesAsItems.unshift({
label: 'Default',
value: 'Default',
}); });
Features.filter( setThemeItems(themesAsItems);
(feature) => }, [application]);
feature.content_type === ContentType.Theme && !feature.layerable
).forEach((theme) => { const toggleUseDeviceSettings = () => {
if ( application.setPreference(
themesAsItems.findIndex((item) => item.value === theme.identifier) === PrefKey.UseSystemColorScheme,
-1 !useDeviceSettings
) { );
themesAsItems.push({ if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
label: theme.name as string, application.setPreference(
value: theme.identifier, PrefKey.AutoLightThemeIdentifier,
icon: 'premium-feature', autoLightTheme as FeatureIdentifier
}); );
} }
}); if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
autoDarkTheme as FeatureIdentifier
);
}
setUseDeviceSettings(!useDeviceSettings);
};
themesAsItems.unshift({ const changeAutoLightTheme = (value: string, item: DropdownItem) => {
label: 'Default', if (item.icon === 'premium-feature') {
value: 'Default', premiumModal.activate(`${item.label} theme`);
}); } else {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
value as FeatureIdentifier
);
setAutoLightTheme(value);
}
};
setThemeItems(themesAsItems); const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
}, [application]); if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`);
} else {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
value as FeatureIdentifier
);
setAutoDarkTheme(value);
}
};
const toggleUseDeviceSettings = () => { return (
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings); <PreferencesPane>
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) { <PreferencesGroup>
application.setPreference( <PreferencesSegment>
PrefKey.AutoLightThemeIdentifier, <Title>Themes</Title>
autoLightTheme as FeatureIdentifier <div className="mt-2">
); <div className="flex items-center justify-between">
} <div className="flex flex-col">
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) { <Subtitle>Use system color scheme</Subtitle>
application.setPreference( <Text>
PrefKey.AutoDarkThemeIdentifier, Automatically change active theme based on your system
autoDarkTheme as FeatureIdentifier settings.
); </Text>
} </div>
setUseDeviceSettings(!useDeviceSettings); <Switch
}; onChange={toggleUseDeviceSettings}
checked={useDeviceSettings}
const changeAutoLightTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`);
} else {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
value as FeatureIdentifier
);
setAutoLightTheme(value);
}
};
const changeAutoDarkTheme = (value: string, item: DropdownItem) => {
if (item.icon === 'premium-feature') {
premiumModal.activate(`${item.label} theme`);
} else {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
value as FeatureIdentifier
);
setAutoDarkTheme(value);
}
};
return (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Themes</Title>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Use system color scheme</Subtitle>
<Text>
Automatically change active theme based on your system settings.
</Text>
</div>
<Switch
onChange={toggleUseDeviceSettings}
checked={useDeviceSettings}
/>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Light Theme</Subtitle>
<Text>Theme to be used for system light mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-light-theme-dropdown"
label="Select the automatic light theme"
items={themeItems}
value={autoLightTheme}
onChange={changeAutoLightTheme}
disabled={!useDeviceSettings}
/> />
</div> </div>
</div> <HorizontalSeparator classes="mt-5 mb-3" />
<HorizontalSeparator classes="mt-5 mb-3" /> <div>
<div> <Subtitle>Automatic Light Theme</Subtitle>
<Subtitle>Automatic Dark Theme</Subtitle> <Text>Theme to be used for system light mode:</Text>
<Text>Theme to be used for system dark mode:</Text> <div className="mt-2">
<div className="mt-2"> <Dropdown
<Dropdown id="auto-light-theme-dropdown"
id="auto-dark-theme-dropdown" label="Select the automatic light theme"
label="Select the automatic dark theme" items={themeItems}
items={themeItems} value={autoLightTheme}
value={autoDarkTheme} onChange={changeAutoLightTheme}
onChange={changeAutoDarkTheme} disabled={!useDeviceSettings}
disabled={!useDeviceSettings} />
/> </div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Dark Theme</Subtitle>
<Text>Theme to be used for system dark mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-dark-theme-dropdown"
label="Select the automatic dark theme"
items={themeItems}
value={autoDarkTheme}
onChange={changeAutoDarkTheme}
disabled={!useDeviceSettings}
/>
</div>
</div> </div>
</div> </div>
</div> </PreferencesSegment>
</PreferencesSegment> </PreferencesGroup>
</PreferencesGroup> </PreferencesPane>
</PreferencesPane> );
); }
});
export const Appearance: FunctionComponent<Props> = observer(
({ application }) => (
<PremiumModalProvider state={application.getAppState().features}>
<AppearancePane application={application} />
</PremiumModalProvider>
)
); );

View File

@@ -74,6 +74,7 @@ export const Extensions: FunctionComponent<{
const confirmExtension = async () => { const confirmExtension = async () => {
await application.insertItem(confirmableExtension as SNComponent); await application.insertItem(confirmableExtension as SNComponent);
application.sync();
setExtensions(loadExtensions(application)); setExtensions(loadExtensions(application));
}; };
@@ -109,7 +110,6 @@ export const Extensions: FunctionComponent<{
{!confirmableExtension && ( {!confirmableExtension && (
<PreferencesSegment> <PreferencesSegment>
<Title>Install Custom Extension</Title> <Title>Install Custom Extension</Title>
<div className="min-h-2" />
<DecoratedInput <DecoratedInput
placeholder={'Enter Extension URL'} placeholder={'Enter Extension URL'}
text={customUrl} text={customUrl}

View File

@@ -10,20 +10,20 @@ import { observer } from 'mobx-react-lite';
interface GeneralProps { interface GeneralProps {
appState: AppState; appState: AppState;
application: WebApplication; application: WebApplication;
extensionsLatestVersions: ExtensionsLatestVersions, extensionsLatestVersions: ExtensionsLatestVersions;
} }
export const General: FunctionComponent<GeneralProps> = observer( export const General: FunctionComponent<GeneralProps> = observer(
({ ({ appState, application, extensionsLatestVersions }) => (
appState,
application,
extensionsLatestVersions
}) => (
<PreferencesPane> <PreferencesPane>
<Tools application={application} /> <Tools application={application} />
<Defaults application={application} /> <Defaults application={application} />
<ErrorReporting appState={appState} /> <ErrorReporting appState={appState} />
<Advanced application={application} appState={appState} extensionsLatestVersions={extensionsLatestVersions} /> <Advanced
application={application}
appState={appState}
extensionsLatestVersions={extensionsLatestVersions}
/>
</PreferencesPane> </PreferencesPane>
) )
); );

View File

@@ -5,72 +5,74 @@ import {
Title, Title,
Subtitle, Subtitle,
Text, Text,
LinkButton,
} from '../components'; } from '../components';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { ContentType, SNComponent } from '@standardnotes/snjs'; import { ButtonType, ListedAccount } from '@standardnotes/snjs';
import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks';
import { BlogItem } from './listed/BlogItem'; import { ListedAccountItem } from './listed/BlogItem';
import { Button } from '@/components/Button';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
}; };
export const Listed = observer(({ application }: Props) => { export const Listed = observer(({ application }: Props) => {
const [items, setItems] = useState<SNComponent[]>([]); const [accounts, setAccounts] = useState<ListedAccount[]>([]);
const [isDeleting, setIsDeleting] = useState(false); const [requestingAccount, setRequestingAccount] = useState<boolean>();
const reloadItems = useCallback(() => { const reloadAccounts = useCallback(async () => {
const components = application setAccounts(await application.getListedAccounts());
.getItems(ContentType.ActionsExtension)
.filter(
(item) => (item as SNComponent).package_info?.name === 'Listed'
) as SNComponent[];
setItems(components);
}, [application]); }, [application]);
useEffect(() => { useEffect(() => {
reloadItems(); reloadAccounts();
}, [reloadItems]); }, [reloadAccounts]);
const disconnectListedBlog = (item: SNItem) => { const registerNewAccount = useCallback(() => {
return new Promise((resolve, reject) => { setRequestingAccount(true);
setIsDeleting(true);
application const requestAccount = async () => {
.deleteItem(item) const account = await application.requestNewListedAccount();
.then(() => { if (account) {
reloadItems(); const openSettings = await application.alertService.confirm(
setIsDeleting(false); `Your new Listed blog has been successfully created!` +
resolve(true); ` You can publish a new post to your blog from Standard Notes via the` +
}) ` <i>Actions</i> menu in the editor pane. Open your blog settings to begin setting it up.`,
.catch((err) => { undefined,
application.alertService.alert(err); 'Open Settings',
setIsDeleting(false); ButtonType.Info,
console.error(err); 'Later'
reject(err); );
}); reloadAccounts();
}); if (openSettings) {
}; const info = await application.getListedAccountInfo(account);
if (info) {
application.deviceInterface.openUrl(info?.settings_url);
}
}
}
setRequestingAccount(false);
};
requestAccount();
}, [application, reloadAccounts]);
return ( return (
<PreferencesPane> <PreferencesPane>
{items.length > 0 && ( {accounts.length > 0 && (
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title> <Title>
Your {items.length === 1 ? 'Blog' : 'Blogs'} on Listed Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
</Title> </Title>
<div className="h-2 w-full" /> <div className="h-2 w-full" />
{items.map((item, index, array) => { {accounts.map((item, index, array) => {
return ( return (
<BlogItem <ListedAccountItem
item={item} account={item}
showSeparator={index !== array.length - 1} showSeparator={index !== array.length - 1}
disabled={isDeleting} key={item.authorId}
disconnect={disconnectListedBlog}
key={item.uuid}
application={application} application={application}
/> />
); );
@@ -95,21 +97,19 @@ export const Listed = observer(({ application }: Props) => {
</a> </a>
</Text> </Text>
</PreferencesSegment> </PreferencesSegment>
{items.length === 0 ? ( <PreferencesSegment>
<PreferencesSegment> <Subtitle>Get Started</Subtitle>
<Subtitle>How to get started?</Subtitle> <Text>Create a free Listed author account to get started.</Text>
<Text> <Button
First, youll need to sign up for Listed. Once you have your className="mt-3"
Listed account, follow the instructions to connect it with your type="normal"
Standard Notes account. disabled={requestingAccount}
</Text> label={
<LinkButton requestingAccount ? 'Creating account...' : 'Create New Author'
className="min-w-20 mt-3" }
link="https://listed.to" onClick={registerNewAccount}
label="Get started" />
/> </PreferencesSegment>
</PreferencesSegment>
) : null}
</PreferencesGroup> </PreferencesGroup>
</PreferencesPane> </PreferencesPane>
); );

View File

@@ -1,68 +1,80 @@
import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/preferences/components'; import {
PreferencesGroup,
PreferencesSegment,
Subtitle,
Text,
Title,
} from '@/preferences/components';
import { Button } from '@/components/Button'; import { Button } from '@/components/Button';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { observer } from '@node_modules/mobx-react-lite'; import { observer } from '@node_modules/mobx-react-lite';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator'; import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { dateToLocalizedString } from '@standardnotes/snjs'; import { dateToLocalizedString } from '@standardnotes/snjs';
import { useState } from 'preact/hooks'; import { useCallback, useState } from 'preact/hooks';
import { ChangeEmail } from '@/preferences/panes/account/changeEmail'; import { ChangeEmail } from '@/preferences/panes/account/changeEmail';
import { PasswordWizardType } from '@/types'; import { FunctionComponent, render } from 'preact';
import { FunctionComponent } from 'preact';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { PasswordWizard } from '@/components/PasswordWizard';
type Props = { type Props = {
application: WebApplication; application: WebApplication;
appState: AppState; appState: AppState;
}; };
export const Credentials: FunctionComponent<Props> = observer(({ application, appState }: Props) => { export const Credentials: FunctionComponent<Props> = observer(
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false); ({ application }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] =
useState(false);
const user = application.getUser(); const user = application.getUser();
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date; const passwordCreatedAtTimestamp =
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp); application.getUserPasswordCreationDate() as Date;
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
return ( const presentPasswordWizard = useCallback(() => {
<PreferencesGroup> render(
<PreferencesSegment> <PasswordWizard application={application} />,
<Title>Credentials</Title> document.body.appendChild(document.createElement('div'))
<div className={'text-input mt-2'}> );
Email }, [application]);
</div>
<Text> return (
You're signed in as <span className='font-bold'>{user?.email}</span> <PreferencesGroup>
</Text> <PreferencesSegment>
<Button <Title>Credentials</Title>
className='min-w-20 mt-3' <Subtitle>Email</Subtitle>
type='normal' <Text>
label='Change email' You're signed in as <span className="font-bold">{user?.email}</span>
onClick={() => { </Text>
setIsChangeEmailDialogOpen(true); <Button
}} className="min-w-20 mt-3"
/> type="normal"
<HorizontalSeparator classes='mt-5 mb-3' /> label="Change email"
<div className={'text-input mt-2'}> onClick={() => {
Password setIsChangeEmailDialogOpen(true);
</div> }}
<Text>
Current password was set on <span className='font-bold'>{passwordCreatedOn}</span>
</Text>
<Button
className='min-w-20 mt-3'
type='normal'
label='Change password'
onClick={() => {
application.presentPasswordWizard(PasswordWizardType.ChangePassword);
}}
/>
{isChangeEmailDialogOpen && (
<ChangeEmail
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
application={application}
/> />
)} <HorizontalSeparator classes="mt-5 mb-3" />
</PreferencesSegment> <Subtitle>Password</Subtitle>
</PreferencesGroup> <Text>
); Current password was set on{' '}
}); <span className="font-bold">{passwordCreatedOn}</span>
</Text>
<Button
className="min-w-20 mt-3"
type="normal"
label="Change password"
onClick={presentPasswordWizard}
/>
{isChangeEmailDialogOpen && (
<ChangeEmail
onCloseDialog={() => setIsChangeEmailDialogOpen(false)}
application={application}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
);
}
);

View File

@@ -22,7 +22,6 @@ const SignOutView: FunctionComponent<{
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Sign out</Title> <Title>Sign out</Title>
<div className="min-h-2" />
<Subtitle>Other devices</Subtitle> <Subtitle>Other devices</Subtitle>
<Text>Want to sign out on all devices except this one?</Text> <Text>Want to sign out on all devices except this one?</Text>
<div className="min-h-3" /> <div className="min-h-3" />
@@ -74,7 +73,6 @@ const ClearSessionDataView: FunctionComponent<{
<PreferencesGroup> <PreferencesGroup>
<PreferencesSegment> <PreferencesSegment>
<Title>Clear session data</Title> <Title>Clear session data</Title>
<div className="min-h-2" />
<Text>This will delete all local items and preferences.</Text> <Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" /> <div className="min-h-3" />
<Button <Button

Some files were not shown because too many files have changed in this diff Show More