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"
],
"plugins": [
"angularjs-annotate",
["@babel/plugin-transform-react-jsx", {
"pragma": "h",
"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 { AppState } from '@/ui_models/app_state';
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 CtrlProps = Partial<Record<string, any>>;
export type PureComponentState = Partial<Record<string, any>>;
export type PureComponentProps = Partial<Record<string, any>>;
export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
$timeout: ng.ITimeoutService;
/** Passed through templates */
application!: WebApplication;
state: S = {} as any;
private unsubApp: any;
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;
export abstract class PureComponent<
P = PureComponentProps,
S = PureComponentState
> extends Component<P, S> {
private unsubApp!: () => void;
private unsubState!: () => void;
private reactionDisposers: IReactionDisposer[] = [];
/* @ngInject */
constructor($timeout: ng.ITimeoutService, public props: P = {} as any) {
this.$timeout = $timeout;
constructor(props: P, protected application: WebApplication) {
super(props);
}
$onInit(): void {
this.state = {
...this.getInitialState(),
...this.state,
};
componentDidMount() {
this.addAppEventObserver();
this.addAppStateObserver();
this.templateReady = true;
}
deinit(): void {
@@ -43,63 +32,38 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
disposer();
}
this.reactionDisposers.length = 0;
this.unsubApp = undefined;
this.unsubState = undefined;
if (this.stateTimeout) {
this.$timeout.cancel(this.stateTimeout);
}
(this.unsubApp as unknown) = undefined;
(this.unsubState as unknown) = undefined;
}
$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();
}
render() {
return <div>Must override</div>;
}
public get appState(): AppState {
return this.application.getAppState();
}
/** @private */
async resetState(): Promise<void> {
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));
protected getElement(): Element | null {
return findDOMNode(this);
}
autorun(view: (r: IReactionPublic) => void): void {
@@ -151,7 +115,7 @@ export class PureViewCtrl<P = CtrlProps, S = CtrlState> {
/** @override */
async onAppStart() {
await this.resetState();
/** Optional override */
}
onLocalDataLoaded() {

View File

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

View File

@@ -1,8 +1,8 @@
import { observer } from 'mobx-react-lite';
import { toDirective } from '@/components/utils';
import { useCloseOnClickOutside } from '@/components/utils';
import { AppState } from '@/ui_models/app_state';
import { WebApplication } from '@/ui_models/application';
import { useState } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import { GeneralAccountMenu } from './GeneralAccountMenu';
import { FunctionComponent } from 'preact';
import { SignInPane } from './SignIn';
@@ -21,9 +21,12 @@ export enum AccountMenuPane {
type Props = {
appState: AppState;
application: WebApplication;
onClickOutside: () => void;
};
type PaneSelectorProps = Props & {
type PaneSelectorProps = {
appState: AppState;
application: WebApplication;
menuPane: AccountMenuPane;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
@@ -79,8 +82,8 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
}
);
const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState }) => {
export const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState, onClickOutside }) => {
const {
currentPane,
setCurrentPane,
@@ -88,6 +91,11 @@ const AccountMenu: FunctionComponent<Props> = observer(
closeAccountMenu,
} = appState.accountMenu;
const ref = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(ref, () => {
onClickOutside();
});
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLDivElement> = (
event
) => {
@@ -105,7 +113,7 @@ const AccountMenu: FunctionComponent<Props> = observer(
};
return (
<div className='sn-component'>
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`sn-menu-border sn-account-menu sn-dropdown ${
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';
import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact';
import { toDirective } from '@/components/utils';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { observer } from 'mobx-react-lite';
import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted';
import { UrlMissing } from '@/components/ComponentView/UrlMissing';
@@ -24,7 +29,7 @@ interface IProps {
application: WebApplication;
appState: AppState;
componentViewer: ComponentViewer;
requestReload?: (viewer: ComponentViewer) => void;
requestReload?: (viewer: ComponentViewer, force?: boolean) => void;
onLoad?: (component: SNComponent) => void;
manualDealloc?: boolean;
}
@@ -66,20 +71,6 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
openSubscriptionDashboard(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(() => {
setFeatureStatus(componentViewer.getFeatureStatus());
if (!componentViewer.lockReadonly) {
@@ -128,28 +119,35 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
} else {
document.addEventListener(VisibilityChangeKey, onVisibilityChange);
}
}, [componentViewer, didAttemptReload, onVisibilityChange, requestReload]);
}, [didAttemptReload, onVisibilityChange, componentViewer, requestReload]);
useEffect(() => {
if (!iframeRef.current) {
return;
}
useMemo(() => {
const loadTimeout = setTimeout(() => {
handleIframeTakingTooLongToLoad();
}, MaxLoadThreshold);
const iframe = iframeRef.current as HTMLIFrameElement;
iframe.onload = () => {
const contentWindow = iframe.contentWindow as Window;
excessiveLoadingTimeout.current = loadTimeout;
return () => {
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(() => {
const removeFeaturesChangedObserver = componentViewer.addEventObserver(
@@ -208,7 +206,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
<IssueOnLoading
componentName={component.name}
reloadIframe={() => {
reloadValidityStatus(), requestReload?.(componentViewer);
reloadValidityStatus(), requestReload?.(componentViewer, true);
}}
/>
)}
@@ -236,6 +234,7 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
{component.uuid && isComponentValid && (
<iframe
ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier}
frameBorder={0}
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';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { toDirective } from './utils';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
@@ -22,96 +21,94 @@ export const ConfirmSignoutContainer = observer((props: Props) => {
return <ConfirmSignoutModal {...props} />;
});
const ConfirmSignoutModal = observer(({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
export const ConfirmSignoutModal = observer(
({ application, appState }: Props) => {
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false);
const cancelRef = useRef<HTMLButtonElement>(null);
function closeDialog() {
appState.accountMenu.setSigningOut(false);
}
const cancelRef = useRef<HTMLButtonElement>(null);
function closeDialog() {
appState.accountMenu.setSigningOut(false);
}
const [localBackupsCount, setLocalBackupsCount] = useState(0);
const [localBackupsCount, setLocalBackupsCount] = useState(0);
useEffect(() => {
application.bridge.localBackupsCount().then(setLocalBackupsCount);
}, [appState.accountMenu.signingOut, application.bridge]);
useEffect(() => {
application.bridge.localBackupsCount().then(setLocalBackupsCount);
}, [appState.accountMenu.signingOut, application.bridge]);
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
End your session?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">
{STRING_SIGN_OUT_CONFIRMATION}
</p>
</AlertDialogDescription>
{localBackupsCount > 0 && (
<div className="flex">
<div className="sk-panel-row"></div>
<label className="flex items-center">
<input
type="checkbox"
checked={deleteLocalBackups}
onChange={(event) => {
setDeleteLocalBackups(
(event.target as HTMLInputElement).checked
);
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title capitalize">
End your session?
</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<p className="color-foreground">
{STRING_SIGN_OUT_CONFIRMATION}
</p>
</AlertDialogDescription>
{localBackupsCount > 0 && (
<div className="flex">
<div className="sk-panel-row"></div>
<label className="flex items-center">
<input
type="checkbox"
checked={deleteLocalBackups}
onChange={(event) => {
setDeleteLocalBackups(
(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">
Delete {localBackupsCount} local backup file
{localBackupsCount > 1 ? 's' : ''}
</span>
</label>
>
View backup files
</button>
</div>
)}
<div className="flex my-1 mt-4">
<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={() => {
application.bridge.viewlocalBackups();
if (deleteLocalBackups) {
application.signOutAndDeleteLocalBackups();
} else {
application.signOut();
}
closeDialog();
}}
>
View backup files
{application.hasAccount()
? 'Sign Out'
: 'Clear Session Data'}
</button>
</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>
</AlertDialog>
);
});
export const ConfirmSignoutDirective = toDirective<Props>(
ConfirmSignoutContainer
</AlertDialog>
);
}
);

View File

@@ -8,7 +8,8 @@ import {
} from '@reach/listbox';
import VisuallyHidden from '@reach/visually-hidden';
import { FunctionComponent } from 'preact';
import { IconType, Icon } from './Icon';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
export type DropdownItem = {
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 AccountCircleIcon from '../../icons/ic-account-circle.svg';
import AddIcon from '../../icons/ic-add.svg';
import HelpIcon from '../../icons/ic-help.svg';
import KeyboardIcon from '../../icons/ic-keyboard.svg';
import ListBulleted from '../../icons/ic-list-bulleted.svg';
import ListedIcon from '../../icons/ic-listed.svg';
import SecurityIcon from '../../icons/ic-security.svg';
import SettingsIcon from '../../icons/ic-settings.svg';
import StarIcon from '../../icons/ic-star.svg';
import ThemesIcon from '../../icons/ic-themes.svg';
import UserIcon from '../../icons/ic-user.svg';
import ArchiveIcon from '../../icons/ic-archive.svg';
import ArrowLeftIcon from '../../icons/ic-arrow-left.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
import CheckBoldIcon from '../../icons/ic-check-bold.svg';
import CheckCircleIcon from '../../icons/ic-check-circle.svg';
import CheckIcon from '../../icons/ic-check.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 DownloadIcon from '../../icons/ic-download.svg';
import InfoIcon from '../../icons/ic-info.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 EditorIcon from '../../icons/ic-editor.svg';
import EmailIcon from '../../icons/ic-email.svg';
import ServerIcon from '../../icons/ic-server.svg';
import EyeIcon from '../../icons/ic-eye.svg';
import EyeOffIcon from '../../icons/ic-eye-off.svg';
import LockIcon from '../../icons/ic-lock.svg';
import LockFilledIcon from '../../icons/ic-lock-filled.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import WindowIcon from '../../icons/ic-window.svg';
import HashtagIcon from '../../icons/ic-hashtag.svg';
import HelpIcon from '../../icons/ic-help.svg';
import InfoIcon from '../../icons/ic-info.svg';
import KeyboardIcon from '../../icons/ic-keyboard.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 MenuArrowDownIcon from '../../icons/ic-menu-arrow-down.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 { IconType } from '@standardnotes/snjs';
const ICONS = {
'menu-arrow-down-alt': MenuArrowDownAlt,
'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,
'account-circle': AccountCircleIcon,
'arrow-left': ArrowLeftIcon,
sync: SyncIcon,
'arrows-sort-down': ArrowsSortDownIcon,
'arrows-sort-up': ArrowsSortUpIcon,
'check-bold': CheckBoldIcon,
'check-circle': CheckCircleIcon,
signIn: SignInIcon,
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-down': ChevronDownIcon,
'chevron-right': ChevronRightIcon,
restore: RestoreIcon,
close: CloseIcon,
password: PasswordIcon,
'cloud-off': CloudOffIcon,
'eye-off': EyeOffIcon,
'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,
more: MoreIcon,
tune: TuneIcon,
'user-switch': UserSwitch,
accessibility: AccessibilityIcon,
add: AddIcon,
help: HelpIcon,
keyboard: KeyboardIcon,
spellcheck: NotesIcon,
'list-bulleted': ListBulleted,
'link-off': LinkOffIcon,
listed: ListedIcon,
security: SecurityIcon,
settings: SettingsIcon,
star: StarIcon,
themes: ThemesIcon,
user: UserIcon,
archive: ArchiveIcon,
authenticator: AuthenticatorIcon,
check: CheckIcon,
close: CloseIcon,
code: CodeIcon,
copy: CopyIcon,
download: DownloadIcon,
editor: EditorIcon,
email: EmailIcon,
eye: EyeIcon,
hashtag: HashtagIcon,
help: HelpIcon,
info: InfoIcon,
check: CheckIcon,
'check-bold': CheckBoldIcon,
'account-circle': AccountCircleIcon,
'menu-arrow-down': MenuArrowDownIcon,
'menu-close': MenuCloseIcon,
keyboard: KeyboardIcon,
listed: ListedIcon,
lock: LockIcon,
markdown: MarkdownIcon,
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,
'premium-feature': PremiumFeatureIcon,
};
export type IconType = keyof typeof ICONS;
type Props = {
type: IconType;
className?: string;
@@ -147,7 +149,10 @@ export const Icon: FunctionalComponent<Props> = ({
className = '',
ariaLabel,
}) => {
const IconComponent = ICONS[type];
const IconComponent = ICONS[type as keyof typeof ICONS];
if (!IconComponent) {
return null;
}
return (
<IconComponent
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 { Icon, IconType } from './Icon';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
interface Props {
/**

View File

@@ -1,8 +1,9 @@
import { FunctionComponent, Ref } from 'preact';
import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat';
import { Icon, IconType } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { IconType } from '@standardnotes/snjs';
type ToggleProps = {
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 { toDirective } from './utils';
import NotesIcon from '../../icons/il-notes.svg';
import { observer } from 'mobx-react-lite';
import { NotesOptionsPanel } from './NotesOptionsPanel';
@@ -11,31 +10,31 @@ type Props = {
appState: AppState;
};
const MultipleSelectedNotes = observer(({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount;
export const MultipleSelectedNotes = observer(
({ application, appState }: Props) => {
const count = appState.notes.selectedNotesCount;
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex">
<div className="mr-3">
<PinNoteButton appState={appState} />
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
<div className="flex">
<div className="mr-3">
<PinNoteButton appState={appState} />
</div>
<NotesOptionsPanel application={application} appState={appState} />
</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 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 { 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 {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { WebApplication } from '@/ui_models/application';
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { PremiumModalProvider } from './Premium';
PanelResizer,
PanelResizeType,
} from './PanelResizer';
type Props = {
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(
({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]);
const componentViewer = appState.foldersComponentViewer;
const enableNativeSmartTagsFeature =
appState.features.enableNativeSmartTagsFeature;
const [panelRef, setPanelRef] = useNavigationPanelRef();
const [ref, setRef] = useState<HTMLDivElement | null>();
const [panelWidth, setPanelWidth] = useState<number>(0);
const onCreateNewTag = useCallback(() => {
appState.tags.createNewTemplate();
}, [appState]);
useEffect(() => {
const removeObserver = application.addEventObserver(async () => {
const width = application.getPreference(PrefKey.TagsPanelWidth);
if (width) {
setPanelWidth(width);
}
}, ApplicationEvent.PreferencesChanged);
return () => {
removeObserver();
};
}, [application]);
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => {
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.TagsPanelWidth, width);
appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
},
[appState]
[application, appState]
);
const panelWidthEventCallback = useCallback(() => {
@@ -59,65 +50,40 @@ export const Navigation: FunctionComponent<Props> = observer(
}, [appState]);
return (
<PremiumModalProvider state={appState.features}>
<div
id="navigation"
className="sn-component section"
data-aria-label="Navigation"
ref={setPanelRef}
>
{componentViewer ? (
<div className="component-view-container">
<div className="component-view">
<ComponentView
componentViewer={componentViewer}
application={application}
appState={appState}
/>
<div
id="navigation"
className="sn-component section app-column app-column-first"
data-aria-label="Navigation"
ref={setRef}
>
<div id="navigation-content" className="content">
<div className="section-title-bar">
<div className="section-title-bar-header">
<div className="sk-h3 title">
<span className="sk-bold">Views</span>
</div>
</div>
) : (
<div id="navigation-content" className="content">
<div className="section-title-bar">
<div className="section-title-bar-header">
<div className="sk-h3 title">
<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 className="scrollable">
<SmartTagsSection appState={appState} />
<TagsSection appState={appState} />
</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 { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
@@ -39,5 +38,3 @@ export const NoAccountWarning = observer(({ appState }: Props) => {
</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 { observer } from 'mobx-react-lite';
import { toDirective } from './utils';
import { AutocompleteTagInput } from './AutocompleteTagInput';
import { NoteTag } from './NoteTag';
import { useEffect } from 'preact/hooks';
@@ -9,33 +8,24 @@ type Props = {
appState: AppState;
};
const NoteTagsContainer = observer(({ appState }: Props) => {
const {
tags,
tagsContainerMaxWidth,
} = appState.noteTags;
export const NoteTagsContainer = observer(({ appState }: Props) => {
const { tags, tagsContainerMaxWidth } = appState.noteTags;
useEffect(() => {
appState.noteTags.reloadTagsContainerMaxWidth();
}, [appState.noteTags]);
return (
<div
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
style={{
maxWidth: tagsContainerMaxWidth,
}}
>
{tags.map((tag) => (
<NoteTag
key={tag.uuid}
appState={appState}
tag={tag}
/>
))}
<AutocompleteTagInput appState={appState} />
</div>
<div
className="bg-transparent flex flex-wrap min-w-80 -mt-1 -mr-2"
style={{
maxWidth: tagsContainerMaxWidth,
}}
>
{tags.map((tag) => (
<NoteTag key={tag.uuid} appState={appState} tag={tag} />
))}
<AutocompleteTagInput appState={appState} />
</div>
);
});
export const NoteTagsContainerDirective = toDirective<Props>(NoteTagsContainer);

View File

@@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { NoteView } from '@Views/note_view/note_view';
import { NoteView } from './NoteView';
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
@@ -13,8 +13,7 @@ describe('editor-view', () => {
let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => {
const $timeout = {} as jest.Mocked<ng.ITimeoutService>;
ctrl = new NoteView($timeout);
ctrl = new NoteView({} as any);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
@@ -162,7 +161,7 @@ describe('editor-view', () => {
describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning();
@@ -172,7 +171,7 @@ describe('editor-view', () => {
it('should not reveal note contents if the authorization has not been passed', async () => {
jest
.spyOn(ctrl.application, 'authorizeNoteAccess')
.spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning();
@@ -184,7 +183,7 @@ describe('editor-view', () => {
describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => {
jest
.spyOn(ctrl.application, 'hasProtectionSources')
.spyOn(ctrl['application'], 'hasProtectionSources')
.mockImplementation(() => false);
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 { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils';
import { useCloseOnBlur, useCloseOnClickOutside } from './utils';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { NotesOptions } from './NotesOptions/NotesOptions';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
@@ -10,22 +10,17 @@ type Props = {
appState: AppState;
};
const NotesContextMenu = observer(({ application, appState }: Props) => {
const {
contextMenuOpen,
contextMenuPosition,
contextMenuMaxHeight,
} = appState.notes;
export const NotesContextMenu = observer(({ application, appState }: Props) => {
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
appState.notes;
const contextMenuRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
appState.notes.setContextMenuOpen(open)
);
useCloseOnClickOutside(
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
useCloseOnClickOutside(contextMenuRef, () =>
appState.notes.setContextMenuOpen(false)
);
const reloadContextMenuLayout = useCallback(() => {
@@ -42,7 +37,7 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
return contextMenuOpen ? (
<div
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={{
...contextMenuPosition,
maxHeight: contextMenuMaxHeight,
@@ -56,5 +51,3 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
</div>
) : 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 { FunctionComponent } from 'preact';
import { NotesListItem } from './NotesListItem';
import {
FOCUSABLE_BUT_NOT_TABBABLE,
NOTES_LIST_SCROLL_THRESHOLD,
} from '@/views/constants';
type Props = {
application: WebApplication;
@@ -16,9 +20,6 @@ type Props = {
paginate: () => void;
};
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const NotesList: FunctionComponent<Props> = observer(
({
application,
@@ -44,7 +45,7 @@ export const NotesList: FunctionComponent<Props> = observer(
if (!selectedTag.isSmartTag && tags.length === 1) {
return [];
}
return tags.map((tag) => tag.title);
return tags.map((tag) => tag.title).sort();
};
const openNoteContextMenu = (posX: number, posY: number) => {
@@ -84,7 +85,7 @@ export const NotesList: FunctionComponent<Props> = observer(
return (
<div
className="infinite-scroll"
className="infinite-scroll focus:shadow-none focus:outline-none"
id="notes-scrollable"
onScroll={onScroll}
onKeyDown={onKeyDown}

View File

@@ -1,4 +1,3 @@
import { getIconAndTintForEditor } from '@/preferences/panes/general-segments';
import { WebApplication } from '@/ui_models/application';
import {
CollectionSort,
@@ -74,7 +73,9 @@ export const NotesListItem: FunctionComponent<Props> = ({
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
const editorForNote = application.componentManager.editorForNote(note);
const editorName = editorForNote?.name ?? 'Plain editor';
const [icon, tint] = getIconAndTintForEditor(editorForNote?.identifier);
const [icon, tint] = application.iconsController.getIconAndTintForEditor(
editorForNote?.identifier
);
return (
<div
@@ -93,46 +94,8 @@ export const NotesListItem: FunctionComponent<Props> = ({
</div>
)}
<div className={`meta ${hideEditorIcon ? 'icon-hidden' : ''}`}>
<div className="name">
<div>{note.title}</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 className="name-container">
{note.title.length ? <div className="name">{note.title}</div> : null}
</div>
{!hidePreview && !note.hidePreview && !note.protected && (
<div className="note-preview">
@@ -186,6 +149,44 @@ export const NotesListItem: FunctionComponent<Props> = ({
</div>
) : null}
</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>
);
};

View File

@@ -6,19 +6,19 @@ import { useRef, useState } from 'preact/hooks';
import { Icon } from './Icon';
import { Menu } from './menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
import { toDirective, useCloseOnClickOutside } from './utils';
type Props = {
application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
closeDisplayOptionsMenu: () => void;
};
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ closeDisplayOptionsMenu, application }) => {
({ closeDisplayOptionsMenu, closeOnBlur, application }) => {
const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
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(() =>
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);
useCloseOnClickOutside(menuRef, (open: boolean) => {
if (!open) {
closeDisplayOptionsMenu();
}
});
return (
<div ref={menuRef} className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
@@ -135,6 +129,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between">
<span>Date modified</span>
@@ -152,6 +147,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between">
<span>Creation date</span>
@@ -169,6 +165,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title}
onBlur={closeOnBlur}
>
<div className="flex flex-grow items-center justify-between">
<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"
checked={!hidePreview}
onChange={toggleHidePreview}
onBlur={closeOnBlur}
>
<div className="flex flex-col max-w-3/4">Show note preview</div>
</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"
checked={!hideDate}
onChange={toggleHideDate}
onBlur={closeOnBlur}
>
Show date
</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"
checked={!hideTags}
onChange={toggleHideTags}
onBlur={closeOnBlur}
>
Show tags
</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"
checked={!hideEditorIcon}
onChange={toggleEditorIcon}
onBlur={closeOnBlur}
>
Show editor icon
</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"
checked={!hidePinned}
onChange={toggleHidePinned}
onBlur={closeOnBlur}
>
Show pinned notes
</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"
checked={!hideProtected}
onChange={toggleHideProtected}
onBlur={closeOnBlur}
>
Show protected notes
</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"
checked={showArchived}
onChange={toggleShowArchived}
onBlur={closeOnBlur}
>
Show archived notes
</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"
checked={showTrashed}
onChange={toggleShowTrashed}
onBlur={closeOnBlur}
>
Show trashed notes
</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 { Icon } from './Icon';
import { Switch } from './Switch';
import { Icon } from '../Icon';
import { Switch } from '../Switch';
import { observer } from 'mobx-react-lite';
import { useRef, useState, useEffect, useMemo } from 'preact/hooks';
import {
@@ -8,12 +8,18 @@ import {
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { SNApplication, SNNote } from '@standardnotes/snjs/dist/@types';
import { SNApplication, SNNote } from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
import { KeyboardModifier } from '@/services/ioService';
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;
appState: AppState;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
@@ -21,7 +27,7 @@ type Props = {
};
type DeletePermanentlyButtonProps = {
closeOnBlur: Props['closeOnBlur'];
closeOnBlur: NotesOptionsProps['closeOnBlur'];
onClick: () => void;
};
@@ -86,7 +92,10 @@ const formatDate = (date: Date | undefined) => {
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(
() => countNoteAttributes(note.text),
[note.text]
@@ -111,7 +120,7 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
const format = editor?.package_info?.file_type || 'txt';
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') ? (
<>
<div className="mb-1">
@@ -136,40 +145,72 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
};
const SpellcheckOptions: FunctionComponent<{
appState: AppState, note: SNNote
appState: AppState;
note: SNNote;
}> = ({ appState, note }) => {
const editor = appState.application.componentManager.editorForNote(note);
const spellcheckControllable = Boolean(
!editor ||
appState.application.getFeature(editor.identifier)?.spellcheckControl
!editor || editor.package_info.spellcheckControl
);
const noteSpellcheck = !spellcheckControllable ? true : note ? appState.notes.getSpellcheckStateForNote(note) : undefined;
const noteSpellcheck = !spellcheckControllable
? true
: note
? appState.notes.getSpellcheckStateForNote(note)
: undefined;
return (
<div className="flex flex-col px-3 py-1.5">
<Switch
className="px-0 py-0"
checked={noteSpellcheck}
disabled={!spellcheckControllable}
onChange={() => {
<div className="flex flex-col">
<button
className="sn-dropdown-item justify-between px-3 py-1"
onClick={() => {
appState.notes.toggleGlobalSpellcheckForNote(note);
}}
disabled={!spellcheckControllable}
>
<span className="flex items-center">
<Icon type='spellcheck' className={iconClass} />
<Icon type="spellcheck" className={iconClass} />
Spellcheck
</span>
</Switch>
<Switch
className="px-0"
checked={noteSpellcheck}
disabled={!spellcheckControllable}
/>
</button>
{!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>
);
};
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(
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => {
({
application,
appState,
closeOnBlur,
onSubmenuChange,
}: NotesOptionsProps) => {
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
const [tagsMenuPosition, setTagsMenuPosition] = useState<{
top: number;
@@ -232,25 +273,39 @@ export const NotesOptions = observer(
const defaultFontSize = window.getComputedStyle(
document.documentElement
).fontSize;
const maxTagsMenuSize = parseFloat(defaultFontSize) * 30;
const maxTagsMenuSize =
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = tagsButtonRef.current!.getBoundingClientRect();
const footerHeight = 32;
const buttonRect = tagsButtonRef.current?.getBoundingClientRect();
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) {
setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2);
}
if (buttonRect && footerHeightInPx) {
if (
buttonRect.top + maxTagsMenuSize >
clientHeight - footerHeightInPx
) {
setTagsMenuMaxHeight(
clientHeight -
buttonRect.top -
footerHeightInPx -
MENU_MARGIN_FROM_APP_BORDER
);
}
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
setTagsMenuPosition({
top: buttonRect.top,
right: clientWidth - buttonRect.left,
});
} else {
setTagsMenuPosition({
top: buttonRect.top,
left: buttonRect.right,
});
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
setTagsMenuPosition({
top: buttonRect.top,
right: clientWidth - buttonRect.left,
});
} else {
setTagsMenuPosition({
top: buttonRect.top,
left: buttonRect.right,
});
}
}
setTagsMenuOpen(!tagsMenuOpen);
@@ -298,45 +353,56 @@ export const NotesOptions = observer(
return (
<>
<Switch
onBlur={closeOnBlur}
className="px-3 py-1.5"
checked={locked}
onChange={() => {
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setLockSelectedNotes(!locked);
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="pencil-off" className={iconClass} />
Prevent editing
</span>
</Switch>
<Switch
onBlur={closeOnBlur}
className="px-3 py-1.5"
checked={!hidePreviews}
onChange={() => {
<Switch className="px-0" checked={locked} />
</button>
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="rich-text" className={iconClass} />
Show preview
</span>
</Switch>
<Switch
onBlur={closeOnBlur}
className="px-3 py-1.5"
checked={protect}
onChange={() => {
<Switch className="px-0" checked={!hidePreviews} />
</button>
<button
className="sn-dropdown-item justify-between"
onClick={() => {
appState.notes.setProtectSelectedNotes(!protect);
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className={iconClass} />
Protect
</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>
{appState.tags.tagsCount > 0 && (
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
@@ -360,7 +426,7 @@ export const NotesOptions = observer(
onKeyDown={(event) => {
if (event.key === 'Escape') {
setTagsMenuOpen(false);
tagsButtonRef.current!.focus();
tagsButtonRef.current?.focus();
}
}}
style={{
@@ -383,12 +449,13 @@ export const NotesOptions = observer(
>
<span
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>
</button>
))}
@@ -516,17 +583,13 @@ export const NotesOptions = observer(
</button>
</>
)}
{notes.length === 1 ? (
<>
<div className="min-h-1px my-2 bg-border"></div>
<SpellcheckOptions appState={appState} note={notes[0]} />
<div className="min-h-1px my-2 bg-border"></div>
<NoteAttributes application={application} note={notes[0]} />
<NoteSizeWarning note={notes[0]} />
</>
) : 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 { Icon } from './Icon';
import VisuallyHidden from '@reach/visually-hidden';
import { toDirective, useCloseOnBlur } from './utils';
import { useCloseOnBlur } from './utils';
import {
Disclosure,
DisclosureButton,
@@ -9,84 +9,96 @@ import {
} from '@reach/disclosure';
import { useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { NotesOptions } from './NotesOptions/NotesOptions';
import { WebApplication } from '@/ui_models/application';
type Props = {
application: WebApplication;
appState: AppState;
onClickPreprocessing?: () => Promise<void>;
};
export const NotesOptionsPanel = observer(({ application, appState }: Props) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
export const NotesOptionsPanel = observer(
({ application, appState, onClickPreprocessing }: Props) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
const onSubmenuChange = (open: boolean) => {
setSubmenuOpen(open);
};
const onSubmenuChange = (open: boolean) => {
setSubmenuOpen(open);
};
return (
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current!.getBoundingClientRect();
const { clientHeight } = document.documentElement;
const footerHeight = 32;
setMaxHeight(clientHeight - rect.bottom - footerHeight - 2);
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
setOpen(!open);
}}
>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
return (
<Disclosure
open={open}
onChange={async () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) {
const { clientHeight } = document.documentElement;
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2);
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
const newOpenState = !open;
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing();
}
setOpen(newOpenState);
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button"
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current!.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{open && (
<NotesOptions
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
</Disclosure>
);
});
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button"
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current?.focus();
}
}}
ref={panelRef}
style={{
...position,
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"
onBlur={closeOnBlur}
>
{open && (
<NotesOptions
application={application}
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 { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
@@ -9,22 +5,33 @@ import { PANEL_NAME_NOTES } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { NoAccountWarning } from './NoAccountWarning';
import { NotesList } from './NotesList';
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
import { PanelResizer } from './PanelResizer';
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 = {
application: WebApplication;
appState: AppState;
};
const NotesView: FunctionComponent<Props> = observer(
export const NotesView: FunctionComponent<Props> = observer(
({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null);
const displayOptionsMenuRef = useRef<HTMLDivElement>(null);
const {
completedFullSync,
@@ -36,8 +43,6 @@ const NotesView: FunctionComponent<Props> = observer(
renderedNotes,
selectedNotes,
setNoteFilterText,
showDisplayOptionsMenu,
toggleDisplayOptionsMenu,
searchBarElement,
selectNextNote,
selectPreviousNote,
@@ -46,8 +51,16 @@ const NotesView: FunctionComponent<Props> = observer(
onSearchInputBlur,
clearFilterText,
paginate,
panelWidth,
} = appState.notesView;
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false);
const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(
displayOptionsMenuRef,
setShowDisplayOptionsMenu
);
useEffect(() => {
handleFilterTextChanged();
}, [noteFilterText, handleFilterTextChanged]);
@@ -124,11 +137,12 @@ const NotesView: FunctionComponent<Props> = observer(
};
const panelResizeFinishCallback: ResizeFinishCallback = (
_lastWidth,
width,
_lastLeft,
_isMaxWidth,
isCollapsed
) => {
application.setPreference(PrefKey.NotesPanelWidth, width);
appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
};
@@ -137,16 +151,20 @@ const NotesView: FunctionComponent<Props> = observer(
appState.noteTags.reloadTagsContainerMaxWidth();
};
const toggleDisplayOptionsMenu = () => {
setShowDisplayOptionsMenu(!showDisplayOptionsMenu);
};
return (
<div
id="notes-column"
className="sn-component section notes"
className="sn-component section notes app-column app-column-second"
aria-label="Notes"
ref={notesViewPanelRef}
>
<div className="content">
<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="sk-h2 font-semibold title">{panelTitle}</div>
<button
@@ -190,34 +208,42 @@ const NotesView: FunctionComponent<Props> = observer(
</div>
<NoAccountWarning appState={appState} />
</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="left">
<div
className={`sk-app-bar-item ${
showDisplayOptionsMenu ? 'selected' : ''
}`}
onClick={() =>
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
}
<Disclosure
open={showDisplayOptionsMenu}
onChange={toggleDisplayOptionsMenu}
>
<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>
</div>
<DisclosureButton
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
showDisplayOptionsMenu ? 'selected' : ''
}`}
onBlur={closeDisplayOptMenuOnBlur}
>
<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>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={() =>
toggleDisplayOptionsMenu(false)
}
/>
)}
</div>
</div>
{completedFullSync && !renderedNotes.length ? (
@@ -239,19 +265,19 @@ const NotesView: FunctionComponent<Props> = observer(
</div>
{notesViewPanelRef.current && (
<PanelResizer
application={application}
collapsable={true}
hoverable={true}
defaultWidth={300}
panel={document.querySelector('notes-view') as HTMLDivElement}
prefKey={PrefKey.NotesPanelWidth}
panel={notesViewPanelRef.current}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>
)}
</div>
);
}
);
export const NotesViewDirective = toDirective<Props>(NotesView);

View File

@@ -1,60 +1,340 @@
import {
PanelResizerProps,
PanelResizerState,
} from '@/ui_models/panel_resizer';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Component, createRef } from 'preact';
import { debounce } from '@/utils';
export const PanelResizer: FunctionComponent<PanelResizerProps> = observer(
({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
}) => {
const [panelResizerState] = useState(
() =>
new PanelResizerState({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
})
export type ResizeFinishCallback = (
lastWidth: number,
lastLeft: number,
isMaxWidth: boolean,
isCollapsed: boolean
) => void;
export enum PanelSide {
Right = 'right',
Left = 'left',
}
export enum PanelResizeType {
WidthOnly = 'WidthOnly',
OffsetAndWidth = 'OffsetAndWidth',
}
type Props = {
width: number;
left: number;
alwaysVisible?: boolean;
collapsable?: boolean;
defaultWidth?: number;
hoverable?: boolean;
minWidth?: number;
panel: HTMLDivElement;
side: PanelSide;
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(() => {
if (panelResizerRef.current) {
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
isCollapsed() {
return this.lastWidth <= this.minWidth;
}
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 (
<div
className={`panel-resizer ${panelResizerState.side} ${
panelResizerState.hoverable ? 'hoverable' : ''
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${
panelResizerState.pressed ? 'dragging' : ''
} ${panelResizerState.collapsed ? 'collapsed' : ''}`}
onMouseDown={panelResizerState.onMouseDown}
onDblClick={panelResizerState.onDblClick}
ref={panelResizerRef}
className={`panel-resizer ${this.props.side} ${
this.props.hoverable ? 'hoverable' : ''
} ${this.props.alwaysVisible ? 'alwaysVisible' : ''} ${
this.state.pressed ? 'dragging' : ''
} ${this.state.collapsed ? 'collapsed' : ''}`}
onMouseDown={this.onMouseDown}
onDblClick={this.onDblClick}
ref={this.resizerElementRef}
></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 { FunctionComponent } from 'preact';
import { Icon } from './Icon';
import { toDirective } from './utils';
type Props = {
appState: AppState;
className?: string;
onClickPreprocessing?: () => Promise<void>;
};
export const PinNoteButton: FunctionComponent<Props> = observer(
({ appState, className = '' }) => {
({ appState, className = '', onClickPreprocessing }) => {
const notes = Object.values(appState.notes.selectedNotes);
const pinned = notes.some((note) => note.pinned);
const togglePinned = () => {
const togglePinned = async () => {
if (onClickPreprocessing) {
await onClickPreprocessing();
}
if (!pinned) {
appState.notes.setPinSelectedNotes(true);
} 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 { observer } from 'mobx-react-lite';
import { FunctionalComponent } from 'preact';
import { useCallback, useContext, useState } from 'preact/hooks';
import { useContext } from 'preact/hooks';
import { createContext } from 'react';
import { PremiumFeaturesModal } from '../PremiumFeaturesModal';

View File

@@ -51,7 +51,7 @@ export const PremiumFeaturesModal: FunctionalComponent<Props> = ({
<PremiumIllustration className="mb-2" />
</div>
<div className="text-lg text-center font-bold mb-1">
Enable premium features
Enable Premium Features
</div>
</AlertDialogLabel>
<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"
ref={plansButtonRef}
>
See our plans
See Plans
</button>
</div>
</div>

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import {
import {
ComponentArea,
ContentType,
FeatureIdentifier,
GetFeatures,
SNComponent,
SNTheme,
} from '@standardnotes/snjs';
@@ -17,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon';
import { Switch } from '../Switch';
import { toDirective, useCloseOnBlur } from '../utils';
import { useCloseOnBlur, useCloseOnClickOutside } from '../utils';
import {
quickSettingsKeyDownHandler,
themesMenuKeyDownHandler,
@@ -30,9 +32,16 @@ const focusModeAnimationDuration = 1255;
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';
export type ThemeItem = {
name: string;
identifier: FeatureIdentifier;
component?: SNTheme;
};
type MenuProps = {
appState: AppState;
application: WebApplication;
onClickOutside: () => void;
};
const toggleFocusMode = (enabled: boolean) => {
@@ -62,15 +71,15 @@ export const sortThemes = (a: SNTheme, b: SNTheme) => {
}
};
const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
({ application, appState }) => {
export const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
({ application, appState, onClickOutside }) => {
const {
closeQuickSettingsMenu,
shouldAnimateCloseMenu,
focusModeEnabled,
setFocusModeEnabled,
} = appState.quickSettingsMenu;
const [themes, setThemes] = useState<SNTheme[]>([]);
const [themes, setThemes] = useState<ThemeItem[]>([]);
const [toggleableComponents, setToggleableComponents] = useState<
SNComponent[]
>([]);
@@ -84,31 +93,72 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
const quickSettingsMenuRef = useRef<HTMLDivElement>(null);
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(mainRef, () => {
onClickOutside();
});
useEffect(() => {
toggleFocusMode(focusModeEnabled);
}, [focusModeEnabled]);
const reloadThemes = useCallback(() => {
const themes = application.getDisplayableItems(
ContentType.Theme
) as SNTheme[];
setThemes(themes.sort(sortThemes));
const themes = (
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
)
.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(
!themes.find((theme) => theme.active && !theme.isLayerable())
!themes
.map((item) => item?.component)
.find((theme) => theme?.active && !theme.isLayerable())
);
}, [application]);
const reloadToggleableComponents = useCallback(() => {
const toggleableComponents = (
application.getDisplayableItems(ContentType.Component) as SNComponent[]
).filter((component) =>
[ComponentArea.EditorStack, ComponentArea.TagsList].includes(
component.area
)
).filter(
(component) =>
[ComponentArea.EditorStack, ComponentArea.TagsList].includes(
component.area
) && component.identifier !== FeatureIdentifier.FoldersComponent
);
setToggleableComponents(toggleableComponents);
}, [application]);
useEffect(() => {
if (!themes.length) {
reloadThemes();
}
}, [reloadThemes, themes.length]);
useEffect(() => {
const cleanupItemStream = application.streamItems(
ContentType.Theme,
@@ -145,10 +195,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
prefsButtonRef.current?.focus();
}, []);
const [closeOnBlur] = useCloseOnBlur(
themesMenuRef as any,
setThemesMenuOpen
);
const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen);
const toggleThemesMenu = () => {
if (!themesMenuOpen && themesButtonRef.current) {
@@ -216,14 +263,14 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
};
const toggleDefaultTheme = () => {
const activeTheme = themes.find(
(theme) => theme.active && !theme.isLayerable()
);
const activeTheme = themes
.map((item) => item.component)
.find((theme) => theme?.active && !theme.isLayerable());
if (activeTheme) application.toggleTheme(activeTheme);
};
return (
<div className="sn-component">
<div ref={mainRef} className="sn-component">
<div
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
shouldAnimateCloseMenu
@@ -236,56 +283,54 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
<div className="px-3 mt-1 mb-2 font-semibold color-text uppercase">
Quick Settings
</div>
{themes && themes.length ? (
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
<DisclosureButton
onKeyDown={handleBtnKeyDown}
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
<DisclosureButton
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}
ref={themesButtonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
ref={defaultThemeButtonRef}
>
<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}
<div
className={`pseudo-radio-btn ${
defaultThemeOn ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
Default
</button>
{themes.map((theme) => (
<ThemesMenuButton
item={theme}
application={application}
key={theme.component?.uuid ?? theme.identifier}
onBlur={closeOnBlur}
ref={defaultThemeButtonRef}
>
<div
className={`pseudo-radio-btn ${
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}
/>
))}
</DisclosurePanel>
</Disclosure>
{toggleableComponents.map((component) => (
<button
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 { SNTheme } from '@standardnotes/snjs';
import { FeatureStatus } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useMemo } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { Icon } from '../Icon';
import { usePremiumModal } from '../Premium';
import { Switch } from '../Switch';
import { ThemeItem } from './QuickSettingsMenu';
type Props = {
theme: SNTheme;
item: ThemeItem;
application: WebApplication;
onBlur: (event: { relatedTarget: EventTarget | null }) => void;
};
export const ThemesMenuButton: FunctionComponent<Props> = ({
application,
theme,
item,
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) => {
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 (
<button
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${
theme.isLayerable() ? `justify-start` : `justify-between`
}`}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between`}
onClick={toggleTheme}
onBlur={onBlur}
>
{theme.isLayerable() ? (
{item.component?.isLayerable() ? (
<>
<Switch className="px-0 mr-2" checked={theme.active} />
{theme.package_info.name}
<div className="flex items-center">
<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={`pseudo-radio-btn ${
theme.active ? 'pseudo-radio-btn--checked' : ''
item.component?.active ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
<span className={theme.active ? 'font-semibold' : undefined}>
{theme.package_info.name}
<span
className={item.component?.active ? 'font-semibold' : undefined}
>
{item.name}
</span>
</div>
<div
className="w-5 h-5 rounded-full"
style={{
backgroundColor: theme.package_info?.dock_icon?.background_color,
}}
></div>
{item.component && canActivateTheme ? (
<div
className="w-5 h-5 rounded-full"
style={{
backgroundColor:
item.component.package_info?.dock_icon?.background_color,
}}
></div>
) : (
<Icon type="premium-feature" />
)}
</>
)}
</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 { Icon, IconType } from './Icon';
import { Icon } from './Icon';
import { IconType } from '@standardnotes/snjs';
type ButtonType = 'normal' | 'primary';

View File

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

View File

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

View File

@@ -29,7 +29,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
return (
<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.children}
@@ -51,8 +53,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
/>
<span
aria-hidden
className={`sn-switch-handle ${checked ? 'sn-switch-handle--right' : ''
}`}
className={`sn-switch-handle ${
checked ? 'sn-switch-handle--right' : ''
}`}
/>
</CustomCheckboxContainer>
</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 { usePremiumModal } from '@/components/Premium';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
} 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 { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd';
@@ -14,51 +11,38 @@ type Props = {
featuresState: FeaturesState;
};
export const RootTagDropZone: React.FC<Props> = observer(
({ tagsState, featuresState }) => {
const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
const hasFolders = featuresState.hasFolders;
export const RootTagDropZone: React.FC<Props> = observer(({ tagsState }) => {
const premiumModal = usePremiumModal();
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: () => {
return true;
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME);
return;
}
tagsState.assignParent(item.uuid, undefined);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.hasParent(item.uuid);
},
drop: (item) => {
tagsState.assignParent(item.uuid, undefined);
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
[tagsState, hasFolders, premiumModal]
);
}),
[tagsState, premiumModal]
);
if (!isNativeFoldersEnabled || !hasFolders) {
return null;
}
return (
<div
ref={dropRef}
className={`root-drop ${canDrop ? 'active' : ''} ${
isOver ? 'is-drag-over' : ''
}`}
>
<Icon className="color-neutral" type="link-off" />
<p className="content">
Move the tag here to <br />
remove it from its folder.
</p>
</div>
);
}
);
return (
<div
ref={dropRef}
className={`root-drop ${canDrop ? 'active' : ''} ${
isOver ? 'is-drag-over' : ''
}`}
>
<Icon className="color-neutral" type="link-off" />
<p className="content">
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 { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css';
import { SNSmartTag } from '@standardnotes/snjs';
import { SNSmartTag, IconType } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
@@ -37,7 +37,6 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
const level = 0;
const isSelected = tagsState.selected === tag;
const isEditing = tagsState.editingTag === tag;
const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
useEffect(() => {
setTitle(tag.title || '');
@@ -88,7 +87,7 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
tagsState.remove(tag);
}, [tagsState, tag]);
const isFaded = !isSmartTagsEnabled && !tag.isAllTag;
const isFaded = !tag.isAllTag;
const iconType = smartTagIconType(tag);
return (
@@ -104,14 +103,12 @@ export const SmartTagsListItem: FunctionComponent<Props> = observer(
>
{!tag.errorDecrypting ? (
<div className="tag-info">
{isSmartTagsEnabled && (
<div className={`tag-icon mr-1`}>
<Icon
type={iconType}
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
/>
</div>
)}
<div className={`tag-icon mr-1`}>
<Icon
type={iconType}
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
/>
</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}

View File

@@ -22,7 +22,7 @@ export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
<DndProvider backend={backend}>
{allTags.length === 0 ? (
<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>
) : (
<>

View File

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

View File

@@ -1,7 +1,9 @@
import { TagsList } from '@/components/Tags/TagsList';
import { AppState } from '@/ui_models/app_state';
import { ApplicationEvent } from '@/__mocks__/@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { TagsSectionAddButton } from './TagsSectionAddButton';
import { TagsSectionTitle } from './TagsSectionTitle';
@@ -11,11 +13,51 @@ type Props = {
export const TagsSection: FunctionComponent<Props> = observer(
({ 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 (
<section>
<div className="section-title-bar">
<div className="section-title-bar-header">
<TagsSectionTitle features={appState.features} />
<TagsSectionTitle
features={appState.features}
hasMigration={hasMigration}
onClickMigration={runMigration}
/>
<TagsSectionAddButton
tags={appState.tags}
features={appState.features}

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
* monitored.
*/
export function useCloseOnBlur(
container: { current?: HTMLDivElement },
container: { current?: HTMLDivElement | null },
setOpen: (open: boolean) => void
): [
(event: { relatedTarget: EventTarget | null }) => void,
@@ -33,55 +33,27 @@ export function useCloseOnBlur(
export function useCloseOnClickOutside(
container: { current: HTMLDivElement | null },
setOpen: (open: boolean) => void
callback: () => void
): void {
const closeOnClickOutside = useCallback(
(event: { target: EventTarget | null }) => {
if (!container.current?.contains(event.target as Node)) {
setOpen(false);
if (!container.current) {
return;
}
const isDescendant = container.current.contains(event.target as Node);
if (!isDescendant) {
callback();
}
},
[container, setOpen]
[container, callback]
);
useEffect(() => {
document.addEventListener('click', closeOnClickOutside);
document.addEventListener('click', closeOnClickOutside, { capture: true });
return () => {
document.removeEventListener('click', closeOnClickOutside);
document.removeEventListener('click', closeOnClickOutside, {
capture: true,
});
};
}, [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';
// Vendor
import 'angular';
import '../../../vendor/assets/javascripts/zip/deflate';
import '../../../vendor/assets/javascripts/zip/inflate';
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 { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
import {
@@ -6,14 +5,15 @@ import {
ContentType,
FeatureIdentifier,
SNComponent,
IconType,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
const PREFERENCE_IDS = [
'general',
'account',
'appearance',
'security',
'appearance',
'backups',
'listed',
'shortcuts',
@@ -39,8 +39,8 @@ interface SelectableMenuItem extends PreferencesMenuItem {
const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
@@ -52,8 +52,8 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },

View File

@@ -1,7 +1,10 @@
import { FunctionComponent } from 'preact';
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 }> = ({

View File

@@ -1,5 +1,6 @@
import { Icon, IconType } from '@/components/Icon';
import { Icon } from '@/components/Icon';
import { FunctionComponent } from 'preact';
import { IconType } from '@standardnotes/snjs';
interface Props {
iconType: IconType;
@@ -15,7 +16,9 @@ export const MenuItem: FunctionComponent<Props> = ({
onClick,
}) => (
<div
className={`preferences-menu-item select-none ${selected ? 'selected' : ''}`}
className={`preferences-menu-item select-none ${
selected ? 'selected' : ''
}`}
onClick={(e) => {
e.preventDefault();
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 { PremiumModalProvider, usePremiumModal } from '@/components/Premium';
import { usePremiumModal } from '@/components/Premium';
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { Switch } from '@/components/Switch';
import { WebApplication } from '@/ui_models/application';
import { Features } from '@standardnotes/features';
import { GetFeatures } from '@standardnotes/features';
import {
ContentType,
FeatureIdentifier,
@@ -28,169 +28,175 @@ type Props = {
application: WebApplication;
};
const AppearancePane: FunctionComponent<Props> = observer(({ application }) => {
const premiumModal = usePremiumModal();
const isEntitledToMidnightTheme =
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled;
export const Appearance: FunctionComponent<Props> = observer(
({ application }) => {
const premiumModal = usePremiumModal();
const isEntitledToMidnightTheme =
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled;
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoLightThemeIdentifier,
'Default'
) as string
);
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default'
) as string
);
const [useDeviceSettings, setUseDeviceSettings] = useState(
() =>
application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean
);
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoLightThemeIdentifier,
'Default'
) as string
);
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoDarkThemeIdentifier,
isEntitledToMidnightTheme
? FeatureIdentifier.MidnightTheme
: 'Default'
) as string
);
const [useDeviceSettings, setUseDeviceSettings] = useState(
() =>
application.getPreference(
PrefKey.UseSystemColorScheme,
false
) as boolean
);
useEffect(() => {
const themesAsItems: DropdownItem[] = (
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
)
.filter((theme) => !theme.isLayerable())
.sort(sortThemes)
.map((theme) => {
return {
label: theme.name,
value: theme.identifier as string,
};
useEffect(() => {
const themesAsItems: DropdownItem[] = (
application.getDisplayableItems(ContentType.Theme) as SNTheme[]
)
.filter((theme) => !theme.isLayerable())
.sort(sortThemes)
.map((theme) => {
return {
label: theme.name,
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(
(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',
});
setThemeItems(themesAsItems);
}, [application]);
const toggleUseDeviceSettings = () => {
application.setPreference(
PrefKey.UseSystemColorScheme,
!useDeviceSettings
);
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
autoLightTheme as FeatureIdentifier
);
}
});
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
autoDarkTheme as FeatureIdentifier
);
}
setUseDeviceSettings(!useDeviceSettings);
};
themesAsItems.unshift({
label: 'Default',
value: 'Default',
});
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);
}
};
setThemeItems(themesAsItems);
}, [application]);
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);
}
};
const toggleUseDeviceSettings = () => {
application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings);
if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) {
application.setPreference(
PrefKey.AutoLightThemeIdentifier,
autoLightTheme as FeatureIdentifier
);
}
if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) {
application.setPreference(
PrefKey.AutoDarkThemeIdentifier,
autoDarkTheme as FeatureIdentifier
);
}
setUseDeviceSettings(!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}
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>
</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}
/>
<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>
<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>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
});
export const Appearance: FunctionComponent<Props> = observer(
({ application }) => (
<PremiumModalProvider state={application.getAppState().features}>
<AppearancePane application={application} />
</PremiumModalProvider>
)
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
}
);

View File

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

View File

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

View File

@@ -5,72 +5,74 @@ import {
Title,
Subtitle,
Text,
LinkButton,
} from '../components';
import { observer } from 'mobx-react-lite';
import { WebApplication } from '@/ui_models/application';
import { ContentType, SNComponent } from '@standardnotes/snjs';
import { SNItem } from '@standardnotes/snjs/dist/@types/models/core/item';
import { ButtonType, ListedAccount } from '@standardnotes/snjs';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { BlogItem } from './listed/BlogItem';
import { ListedAccountItem } from './listed/BlogItem';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
};
export const Listed = observer(({ application }: Props) => {
const [items, setItems] = useState<SNComponent[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
const [accounts, setAccounts] = useState<ListedAccount[]>([]);
const [requestingAccount, setRequestingAccount] = useState<boolean>();
const reloadItems = useCallback(() => {
const components = application
.getItems(ContentType.ActionsExtension)
.filter(
(item) => (item as SNComponent).package_info?.name === 'Listed'
) as SNComponent[];
setItems(components);
const reloadAccounts = useCallback(async () => {
setAccounts(await application.getListedAccounts());
}, [application]);
useEffect(() => {
reloadItems();
}, [reloadItems]);
reloadAccounts();
}, [reloadAccounts]);
const disconnectListedBlog = (item: SNItem) => {
return new Promise((resolve, reject) => {
setIsDeleting(true);
application
.deleteItem(item)
.then(() => {
reloadItems();
setIsDeleting(false);
resolve(true);
})
.catch((err) => {
application.alertService.alert(err);
setIsDeleting(false);
console.error(err);
reject(err);
});
});
};
const registerNewAccount = useCallback(() => {
setRequestingAccount(true);
const requestAccount = async () => {
const account = await application.requestNewListedAccount();
if (account) {
const openSettings = await application.alertService.confirm(
`Your new Listed blog has been successfully created!` +
` 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.`,
undefined,
'Open Settings',
ButtonType.Info,
'Later'
);
reloadAccounts();
if (openSettings) {
const info = await application.getListedAccountInfo(account);
if (info) {
application.deviceInterface.openUrl(info?.settings_url);
}
}
}
setRequestingAccount(false);
};
requestAccount();
}, [application, reloadAccounts]);
return (
<PreferencesPane>
{items.length > 0 && (
{accounts.length > 0 && (
<PreferencesGroup>
<PreferencesSegment>
<Title>
Your {items.length === 1 ? 'Blog' : 'Blogs'} on Listed
Your {accounts.length === 1 ? 'Blog' : 'Blogs'} on Listed
</Title>
<div className="h-2 w-full" />
{items.map((item, index, array) => {
{accounts.map((item, index, array) => {
return (
<BlogItem
item={item}
<ListedAccountItem
account={item}
showSeparator={index !== array.length - 1}
disabled={isDeleting}
disconnect={disconnectListedBlog}
key={item.uuid}
key={item.authorId}
application={application}
/>
);
@@ -95,21 +97,19 @@ export const Listed = observer(({ application }: Props) => {
</a>
</Text>
</PreferencesSegment>
{items.length === 0 ? (
<PreferencesSegment>
<Subtitle>How to get started?</Subtitle>
<Text>
First, youll need to sign up for Listed. Once you have your
Listed account, follow the instructions to connect it with your
Standard Notes account.
</Text>
<LinkButton
className="min-w-20 mt-3"
link="https://listed.to"
label="Get started"
/>
</PreferencesSegment>
) : null}
<PreferencesSegment>
<Subtitle>Get Started</Subtitle>
<Text>Create a free Listed author account to get started.</Text>
<Button
className="mt-3"
type="normal"
disabled={requestingAccount}
label={
requestingAccount ? 'Creating account...' : 'Create New Author'
}
onClick={registerNewAccount}
/>
</PreferencesSegment>
</PreferencesGroup>
</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 { WebApplication } from '@/ui_models/application';
import { observer } from '@node_modules/mobx-react-lite';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { dateToLocalizedString } from '@standardnotes/snjs';
import { useState } from 'preact/hooks';
import { useCallback, useState } from 'preact/hooks';
import { ChangeEmail } from '@/preferences/panes/account/changeEmail';
import { PasswordWizardType } from '@/types';
import { FunctionComponent } from 'preact';
import { FunctionComponent, render } from 'preact';
import { AppState } from '@/ui_models/app_state';
import { PasswordWizard } from '@/components/PasswordWizard';
type Props = {
application: WebApplication;
appState: AppState;
};
export const Credentials: FunctionComponent<Props> = observer(({ application, appState }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] = useState(false);
export const Credentials: FunctionComponent<Props> = observer(
({ application }: Props) => {
const [isChangeEmailDialogOpen, setIsChangeEmailDialogOpen] =
useState(false);
const user = application.getUser();
const user = application.getUser();
const passwordCreatedAtTimestamp = application.getUserPasswordCreationDate() as Date;
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
const passwordCreatedAtTimestamp =
application.getUserPasswordCreationDate() as Date;
const passwordCreatedOn = dateToLocalizedString(passwordCreatedAtTimestamp);
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Credentials</Title>
<div className={'text-input mt-2'}>
Email
</div>
<Text>
You're signed in as <span className='font-bold'>{user?.email}</span>
</Text>
<Button
className='min-w-20 mt-3'
type='normal'
label='Change email'
onClick={() => {
setIsChangeEmailDialogOpen(true);
}}
/>
<HorizontalSeparator classes='mt-5 mb-3' />
<div className={'text-input mt-2'}>
Password
</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}
const presentPasswordWizard = useCallback(() => {
render(
<PasswordWizard application={application} />,
document.body.appendChild(document.createElement('div'))
);
}, [application]);
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Credentials</Title>
<Subtitle>Email</Subtitle>
<Text>
You're signed in as <span className="font-bold">{user?.email}</span>
</Text>
<Button
className="min-w-20 mt-3"
type="normal"
label="Change email"
onClick={() => {
setIsChangeEmailDialogOpen(true);
}}
/>
)}
</PreferencesSegment>
</PreferencesGroup>
);
});
<HorizontalSeparator classes="mt-5 mb-3" />
<Subtitle>Password</Subtitle>
<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>
<PreferencesSegment>
<Title>Sign out</Title>
<div className="min-h-2" />
<Subtitle>Other devices</Subtitle>
<Text>Want to sign out on all devices except this one?</Text>
<div className="min-h-3" />
@@ -74,7 +73,6 @@ const ClearSessionDataView: FunctionComponent<{
<PreferencesGroup>
<PreferencesSegment>
<Title>Clear session data</Title>
<div className="min-h-2" />
<Text>This will delete all local items and preferences.</Text>
<div className="min-h-3" />
<Button

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