refactor: migrate remaining angular components to react (#833)

* refactor: menuRow directive to MenuRow component

* refactor: migrate footer to react

* refactor: migrate actions menu to react

* refactor: migrate history menu to react

* fix: click outside handler use capture to trigger event before re-render occurs which would otherwise cause node.contains to return incorrect result (specifically for the account menu)

* refactor: migrate revision preview modal to react

* refactor: migrate permissions modal to react

* refactor: migrate password wizard to react

* refactor: remove unused input modal directive

* refactor: remove unused delay hide component

* refactor: remove unused filechange directive

* refactor: remove unused elemReady directive

* refactor: remove unused sn-enter directive

* refactor: remove unused lowercase directive

* refactor: remove unused autofocus directive

* refactor(wip): note view to react

* refactor: use mutation observer to deinit textarea listeners

* refactor: migrate challenge modal to react

* refactor: migrate note group view to react

* refactor(wip): migrate remaining classes

* fix: navigation parent ref

* refactor: fully remove angular assets

* fix: account switcher

* fix: application view state

* refactor: remove unused password wizard type

* fix: revision preview and permissions modal

* fix: remove angular comment

* refactor: react panel resizers for editor

* feat: simple panel resizer

* fix: use simple panel resizer everywhere

* fix: simplify panel resizer state

* chore: rename simple panel resizer to panel resizer

* refactor: simplify column layout

* fix: editor mount safety check

* fix: use inline onLoad callback for iframe, as setting onload after it loads will never call it

* chore: fix note view test

* chore(deps): upgrade snjs
This commit is contained in:
Mo
2022-01-30 19:01:30 -06:00
committed by GitHub
parent 0ecbde6bac
commit 50c92619ce
117 changed files with 4715 additions and 5309 deletions

View File

@@ -0,0 +1,140 @@
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 PureComponentState = Partial<Record<string, any>>;
export type PureComponentProps = Partial<Record<string, any>>;
export abstract class PureComponent<
P = PureComponentProps,
S = PureComponentState
> extends Component<P, S> {
private unsubApp!: () => void;
private unsubState!: () => void;
private reactionDisposers: IReactionDisposer[] = [];
constructor(props: P, protected application: WebApplication) {
super(props);
}
componentDidMount() {
this.addAppEventObserver();
this.addAppStateObserver();
}
deinit(): void {
this.unsubApp?.();
this.unsubState?.();
for (const disposer of this.reactionDisposers) {
disposer();
}
this.reactionDisposers.length = 0;
(this.unsubApp as unknown) = undefined;
(this.unsubState as unknown) = undefined;
}
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();
}
protected getElement(): Element | null {
return findDOMNode(this);
}
autorun(view: (r: IReactionPublic) => void): void {
this.reactionDisposers.push(autorun(view));
}
addAppStateObserver() {
this.unsubState = this.application!.getAppState().addObserver(
async (eventName, data) => {
this.onAppStateEvent(eventName, data);
}
);
}
onAppStateEvent(eventName: any, data: any) {
/** Optional override */
}
addAppEventObserver() {
if (this.application!.isStarted()) {
this.onAppStart();
}
if (this.application!.isLaunched()) {
this.onAppLaunch();
}
this.unsubApp = this.application!.addEventObserver(
async (eventName, data: any) => {
this.onAppEvent(eventName, data);
if (eventName === ApplicationEvent.Started) {
await this.onAppStart();
} else if (eventName === ApplicationEvent.Launched) {
await this.onAppLaunch();
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync();
} else if (eventName === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync();
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
this.onAppKeyChange();
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
this.onLocalDataLoaded();
}
}
);
}
onAppEvent(eventName: ApplicationEvent, data?: any) {
/** Optional override */
}
/** @override */
async onAppStart() {
/** Optional override */
}
onLocalDataLoaded() {
/** Optional override */
}
async onAppLaunch() {
/** Optional override */
}
async onAppKeyChange() {
/** Optional override */
}
onAppIncrementalSync() {
/** Optional override */
}
onAppFullSync() {
/** Optional override */
}
}

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,390 @@
import { WebApplication } from '@/ui_models/application';
import {
SNItem,
Action,
SNActionsExtension,
UuidString,
CopyPayload,
SNNote,
} 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 ActionsMenuScope = {
application: WebApplication;
item: SNItem;
};
type ActionSubRow = {
onClick: () => void;
label: string;
subtitle: string;
spinnerClass?: string;
};
type ExtensionState = {
loading: boolean;
error: boolean;
};
type MenuItem = {
uuid: UuidString;
name: string;
loading: boolean;
error: boolean;
hidden: boolean;
deprecation?: string;
actions: (Action & {
subrows?: ActionSubRow[];
})[];
};
type ActionState = {
error: boolean;
running: boolean;
};
type ActionsMenuState = {
extensions: SNActionsExtension[];
extensionsState: Record<UuidString, ExtensionState>;
hiddenExtensions: Record<UuidString, boolean>;
selectedActionId?: number;
menuItems: MenuItem[];
actionState: Record<number, ActionState>;
};
type Props = {
application: WebApplication;
item: SNNote;
};
export class ActionsMenu
extends PureComponent<Props, ActionsMenuState>
implements ActionsMenuScope
{
application!: WebApplication;
item!: SNItem;
constructor(props: Props) {
super(props, props.application);
const extensions = props.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,
};
});
this.state = {
extensions,
extensionsState,
hiddenExtensions: {},
menuItems: [],
actionState: {},
};
}
componentDidMount() {
this.loadExtensions();
this.autorun(() => {
this.rebuildMenuState({
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions,
});
});
}
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];
const item: MenuItem = {
uuid: extension.uuid,
name: extension.name,
loading: state?.loading ?? false,
error: state?.error ?? false,
hidden: hidden ?? false,
deprecation: extension.deprecation!,
actions: extension
.actionsWithContextForItem(this.props.item)
.map((action) => {
if (action.id === selectedActionId) {
return {
...action,
subrows: this.subRowsForAction(action, extension),
};
} else {
return action;
}
}),
};
return item;
}),
});
}
async loadExtensions() {
await Promise.all(
this.state.extensions.map(async (extension: SNActionsExtension) => {
this.setLoadingExtension(extension.uuid, true);
const updatedExtension =
await this.props.application.actionsManager.loadExtensionInContextOfItem(
extension,
this.props.item
);
if (updatedExtension) {
await this.updateExtension(updatedExtension!);
} else {
this.setErrorExtension(extension.uuid, true);
}
this.setLoadingExtension(extension.uuid, false);
})
);
}
executeAction = async (action: Action, extensionUuid: UuidString) => {
if (action.verb === 'nested') {
this.rebuildMenuState({
selectedActionId: action.id,
});
return;
}
const extension = this.props.application.findItem(
extensionUuid
) as SNActionsExtension;
this.updateActionState(action, { running: true, error: false });
const response = await this.props.application.actionsManager.runAction(
action,
this.props.item,
async () => {
/** @todo */
return '';
}
);
if (response.error) {
this.updateActionState(action, { error: true, running: false });
return;
}
this.updateActionState(action, { running: false, error: false });
this.handleActionResponse(action, response);
await this.reloadExtension(extension);
};
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'))
);
}
}
}
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: this.getActionState(subaction).running
? 'info'
: undefined,
};
});
}
private updateActionState(action: Action, actionState: ActionState): void {
const state = this.state.actionState;
state[action.id] = actionState;
this.setState({ actionState: state });
}
private getActionState(action: Action): ActionState {
return this.state.actionState[action.id] || {};
}
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.props.application.actionsManager.loadExtensionInContextOfItem(
extension,
this.props.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,
});
}
renderMenuItem(item: MenuItem) {
return (
<div>
<div
key={item.uuid}
className="sk-menu-panel-header"
onClick={($event) => {
this.toggleExtensionVisibility(item.uuid);
$event.stopPropagation();
}}
>
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">{item.name}</div>
{item.hidden && <div></div>}
{item.deprecation && !item.hidden && (
<div className="sk-menu-panel-header-subtitle">
{item.deprecation}
</div>
)}
</div>
{item.loading && <div className="sk-spinner small loading" />}
</div>
<div>
{item.error && !item.hidden && (
<MenuRow
faded={true}
label="Error loading actions"
subtitle="Please try again later."
/>
)}
{!item.actions.length && !item.hidden && (
<MenuRow faded={true} label="No Actions Available" />
)}
{!item.hidden &&
!item.loading &&
!item.error &&
item.actions.map((action, index) => {
return (
<MenuRow
key={index}
action={this.executeAction as never}
actionArgs={[action, item.uuid]}
label={action.label}
disabled={this.getActionState(action).running}
spinnerClass={
this.getActionState(action).running ? 'info' : undefined
}
subRows={action.subrows}
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.extensions.length == 0 && (
<a
href="https://standardnotes.com/plans"
rel="noopener"
target="blank"
className="no-decoration"
>
<MenuRow label="Download Actions" />
</a>
)}
{this.state.menuItems.map((extension) =>
this.renderMenuItem(extension)
)}
</div>
</div>
);
}
}

View File

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

View File

@@ -0,0 +1,258 @@
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';
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() {
return (
<div className={this.platformString + ' main-ui-view sn-component'}>
{!this.state.needsUnlock && this.state.launched && (
<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>
)}
{!this.state.needsUnlock && this.state.launched && (
<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>
);
})}
<NotesContextMenu
application={this.application}
appState={this.appState}
/>
<PurchaseFlowWrapper
application={this.application}
appState={this.appState}
/>
</div>
);
}
}

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',
})
) {
await this.application.signOut();
this.dismiss();
}
};
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">
{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';
@@ -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(
@@ -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

@@ -0,0 +1,571 @@
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 observerRemovers: Array<() => void> = [];
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();
}
}
);
this.syncResolutionClickHandler =
this.syncResolutionClickHandler.bind(this);
this.closeAccountMenu = this.closeAccountMenu.bind(this);
}
deinit() {
for (const remove of this.observerRemovers) remove();
this.observerRemovers.length = 0;
(this.closeAccountMenu as unknown) = undefined;
(this.syncResolutionClickHandler as unknown) = undefined;
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.<br>You can silence this warning from the ' +
'<em>Account</em> menu.',
});
};
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
id="account-switcher-icon"
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,311 @@
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}
actionArgs={[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}
actionArgs={[revision]}
label={this.previewRemoteHistoryTitle(revision)}
/>
);
})}
</div>
</div>
);
}
}

