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:
140
app/assets/javascripts/components/Abstract/PureComponent.tsx
Normal file
140
app/assets/javascripts/components/Abstract/PureComponent.tsx
Normal 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 */
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal file
169
app/assets/javascripts/components/AccountSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
390
app/assets/javascripts/components/ActionsMenu.tsx
Normal file
390
app/assets/javascripts/components/ActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal file
51
app/assets/javascripts/components/ApplicationGroupView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
258
app/assets/javascripts/components/ApplicationView.tsx
Normal file
258
app/assets/javascripts/components/ApplicationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal file
371
app/assets/javascripts/components/ChallengeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '=',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
571
app/assets/javascripts/components/Footer.tsx
Normal file
571
app/assets/javascripts/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
311
app/assets/javascripts/components/HistoryMenu.tsx
Normal file
311
app/assets/javascripts/components/HistoryMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '@',
|
||||
});
|
||||
|
||||
120
app/assets/javascripts/components/MenuRow.tsx
Normal file
120
app/assets/javascripts/components/MenuRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
66
app/assets/javascripts/components/NoteGroupView.tsx
Normal file
66
app/assets/javascripts/components/NoteGroupView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
195
app/assets/javascripts/components/NoteView/NoteView.test.ts
Normal file
195
app/assets/javascripts/components/NoteView/NoteView.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1297
app/assets/javascripts/components/NoteView/NoteView.tsx
Normal file
1297
app/assets/javascripts/components/NoteView/NoteView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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: '&',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/views/note_view/note_view';
|
||||
} from '@/components/NoteView/NoteView';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal file
344
app/assets/javascripts/components/PasswordWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal file
94
app/assets/javascripts/components/PermissionsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '=',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal file
181
app/assets/javascripts/components/RevisionPreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal file
187
app/assets/javascripts/components/SyncResolutionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user