View File

@@ -18,6 +18,7 @@ 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 UserSwitch from '../../icons/ic-user-switch.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';
@@ -65,11 +66,10 @@ import LinkOffIcon from '../../icons/ic-link-off.svg';
import MenuArrowDownAlt from '../../icons/ic-menu-arrow-down-alt.svg';
import MenuArrowRight from '../../icons/ic-menu-arrow-right.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'editor': EditorIcon,
editor: EditorIcon,
'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-right': MenuArrowRight,
notes: NotesIcon,
@@ -93,6 +93,7 @@ const ICONS = {
'rich-text': RichTextIcon,
code: CodeIcon,
markdown: MarkdownIcon,
'user-switch': UserSwitch,
authenticator: AuthenticatorIcon,
spreadsheets: SpreadsheetsIcon,
tasks: TasksIcon,
@@ -158,8 +159,3 @@ export const Icon: FunctionalComponent<Props> = ({
/>
);
};
export const IconDirective = toDirective<Props>(Icon, {
type: '@',
className: '@',
});

View File

@@ -0,0 +1,120 @@
import { Component } from 'preact';
type RowProps = {
action?: (...args: any[]) => void;
actionArgs?: any[];
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?: RowProps[];
subtitle?: string;
};
type Props = RowProps;
export class MenuRow extends Component<Props> {
onClick = ($event: Event) => {
if (this.props.disabled || !this.props.action) {
return;
}
$event.stopPropagation();
if (this.props.actionArgs) {
this.props.action(...this.props.actionArgs);
} else {
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}
actionArgs={row.actionArgs}
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,63 @@
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,
useRef,
useState,
} from 'preact/hooks';
import { PremiumModalProvider } from './Premium';
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);
useEffect(() => {
const removeObserver = application.addEventObserver(async () => {
const width = application.getPreference(PrefKey.TagsPanelWidth);
if (width) {
setPanelWidth(width);
}
}, ApplicationEvent.PreferencesChanged);
return () => {
removeObserver();
};
}, [application]);
const onCreateNewTag = useCallback(() => {
appState.tags.createNewTemplate();
}, [appState]);
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(() => {
@@ -62,9 +68,9 @@ export const Navigation: FunctionComponent<Props> = observer(
<PremiumModalProvider state={appState.features}>
<div
id="navigation"
className="sn-component section"
className="sn-component section app-column app-column-first"
data-aria-label="Navigation"
ref={setPanelRef}
ref={setRef}
>
{componentViewer ? (
<div className="component-view-container">
@@ -102,16 +108,18 @@ export const Navigation: FunctionComponent<Props> = observer(
</div>
</div>
)}
{panelRef && (
{ref && (
<PanelResizer
application={application}
collapsable={true}
defaultWidth={150}
panel={panelRef}
prefKey={PrefKey.TagsPanelWidth}
panel={ref}
hoverable={true}
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>
)}
</div>
@@ -119,5 +127,3 @@ export const Navigation: FunctionComponent<Props> = observer(
);
}
);
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,66 @@
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';
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="note-group-view" 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

@@ -0,0 +1,195 @@
/**
* @jest-environment jsdom
*/
import { NoteView } from './NoteView';
import {
ApplicationEvent,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
} from '@standardnotes/snjs/';
describe('editor-view', () => {
let ctrl: NoteView;
let setShowProtectedWarningSpy: jest.SpyInstance;
beforeEach(() => {
ctrl = new NoteView({} as any);
setShowProtectedWarningSpy = jest.spyOn(ctrl, 'setShowProtectedOverlay');
Object.defineProperties(ctrl, {
application: {
value: {
getAppState: () => {
return {
notes: {
setShowProtectedWarning: jest.fn(),
},
};
},
hasProtectionSources: () => true,
authorizeNoteAccess: jest.fn(),
},
},
removeComponentsObserver: {
value: jest.fn(),
writable: true,
},
removeTrashKeyObserver: {
value: jest.fn(),
writable: true,
},
unregisterComponent: {
value: jest.fn(),
writable: true,
},
editor: {
value: {
clearNoteChangeListener: jest.fn(),
},
},
});
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
afterEach(() => {
ctrl.deinit();
});
describe('note is protected', () => {
beforeEach(() => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: true,
},
});
});
it("should hide the note if at the time of the session expiration the note wasn't edited for longer than the allowed idle time", async () => {
jest
.spyOn(ctrl, 'getSecondsElapsedSinceLastEdit')
.mockImplementation(
() =>
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction +
5
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the time passed after its last modification is less than the allowed idle time', async () => {
const secondsElapsedSinceLastEdit =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(Date.now() - secondsElapsedSinceLastEdit * 1000),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
const secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastEdit;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
it('should postpone the note hiding by correct time if the user continued editing it even after the protection session has expired', async () => {
const secondsElapsedSinceLastModification = 3;
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(
Date.now() - secondsElapsedSinceLastModification * 1000
),
configurable: true,
});
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
let secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction -
secondsElapsedSinceLastModification;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
// A new modification has just happened
Object.defineProperty(ctrl.note, 'userModifiedDate', {
value: new Date(),
configurable: true,
});
secondsAfterWhichTheNoteShouldHide =
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction;
jest.advanceTimersByTime((secondsAfterWhichTheNoteShouldHide - 1) * 1000);
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1 * 1000);
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(true);
});
});
describe('note is unprotected', () => {
it('should not call any hiding logic', async () => {
Object.defineProperty(ctrl, 'note', {
value: {
protected: false,
},
});
const hideProtectedNoteIfInactiveSpy = jest.spyOn(
ctrl,
'hideProtectedNoteIfInactive'
);
await ctrl.onAppEvent(ApplicationEvent.UnprotectedSessionExpired);
expect(hideProtectedNoteIfInactiveSpy).not.toHaveBeenCalled();
});
});
describe('dismissProtectedWarning', () => {
describe('the note has protection sources', () => {
it('should reveal note contents if the authorization has been passed', async () => {
jest
.spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(true));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
it('should not reveal note contents if the authorization has not been passed', async () => {
jest
.spyOn(ctrl['application'], 'authorizeNoteAccess')
.mockImplementation(async () => Promise.resolve(false));
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).not.toHaveBeenCalled();
});
});
describe('the note does not have protection sources', () => {
it('should reveal note contents', async () => {
jest
.spyOn(ctrl['application'], 'hasProtectionSources')
.mockImplementation(() => false);
await ctrl.dismissProtectedWarning();
expect(setShowProtectedWarningSpy).toHaveBeenCalledWith(false);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
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/NotesOptions';
import { useCallback, useEffect, useRef } from 'preact/hooks';
@@ -10,7 +10,7 @@ type Props = {
appState: AppState;
};
const NotesContextMenu = observer(({ application, appState }: Props) => {
export const NotesContextMenu = observer(({ application, appState }: Props) => {
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
appState.notes;
@@ -19,8 +19,8 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
appState.notes.setContextMenuOpen(open)
);
useCloseOnClickOutside(contextMenuRef, (open: boolean) =>
appState.notes.setContextMenuOpen(open)
useCloseOnClickOutside(contextMenuRef, () =>
appState.notes.setContextMenuOpen(false)
);
const reloadContextMenuLayout = useCallback(() => {
@@ -51,5 +51,3 @@ const NotesContextMenu = observer(({ application, appState }: Props) => {
</div>
) : null;
});
export const NotesContextMenuDirective = toDirective<Props>(NotesContextMenu);

View File

@@ -6,7 +6,7 @@ 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';
import { useCloseOnClickOutside } from './utils';
type Props = {
application: WebApplication;
@@ -118,10 +118,8 @@ flex flex-col py-2 bottom-0 left-2 absolute';
const menuRef = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(menuRef, (open: boolean) => {
if (!open) {
closeDisplayOptionsMenu();
}
useCloseOnClickOutside(menuRef, () => {
closeDisplayOptionsMenu();
});
return (
@@ -258,11 +256,3 @@ flex flex-col py-2 bottom-0 left-2 absolute';
);
}
);
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
closeDisplayOptionsMenu: '=',
state: '&',
}
);

View File

@@ -10,7 +10,7 @@ import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/views/note_view/note_view';
} from '@/components/NoteView/NoteView';
import {
Disclosure,
DisclosureButton,

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,
@@ -97,5 +97,3 @@ export const NotesOptionsPanel = observer(
);
}
);
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);

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';
@@ -13,16 +9,20 @@ import { useEffect, useRef } 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';
type Props = {
application: WebApplication;
appState: AppState;
};
const NotesView: FunctionComponent<Props> = observer(
export const NotesView: FunctionComponent<Props> = observer(
({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null);
@@ -46,6 +46,7 @@ const NotesView: FunctionComponent<Props> = observer(
onSearchInputBlur,
clearFilterText,
paginate,
panelWidth,
} = appState.notesView;
useEffect(() => {
@@ -124,11 +125,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);
};
@@ -140,7 +142,7 @@ const NotesView: FunctionComponent<Props> = observer(
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}
>
@@ -239,19 +241,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.side === PanelSide.Right) {
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,7 +3,6 @@ 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;
@@ -34,5 +33,3 @@ export const PinNoteButton: FunctionComponent<Props> = observer(
);
}
);
export const PinNoteButtonDirective = toDirective<Props>(PinNoteButton);

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

@@ -17,7 +17,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,
@@ -33,6 +33,7 @@ const MENU_CLASSNAME =
type MenuProps = {
appState: AppState;
application: WebApplication;
onClickOutside: () => void;
};
const toggleFocusMode = (enabled: boolean) => {
@@ -62,8 +63,8 @@ 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,
@@ -84,6 +85,11 @@ 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]);
@@ -223,7 +229,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
};
return (
<div className="sn-component">
<div ref={mainRef} className="sn-component">
<div
className={`sn-quick-settings-menu absolute ${MENU_CLASSNAME} ${
shouldAnimateCloseMenu
@@ -320,6 +326,3 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = observer(
);
}
);
export const QuickSettingsMenuDirective =
toDirective<MenuProps>(QuickSettingsMenu);

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,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

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

@@ -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,
},
};
};
}