diff --git a/.eslintrc b/.eslintrc index b572d609c..8219c6088 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,8 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier", - "plugin:react-hooks/recommended" + "plugin:react-hooks/recommended", + "./node_modules/@standardnotes/config/src/.eslintrc" ], "plugins": ["@typescript-eslint", "react", "react-hooks"], "parserOptions": { @@ -23,7 +24,9 @@ "react-hooks/exhaustive-deps": "error", "eol-last": "error", "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], - "no-trailing-spaces": "error" + "no-trailing-spaces": "error", + "@typescript-eslint/no-explicit-any": "warn", + "no-invalid-this": "warn" }, "env": { "browser": true diff --git a/.prettierrc b/.prettierrc index 544138be4..c9cb3989c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,6 @@ { - "singleQuote": true + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "semi": false } diff --git a/app/assets/javascripts/@types/Svg.d.ts b/app/assets/javascripts/@types/Svg.d.ts new file mode 100644 index 000000000..9b9471da0 --- /dev/null +++ b/app/assets/javascripts/@types/Svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: any + export default content +} diff --git a/app/assets/javascripts/@types/modules.ts b/app/assets/javascripts/@types/modules.ts deleted file mode 100644 index b5518926f..000000000 --- a/app/assets/javascripts/@types/modules.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module '*.svg' { - export default function SvgComponent(props: React.SVGProps): JSX.Element; -} diff --git a/app/assets/javascripts/@types/qrcode.react.d.ts b/app/assets/javascripts/@types/qrcode.react.d.ts index f997f01d9..c30c2c732 100644 --- a/app/assets/javascripts/@types/qrcode.react.d.ts +++ b/app/assets/javascripts/@types/qrcode.react.d.ts @@ -1 +1 @@ -declare module 'qrcode.react'; +declare module 'qrcode.react' diff --git a/app/assets/javascripts/App.tsx b/app/assets/javascripts/App.tsx new file mode 100644 index 000000000..1222efb87 --- /dev/null +++ b/app/assets/javascripts/App.tsx @@ -0,0 +1,98 @@ +'use strict' + +declare global { + interface Window { + dashboardUrl?: string + defaultSyncServer: string + devAccountEmail?: string + devAccountPassword?: string + devAccountServer?: string + enabledUnfinishedFeatures: boolean + plansUrl?: string + purchaseUrl?: string + startApplication?: StartApplication + websocketUrl: string + electronAppVersion?: string + webClient?: DesktopManagerInterface + + application?: WebApplication + mainApplicationGroup?: ApplicationGroup + } +} + +import { IsWebPlatform, WebAppVersion } from '@/Version' +import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs' +import { render } from 'preact' +import { ApplicationGroupView } from './Components/ApplicationGroupView' +import { WebDevice } from './Device/WebDevice' +import { StartApplication } from './Device/StartApplication' +import { ApplicationGroup } from './UIModels/ApplicationGroup' +import { WebOrDesktopDevice } from './Device/WebOrDesktopDevice' +import { WebApplication } from './UIModels/Application' +import { unmountComponentAtRoot } from './Utils/PreactUtils' + +let keyCount = 0 +const getKey = () => { + return keyCount++ +} + +const RootId = 'app-group-root' + +const startApplication: StartApplication = async function startApplication( + defaultSyncServerHost: string, + device: WebOrDesktopDevice, + enableUnfinishedFeatures: boolean, + webSocketUrl: string, +) { + SNLog.onLog = console.log + SNLog.onError = console.error + + const onDestroy = () => { + const root = document.getElementById(RootId) as HTMLElement + unmountComponentAtRoot(root) + root.remove() + renderApp() + } + + const renderApp = () => { + const root = document.createElement('div') + root.id = RootId + + const parentNode = document.body.appendChild(root) + + render( + , + parentNode, + ) + } + + const domReady = document.readyState === 'complete' || document.readyState === 'interactive' + + if (domReady) { + renderApp() + } else { + window.addEventListener('DOMContentLoaded', function callback() { + renderApp() + + window.removeEventListener('DOMContentLoaded', callback) + }) + } +} + +if (IsWebPlatform) { + startApplication( + window.defaultSyncServer, + new WebDevice(WebAppVersion), + window.enabledUnfinishedFeatures, + window.websocketUrl, + ).catch(console.error) +} else { + window.startApplication = startApplication +} diff --git a/app/assets/javascripts/Components/Abstract/PureComponent.tsx b/app/assets/javascripts/Components/Abstract/PureComponent.tsx new file mode 100644 index 000000000..341b3737b --- /dev/null +++ b/app/assets/javascripts/Components/Abstract/PureComponent.tsx @@ -0,0 +1,138 @@ +import { ApplicationEvent } from '@standardnotes/snjs' +import { WebApplication } from '@/UIModels/Application' +import { AppState, AppStateEvent } from '@/UIModels/AppState' +import { autorun, IReactionDisposer, IReactionPublic } from 'mobx' +import { Component } from 'preact' +import { findDOMNode, unmountComponentAtNode } from 'preact/compat' + +export type PureComponentState = Partial> +export type PureComponentProps = Partial> + +export abstract class PureComponent

extends Component { + private unsubApp!: () => void + private unsubState!: () => void + private reactionDisposers: IReactionDisposer[] = [] + + constructor(props: P, protected application: WebApplication) { + super(props) + } + + override 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 + ;(this.application as unknown) = undefined + ;(this.props as unknown) = undefined + ;(this.state as unknown) = undefined + } + + protected dismissModal(): void { + const elem = this.getElement() + if (!elem) { + return + } + + const parent = elem.parentElement + if (!parent) { + return + } + parent.remove() + unmountComponentAtNode(parent) + } + + override componentWillUnmount(): void { + this.deinit() + } + + 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: AppStateEvent, _data: unknown) { + /** Optional override */ + } + + addAppEventObserver() { + if (this.application.isStarted()) { + this.onAppStart().catch(console.error) + } + + if (this.application.isLaunched()) { + this.onAppLaunch().catch(console.error) + } + + this.unsubApp = this.application.addEventObserver(async (eventName, data: unknown) => { + if (!this.application) { + return + } + + 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().catch(console.error) + } else if (eventName === ApplicationEvent.LocalDataLoaded) { + this.onLocalDataLoaded() + } + }) + } + + onAppEvent(_eventName: ApplicationEvent, _data?: unknown) { + /** Optional override */ + } + + async onAppStart() { + /** Optional override */ + } + + onLocalDataLoaded() { + /** Optional override */ + } + + async onAppLaunch() { + /** Optional override */ + } + + async onAppKeyChange() { + /** Optional override */ + } + + onAppIncrementalSync() { + /** Optional override */ + } + + onAppFullSync() { + /** Optional override */ + } +} diff --git a/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx new file mode 100644 index 000000000..7aa70e5dc --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx @@ -0,0 +1,184 @@ +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { Checkbox } from '@/Components/Checkbox' +import { DecoratedInput } from '@/Components/Input/DecoratedInput' +import { Icon } from '@/Components/Icon' + +type Props = { + application: WebApplication + appState: AppState + disabled?: boolean + onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void + onStrictSignInChange?: (isStrictSignIn: boolean) => void +} + +export const AdvancedOptions: FunctionComponent = observer( + ({ appState, application, disabled = false, onPrivateWorkspaceChange, onStrictSignInChange, children }) => { + const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu + const [showAdvanced, setShowAdvanced] = useState(false) + + const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) + const [privateWorkspaceName, setPrivateWorkspaceName] = useState('') + const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('') + + const [isStrictSignin, setIsStrictSignin] = useState(false) + + useEffect(() => { + const recomputePrivateWorkspaceIdentifier = async () => { + const identifier = await application.computePrivateWorkspaceIdentifier( + privateWorkspaceName, + privateWorkspaceUserphrase, + ) + + if (!identifier) { + if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) { + application.alertService.alert('Unable to compute private workspace name.').catch(console.error) + } + return + } + onPrivateWorkspaceChange?.(true, identifier) + } + + if (privateWorkspaceName && privateWorkspaceUserphrase) { + recomputePrivateWorkspaceIdentifier().catch(console.error) + } + }, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange]) + + useEffect(() => { + onPrivateWorkspaceChange?.(isPrivateWorkspace) + }, [isPrivateWorkspace, onPrivateWorkspaceChange]) + + const handleIsPrivateWorkspaceChange = useCallback(() => { + setIsPrivateWorkspace(!isPrivateWorkspace) + }, [isPrivateWorkspace]) + + const handlePrivateWorkspaceNameChange = useCallback((name: string) => { + setPrivateWorkspaceName(name) + }, []) + + const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => { + setPrivateWorkspaceUserphrase(userphrase) + }, []) + + const handleServerOptionChange = useCallback( + (e: Event) => { + if (e.target instanceof HTMLInputElement) { + setEnableServerOption(e.target.checked) + } + }, + [setEnableServerOption], + ) + + const handleSyncServerChange = useCallback( + (server: string) => { + setServer(server) + application.setCustomHost(server).catch(console.error) + }, + [application, setServer], + ) + + const handleStrictSigninChange = useCallback(() => { + const newValue = !isStrictSignin + setIsStrictSignin(newValue) + onStrictSignInChange?.(newValue) + }, [isStrictSignin, onStrictSignInChange]) + + const toggleShowAdvanced = useCallback(() => { + setShowAdvanced(!showAdvanced) + }, [showAdvanced]) + + return ( + <> + + {showAdvanced ? ( +

+ {children} + +
+ + + + +
+ + {isPrivateWorkspace && ( + <> + ]} + type="text" + placeholder="Userphrase" + value={privateWorkspaceUserphrase} + onChange={handlePrivateWorkspaceUserphraseChange} + disabled={disabled} + /> + ]} + type="text" + placeholder="Name" + value={privateWorkspaceName} + onChange={handlePrivateWorkspaceNameChange} + disabled={disabled} + /> + + )} + + {onStrictSignInChange && ( +
+ + + + +
+ )} + + + ]} + placeholder="https://api.standardnotes.com" + value={server} + onChange={handleSyncServerChange} + disabled={!enableServerOption && !disabled} + /> +
+ ) : null} + + ) + }, +) diff --git a/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx new file mode 100644 index 000000000..67774a613 --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx @@ -0,0 +1,158 @@ +import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings' +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { AccountMenuPane } from '.' +import { Button } from '@/Components/Button/Button' +import { Checkbox } from '@/Components/Checkbox' +import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' +import { Icon } from '@/Components/Icon' +import { IconButton } from '@/Components/Button/IconButton' + +type Props = { + appState: AppState + application: WebApplication + setMenuPane: (pane: AccountMenuPane) => void + email: string + password: string +} + +export const ConfirmPassword: FunctionComponent = observer( + ({ application, appState, setMenuPane, email, password }) => { + const { notesAndTagsCount } = appState.accountMenu + const [confirmPassword, setConfirmPassword] = useState('') + const [isRegistering, setIsRegistering] = useState(false) + const [isEphemeral, setIsEphemeral] = useState(false) + const [shouldMergeLocal, setShouldMergeLocal] = useState(true) + const [error, setError] = useState('') + + const passwordInputRef = useRef(null) + + useEffect(() => { + passwordInputRef.current?.focus() + }, []) + + const handlePasswordChange = useCallback((text: string) => { + setConfirmPassword(text) + }, []) + + const handleEphemeralChange = useCallback(() => { + setIsEphemeral(!isEphemeral) + }, [isEphemeral]) + + const handleShouldMergeChange = useCallback(() => { + setShouldMergeLocal(!shouldMergeLocal) + }, [shouldMergeLocal]) + + const handleConfirmFormSubmit = useCallback( + (e: Event) => { + e.preventDefault() + + if (!password) { + passwordInputRef.current?.focus() + return + } + + if (password === confirmPassword) { + setIsRegistering(true) + application + .register(email, password, isEphemeral, shouldMergeLocal) + .then((res) => { + if (res.error) { + throw new Error(res.error.message) + } + appState.accountMenu.closeAccountMenu() + appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu) + }) + .catch((err) => { + console.error(err) + setError(err.message) + }) + .finally(() => { + setIsRegistering(false) + }) + } else { + setError(STRING_NON_MATCHING_PASSWORDS) + setConfirmPassword('') + passwordInputRef.current?.focus() + } + }, + [appState, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal], + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (error.length) { + setError('') + } + if (e.key === 'Enter') { + handleConfirmFormSubmit(e) + } + }, + [handleConfirmFormSubmit, error], + ) + + const handleGoBack = useCallback(() => { + setMenuPane(AccountMenuPane.Register) + }, [setMenuPane]) + + return ( + <> +
+ +
Confirm password
+
+
+ Because your notes are encrypted using your password,{' '} + Standard Notes does not have a password reset option. If you forget + your password, you will permanently lose access to your data. +
+
+ ]} + onChange={handlePasswordChange} + onKeyDown={handleKeyDown} + placeholder="Confirm password" + ref={passwordInputRef} + value={confirmPassword} + /> + {error ?
{error}
: null} + @@ -78,5 +87,5 @@ export const WorkspaceMenuItem: FunctionComponent = ({ )} - ); -}; + ) +} diff --git a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx new file mode 100644 index 000000000..9b49e1ebd --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx @@ -0,0 +1,89 @@ +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { AppState } from '@/UIModels/AppState' +import { ApplicationDescriptor, ApplicationGroupEvent, ButtonType } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { Icon } from '@/Components/Icon' +import { Menu } from '@/Components/Menu/Menu' +import { MenuItem, MenuItemSeparator, MenuItemType } from '@/Components/Menu/MenuItem' +import { WorkspaceMenuItem } from './WorkspaceMenuItem' + +type Props = { + mainApplicationGroup: ApplicationGroup + appState: AppState + isOpen: boolean + hideWorkspaceOptions?: boolean +} + +export const WorkspaceSwitcherMenu: FunctionComponent = observer( + ({ mainApplicationGroup, appState, isOpen, hideWorkspaceOptions = false }: Props) => { + const [applicationDescriptors, setApplicationDescriptors] = useState([]) + + useEffect(() => { + const applicationDescriptors = mainApplicationGroup.getDescriptors() + setApplicationDescriptors(applicationDescriptors) + + const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => { + if (event === ApplicationGroupEvent.DescriptorsDataChanged) { + const applicationDescriptors = mainApplicationGroup.getDescriptors() + setApplicationDescriptors(applicationDescriptors) + } + }) + + return () => { + removeAppGroupObserver() + } + }, [mainApplicationGroup]) + + const signoutAll = useCallback(async () => { + const confirmed = await appState.application.alertService.confirm( + 'Are you sure you want to sign out of all workspaces on this device?', + undefined, + 'Sign out all', + ButtonType.Danger, + ) + if (!confirmed) { + return + } + mainApplicationGroup.signOutAllWorkspaces().catch(console.error) + }, [mainApplicationGroup, appState]) + + const destroyWorkspace = useCallback(() => { + appState.accountMenu.setSigningOut(true) + }, [appState]) + + return ( + + {applicationDescriptors.map((descriptor) => ( + void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)} + renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)} + /> + ))} + + + { + void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor() + }} + > + + Add another workspace + + + {!hideWorkspaceOptions && ( + + + Sign out all workspaces + + )} + + ) + }, +) diff --git a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx new file mode 100644 index 000000000..a8881180c --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx @@ -0,0 +1,67 @@ +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { AppState } from '@/UIModels/AppState' +import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { Icon } from '@/Components/Icon' +import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu' + +type Props = { + mainApplicationGroup: ApplicationGroup + appState: AppState +} + +export const WorkspaceSwitcherOption: FunctionComponent = observer(({ mainApplicationGroup, appState }) => { + const buttonRef = useRef(null) + const menuRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [menuStyle, setMenuStyle] = useState() + + const toggleMenu = useCallback(() => { + if (!isOpen) { + const menuPosition = calculateSubmenuStyle(buttonRef.current) + if (menuPosition) { + setMenuStyle(menuPosition) + } + } + + setIsOpen(!isOpen) + }, [isOpen, setIsOpen]) + + useEffect(() => { + if (isOpen) { + setTimeout(() => { + const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current) + + if (newMenuPosition) { + setMenuStyle(newMenuPosition) + } + }) + } + }, [isOpen]) + + return ( + <> + + {isOpen && ( +
+ +
+ )} + + ) +}) diff --git a/app/assets/javascripts/Components/AccountMenu/index.tsx b/app/assets/javascripts/Components/AccountMenu/index.tsx new file mode 100644 index 000000000..982231644 --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/index.tsx @@ -0,0 +1,138 @@ +import { observer } from 'mobx-react-lite' +import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' +import { AppState } from '@/UIModels/AppState' +import { WebApplication } from '@/UIModels/Application' +import { useCallback, useRef, useState } from 'preact/hooks' +import { GeneralAccountMenu } from './GeneralAccountMenu' +import { FunctionComponent } from 'preact' +import { SignInPane } from './SignIn' +import { CreateAccount } from './CreateAccount' +import { ConfirmPassword } from './ConfirmPassword' +import { JSXInternal } from 'preact/src/jsx' +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' + +export enum AccountMenuPane { + GeneralMenu, + SignIn, + Register, + ConfirmPassword, +} + +type Props = { + appState: AppState + application: WebApplication + onClickOutside: () => void + mainApplicationGroup: ApplicationGroup +} + +type PaneSelectorProps = { + appState: AppState + application: WebApplication + mainApplicationGroup: ApplicationGroup + menuPane: AccountMenuPane + setMenuPane: (pane: AccountMenuPane) => void + closeMenu: () => void +} + +const MenuPaneSelector: FunctionComponent = observer( + ({ application, appState, menuPane, setMenuPane, closeMenu, mainApplicationGroup }) => { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + switch (menuPane) { + case AccountMenuPane.GeneralMenu: + return ( + + ) + case AccountMenuPane.SignIn: + return + case AccountMenuPane.Register: + return ( + + ) + case AccountMenuPane.ConfirmPassword: + return ( + + ) + } + }, +) + +export const AccountMenu: FunctionComponent = observer( + ({ application, appState, onClickOutside, mainApplicationGroup }) => { + const { currentPane, shouldAnimateCloseMenu } = appState.accountMenu + + const closeAccountMenu = useCallback(() => { + appState.accountMenu.closeAccountMenu() + }, [appState]) + + const setCurrentPane = useCallback( + (pane: AccountMenuPane) => { + appState.accountMenu.setCurrentPane(pane) + }, + [appState], + ) + + const ref = useRef(null) + useCloseOnClickOutside(ref, () => { + onClickOutside() + }) + + const handleKeyDown: JSXInternal.KeyboardEventHandler = useCallback( + (event) => { + switch (event.key) { + case 'Escape': + if (currentPane === AccountMenuPane.GeneralMenu) { + closeAccountMenu() + } else if (currentPane === AccountMenuPane.ConfirmPassword) { + setCurrentPane(AccountMenuPane.Register) + } else { + setCurrentPane(AccountMenuPane.GeneralMenu) + } + break + } + }, + [closeAccountMenu, currentPane, setCurrentPane], + ) + + return ( +
+
+ +
+
+ ) + }, +) diff --git a/app/assets/javascripts/Components/ApplicationGroupView/index.tsx b/app/assets/javascripts/Components/ApplicationGroupView/index.tsx new file mode 100644 index 000000000..f486a924c --- /dev/null +++ b/app/assets/javascripts/Components/ApplicationGroupView/index.tsx @@ -0,0 +1,132 @@ +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { WebApplication } from '@/UIModels/Application' +import { Component } from 'preact' +import { ApplicationView } from '@/Components/ApplicationView' +import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' +import { ApplicationGroupEvent, Runtime, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs' +import { unmountComponentAtNode, findDOMNode } from 'preact/compat' +import { DialogContent, DialogOverlay } from '@reach/dialog' +import { isDesktopApplication } from '@/Utils' + +type Props = { + server: string + device: WebOrDesktopDevice + enableUnfinished: boolean + websocketUrl: string + onDestroy: () => void +} + +type State = { + activeApplication?: WebApplication + dealloced?: boolean + deallocSource?: DeinitSource + deviceDestroyed?: boolean +} + +export class ApplicationGroupView extends Component { + applicationObserverRemover?: () => void + private group?: ApplicationGroup + private application?: WebApplication + + constructor(props: Props) { + super(props) + + if (props.device.isDeviceDestroyed()) { + this.state = { + deviceDestroyed: true, + } + + return + } + + this.group = new ApplicationGroup( + props.server, + props.device, + props.enableUnfinished ? Runtime.Dev : Runtime.Prod, + props.websocketUrl, + ) + + window.mainApplicationGroup = this.group + + this.applicationObserverRemover = this.group.addEventObserver((event, data) => { + if (event === ApplicationGroupEvent.PrimaryApplicationSet) { + const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.PrimaryApplicationSet] + + this.application = castData.application as WebApplication + this.setState({ activeApplication: this.application }) + } else if (event === ApplicationGroupEvent.DeviceWillRestart) { + const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.DeviceWillRestart] + + this.setState({ dealloced: true, deallocSource: castData.source }) + } + }) + + this.state = {} + + this.group.initialize().catch(console.error) + } + + deinit() { + this.application = undefined + + this.applicationObserverRemover?.() + ;(this.applicationObserverRemover as unknown) = undefined + + this.group?.deinit() + ;(this.group as unknown) = undefined + + this.setState({ dealloced: true, activeApplication: undefined }) + + const onDestroy = this.props.onDestroy + + const node = findDOMNode(this) as Element + unmountComponentAtNode(node) + + onDestroy() + } + + render() { + const renderDialog = (message: string) => { + return ( + + + {message} + + + ) + } + + if (this.state.deviceDestroyed) { + const message = `Secure memory has destroyed this application instance. ${ + isDesktopApplication() + ? 'Restart the app to continue.' + : 'Close this browser tab and open a new one to continue.' + }` + + return renderDialog(message) + } + + if (this.state.dealloced) { + const message = this.state.deallocSource === DeinitSource.Lock ? 'Locking workspace...' : 'Switching workspace...' + return renderDialog(message) + } + + if (!this.group || !this.state.activeApplication || this.state.activeApplication.dealloced) { + return null + } + + return ( +
+ +
+ ) + } +} diff --git a/app/assets/javascripts/Components/ApplicationView/index.tsx b/app/assets/javascripts/Components/ApplicationView/index.tsx new file mode 100644 index 000000000..80f6d1def --- /dev/null +++ b/app/assets/javascripts/Components/ApplicationView/index.tsx @@ -0,0 +1,242 @@ +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { getPlatformString, getWindowUrlParams } from '@/Utils' +import { AppStateEvent, PanelResizedData } from '@/UIModels/AppState' +import { ApplicationEvent, Challenge, PermissionDialog, removeFromArray } from '@standardnotes/snjs' +import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants' +import { alertDialog } from '@/Services/AlertService' +import { WebApplication } from '@/UIModels/Application' +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 '@/Components/Preferences/PreferencesViewWrapper' +import { ChallengeModal } from '@/Components/ChallengeModal/ChallengeModal' +import { NotesContextMenu } from '@/Components/NotesContextMenu' +import { PurchaseFlowWrapper } from '@/Components/PurchaseFlow/PurchaseFlowWrapper' +import { render, FunctionComponent } from 'preact' +import { PermissionsModal } from '@/Components/PermissionsModal' +import { RevisionHistoryModalWrapper } from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper' +import { PremiumModalProvider } from '@/Hooks/usePremiumModal' +import { ConfirmSignoutContainer } from '@/Components/ConfirmSignoutModal' +import { TagsContextMenu } from '@/Components/Tags/TagContextMenu' +import { ToastContainer } from '@standardnotes/stylekit' +import { FilePreviewModal } from '../Files/FilePreviewModal' +import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' +import { isStateDealloced } from '@/UIModels/AppState/AbstractState' + +type Props = { + application: WebApplication + mainApplicationGroup: ApplicationGroup +} + +export const ApplicationView: FunctionComponent = ({ application, mainApplicationGroup }) => { + const platformString = getPlatformString() + const [appClass, setAppClass] = useState('') + const [launched, setLaunched] = useState(false) + const [needsUnlock, setNeedsUnlock] = useState(true) + const [challenges, setChallenges] = useState([]) + const [dealloced, setDealloced] = useState(false) + + const componentManager = application.componentManager + const appState = application.getAppState() + + useEffect(() => { + setDealloced(application.dealloced) + }, [application.dealloced]) + + useEffect(() => { + if (dealloced) { + return + } + + const desktopService = application.getDesktopService() + + if (desktopService) { + application.componentManager.setDesktopManager(desktopService) + } + + application + .prepareForLaunch({ + receiveChallenge: async (challenge) => { + const challengesCopy = challenges.slice() + challengesCopy.push(challenge) + setChallenges(challengesCopy) + }, + }) + .then(() => { + void application.launch() + }) + .catch(console.error) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [application, dealloced]) + + const removeChallenge = useCallback( + (challenge: Challenge) => { + const challengesCopy = challenges.slice() + removeFromArray(challengesCopy, challenge) + setChallenges(challengesCopy) + }, + [challenges], + ) + + const presentPermissionsDialog = useCallback( + (dialog: PermissionDialog) => { + render( + , + document.body.appendChild(document.createElement('div')), + ) + }, + [application], + ) + + const onAppStart = useCallback(() => { + setNeedsUnlock(application.hasPasscode()) + componentManager.presentPermissionsDialog = presentPermissionsDialog + + return () => { + ;(componentManager.presentPermissionsDialog as unknown) = undefined + } + }, [application, componentManager, presentPermissionsDialog]) + + const handleDemoSignInFromParams = useCallback(() => { + const token = getWindowUrlParams().get('demo-token') + if (!token || application.hasAccount()) { + return + } + + void application.sessions.populateSessionFromDemoShareToken(token) + }, [application]) + + const onAppLaunch = useCallback(() => { + setLaunched(true) + setNeedsUnlock(false) + handleDemoSignInFromParams() + }, [handleDemoSignInFromParams]) + + useEffect(() => { + if (application.isStarted()) { + onAppStart() + } + + if (application.isLaunched()) { + onAppLaunch() + } + + const removeAppObserver = application.addEventObserver(async (eventName) => { + if (eventName === ApplicationEvent.Started) { + onAppStart() + } else if (eventName === ApplicationEvent.Launched) { + onAppLaunch() + } else if (eventName === ApplicationEvent.LocalDatabaseReadError) { + alertDialog({ + text: 'Unable to load local database. Please restart the app and try again.', + }).catch(console.error) + } else if (eventName === ApplicationEvent.LocalDatabaseWriteError) { + alertDialog({ + text: 'Unable to write to local database. Please restart the app and try again.', + }).catch(console.error) + } + }) + + return () => { + removeAppObserver() + } + }, [application, onAppLaunch, onAppStart]) + + useEffect(() => { + const removeObserver = application.getAppState().addObserver(async (eventName, data) => { + 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' + } + setAppClass(appClass) + } else if (eventName === AppStateEvent.WindowDidFocus) { + if (!(await application.isLocked())) { + application.sync.sync().catch(console.error) + } + } + }) + + return () => { + removeObserver() + } + }, [application]) + + const renderAppContents = useMemo(() => { + return !needsUnlock && launched + }, [needsUnlock, launched]) + + const renderChallenges = useCallback(() => { + return ( + <> + {challenges.map((challenge) => { + return ( +
+ +
+ ) + })} + + ) + }, [appState, challenges, mainApplicationGroup, removeChallenge, application]) + + if (dealloced || isStateDealloced(appState)) { + return null + } + + if (!renderAppContents) { + return renderChallenges() + } + + return ( + +
+
+ + + +
+ + <> +
+ + + + + + {renderChallenges()} + + <> + + + + + + + +
+
+ ) +} diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx new file mode 100644 index 000000000..988dd2b6a --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx @@ -0,0 +1,423 @@ +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' +import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' +import VisuallyHidden from '@reach/visually-hidden' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { Icon } from '@/Components/Icon' +import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import { ChallengeReason, CollectionSort, ContentType, FileItem, SNNote } from '@standardnotes/snjs' +import { confirmDialog } from '@/Services/AlertService' +import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit' +import { StreamingFileReader } from '@standardnotes/filepicker' +import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' +import { AttachedFilesPopover } from './AttachedFilesPopover' +import { usePremiumModal } from '@/Hooks/usePremiumModal' +import { PopoverTabs } from './PopoverTabs' +import { isHandlingFileDrag } from '@/Utils/DragTypeCheck' +import { isStateDealloced } from '@/UIModels/AppState/AbstractState' + +type Props = { + application: WebApplication + appState: AppState + onClickPreprocessing?: () => Promise +} + +export const AttachedFilesButton: FunctionComponent = observer( + ({ application, appState, onClickPreprocessing }: Props) => { + if (isStateDealloced(appState)) { + return null + } + + const premiumModal = usePremiumModal() + const note: SNNote | undefined = Object.values(appState.notes.selectedNotes)[0] + + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ + top: 0, + right: 0, + }) + const [maxHeight, setMaxHeight] = useState('auto') + const buttonRef = useRef(null) + const panelRef = useRef(null) + const containerRef = useRef(null) + const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen) + + useEffect(() => { + if (appState.filePreviewModal.isOpen) { + keepMenuOpen(true) + } else { + keepMenuOpen(false) + } + }, [appState.filePreviewModal.isOpen, keepMenuOpen]) + + const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles) + const [allFiles, setAllFiles] = useState([]) + const [attachedFiles, setAttachedFiles] = useState([]) + const attachedFilesCount = attachedFiles.length + + useEffect(() => { + application.items.setDisplayOptions(ContentType.File, CollectionSort.Title, 'dsc') + + const unregisterFileStream = application.streamItems(ContentType.File, () => { + setAllFiles(application.items.getDisplayableItems(ContentType.File)) + if (note) { + setAttachedFiles(application.items.getFilesForNote(note)) + } + }) + + return () => { + unregisterFileStream() + } + }, [application, note]) + + const toggleAttachedFilesMenu = useCallback(async () => { + const rect = buttonRef.current?.getBoundingClientRect() + if (rect) { + const { clientHeight } = document.documentElement + const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() + const footerHeightInPx = footerElementRect?.height + + if (footerHeightInPx) { + setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) + } + + setPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }) + + const newOpenState = !open + if (newOpenState && onClickPreprocessing) { + await onClickPreprocessing() + } + + setOpen(newOpenState) + } + }, [onClickPreprocessing, open]) + + const prospectivelyShowFilesPremiumModal = useCallback(() => { + if (!appState.features.hasFiles) { + premiumModal.activate('Files') + } + }, [appState.features.hasFiles, premiumModal]) + + const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => { + prospectivelyShowFilesPremiumModal() + + await toggleAttachedFilesMenu() + }, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal]) + + const deleteFile = async (file: FileItem) => { + const shouldDelete = await confirmDialog({ + text: `Are you sure you want to permanently delete "${file.name}"?`, + confirmButtonStyle: 'danger', + }) + if (shouldDelete) { + const deletingToastId = addToast({ + type: ToastType.Loading, + message: `Deleting file "${file.name}"...`, + }) + await application.files.deleteFile(file) + addToast({ + type: ToastType.Success, + message: `Deleted file "${file.name}"`, + }) + dismissToast(deletingToastId) + } + } + + const downloadFile = async (file: FileItem) => { + appState.files.downloadFile(file).catch(console.error) + } + + const attachFileToNote = useCallback( + async (file: FileItem) => { + if (!note) { + addToast({ + type: ToastType.Error, + message: 'Could not attach file because selected note was deleted', + }) + return + } + + await application.items.associateFileWithNote(file, note) + }, + [application.items, note], + ) + + const detachFileFromNote = async (file: FileItem) => { + if (!note) { + addToast({ + type: ToastType.Error, + message: 'Could not attach file because selected note was deleted', + }) + return + } + await application.items.disassociateFileWithNote(file, note) + } + + const toggleFileProtection = async (file: FileItem) => { + let result: FileItem | undefined + if (file.protected) { + keepMenuOpen(true) + result = await application.mutator.unprotectFile(file) + keepMenuOpen(false) + buttonRef.current?.focus() + } else { + result = await application.mutator.protectFile(file) + } + const isProtected = result ? result.protected : file.protected + return isProtected + } + + const authorizeProtectedActionForFile = async (file: FileItem, challengeReason: ChallengeReason) => { + const authorizedFiles = await application.protections.authorizeProtectedActionForFiles([file], challengeReason) + const isAuthorized = authorizedFiles.length > 0 && authorizedFiles.includes(file) + return isAuthorized + } + + const renameFile = async (file: FileItem, fileName: string) => { + await application.items.renameFile(file, fileName) + } + + const handleFileAction = async (action: PopoverFileItemAction) => { + const file = action.type !== PopoverFileItemActionType.RenameFile ? action.payload : action.payload.file + let isAuthorizedForAction = true + + if (file.protected && action.type !== PopoverFileItemActionType.ToggleFileProtection) { + keepMenuOpen(true) + isAuthorizedForAction = await authorizeProtectedActionForFile(file, ChallengeReason.AccessProtectedFile) + keepMenuOpen(false) + buttonRef.current?.focus() + } + + if (!isAuthorizedForAction) { + return false + } + + switch (action.type) { + case PopoverFileItemActionType.AttachFileToNote: + await attachFileToNote(file) + break + case PopoverFileItemActionType.DetachFileToNote: + await detachFileFromNote(file) + break + case PopoverFileItemActionType.DeleteFile: + await deleteFile(file) + break + case PopoverFileItemActionType.DownloadFile: + await downloadFile(file) + break + case PopoverFileItemActionType.ToggleFileProtection: { + const isProtected = await toggleFileProtection(file) + action.callback(isProtected) + break + } + case PopoverFileItemActionType.RenameFile: + await renameFile(file, action.payload.name) + break + case PopoverFileItemActionType.PreviewFile: { + keepMenuOpen(true) + const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles + appState.filePreviewModal.activate( + file, + otherFiles.filter((file) => !file.protected), + ) + break + } + } + + if ( + action.type !== PopoverFileItemActionType.DownloadFile && + action.type !== PopoverFileItemActionType.PreviewFile + ) { + application.sync.sync().catch(console.error) + } + + return true + } + + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + const dragCounter = useRef(0) + + const handleDrag = useCallback( + (event: DragEvent) => { + if (isHandlingFileDrag(event, application)) { + event.preventDefault() + event.stopPropagation() + } + }, + [application], + ) + + const handleDragIn = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } + + event.preventDefault() + event.stopPropagation() + + switch ((event.target as HTMLElement).id) { + case PopoverTabs.AllFiles: + setCurrentTab(PopoverTabs.AllFiles) + break + case PopoverTabs.AttachedFiles: + setCurrentTab(PopoverTabs.AttachedFiles) + break + } + + dragCounter.current = dragCounter.current + 1 + + if (event.dataTransfer?.items.length) { + setIsDraggingFiles(true) + if (!open) { + toggleAttachedFilesMenu().catch(console.error) + } + } + }, + [open, toggleAttachedFilesMenu, application], + ) + + const handleDragOut = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } + + event.preventDefault() + event.stopPropagation() + + dragCounter.current = dragCounter.current - 1 + + if (dragCounter.current > 0) { + return + } + + setIsDraggingFiles(false) + }, + [application], + ) + + const handleDrop = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } + + event.preventDefault() + event.stopPropagation() + + setIsDraggingFiles(false) + + if (!appState.features.hasFiles) { + prospectivelyShowFilesPremiumModal() + return + } + + if (event.dataTransfer?.items.length) { + Array.from(event.dataTransfer.items).forEach(async (item) => { + const fileOrHandle = StreamingFileReader.available() + ? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle) + : item.getAsFile() + + if (!fileOrHandle) { + return + } + + const uploadedFiles = await appState.files.uploadNewFile(fileOrHandle) + + if (!uploadedFiles) { + return + } + + if (currentTab === PopoverTabs.AttachedFiles) { + uploadedFiles.forEach((file) => { + attachFileToNote(file).catch(console.error) + }) + } + }) + + event.dataTransfer.clearData() + dragCounter.current = 0 + } + }, + [ + appState.files, + appState.features.hasFiles, + attachFileToNote, + currentTab, + application, + prospectivelyShowFilesPremiumModal, + ], + ) + + useEffect(() => { + window.addEventListener('dragenter', handleDragIn) + window.addEventListener('dragleave', handleDragOut) + window.addEventListener('dragover', handleDrag) + window.addEventListener('drop', handleDrop) + + return () => { + window.removeEventListener('dragenter', handleDragIn) + window.removeEventListener('dragleave', handleDragOut) + window.removeEventListener('dragover', handleDrag) + window.removeEventListener('drop', handleDrop) + } + }, [handleDragIn, handleDrop, handleDrag, handleDragOut]) + + return ( +
+ + { + if (event.key === 'Escape') { + setOpen(false) + } + }} + ref={buttonRef} + className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`} + onBlur={closeOnBlur} + > + Attached files + + {attachedFilesCount > 0 && {attachedFilesCount}} + + { + if (event.key === 'Escape') { + setOpen(false) + buttonRef.current?.focus() + } + }} + ref={panelRef} + style={{ + ...position, + maxHeight, + }} + className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed" + onBlur={closeOnBlur} + > + {open && ( + + )} + + +
+ ) + }, +) diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx new file mode 100644 index 000000000..6c978617e --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx @@ -0,0 +1,173 @@ +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { FileItem } from '@standardnotes/snjs' +import { FilesIllustration } from '@standardnotes/icons' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { StateUpdater, useRef, useState } from 'preact/hooks' +import { Button } from '@/Components/Button/Button' +import { Icon } from '@/Components/Icon' +import { PopoverFileItem } from './PopoverFileItem' +import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' +import { PopoverTabs } from './PopoverTabs' + +type Props = { + application: WebApplication + appState: AppState + allFiles: FileItem[] + attachedFiles: FileItem[] + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void + currentTab: PopoverTabs + handleFileAction: (action: PopoverFileItemAction) => Promise + isDraggingFiles: boolean + setCurrentTab: StateUpdater +} + +export const AttachedFilesPopover: FunctionComponent = observer( + ({ + application, + appState, + allFiles, + attachedFiles, + closeOnBlur, + currentTab, + handleFileAction, + isDraggingFiles, + setCurrentTab, + }) => { + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) + + const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles + + const filteredList = + searchQuery.length > 0 + ? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1) + : filesList + + const handleAttachFilesClick = async () => { + const uploadedFiles = await appState.files.uploadNewFile() + if (!uploadedFiles) { + return + } + if (currentTab === PopoverTabs.AttachedFiles) { + uploadedFiles.forEach((file) => { + handleFileAction({ + type: PopoverFileItemActionType.AttachFileToNote, + payload: file, + }).catch(console.error) + }) + } + } + + return ( +
+
+ + +
+
+ {filteredList.length > 0 || searchQuery.length > 0 ? ( +
+
+ { + setSearchQuery((e.target as HTMLInputElement).value) + }} + onBlur={closeOnBlur} + ref={searchInputRef} + /> + {searchQuery.length > 0 && ( + + )} +
+
+ ) : null} + {filteredList.length > 0 ? ( + filteredList.map((file: FileItem) => { + return ( + + ) + }) + ) : ( +
+
+ +
+
+ {searchQuery.length > 0 + ? 'No result found' + : currentTab === PopoverTabs.AttachedFiles + ? 'No files attached to this note' + : 'No files found in this account'} +
+ +
Or drop your files here
+
+ )} +
+ {filteredList.length > 0 && ( + + )} +
+ ) + }, +) diff --git a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileItem.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx similarity index 53% rename from app/assets/javascripts/components/AttachedFilesPopover/PopoverFileItem.tsx rename to app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx index 1f6981f47..1516b4f3a 100644 --- a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileItem.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx @@ -1,29 +1,26 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants'; -import { KeyboardKey } from '@/services/ioService'; -import { formatSizeToReadableString } from '@standardnotes/filepicker'; -import { IconType, SNFile } from '@standardnotes/snjs'; -import { FunctionComponent } from 'preact'; -import { useEffect, useRef, useState } from 'preact/hooks'; -import { Icon, ICONS } from '../Icon'; -import { - PopoverFileItemAction, - PopoverFileItemActionType, -} from './PopoverFileItemAction'; -import { PopoverFileSubmenu } from './PopoverFileSubmenu'; +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { KeyboardKey } from '@/Services/IOService' +import { formatSizeToReadableString } from '@standardnotes/filepicker' +import { IconType, FileItem } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { useEffect, useRef, useState } from 'preact/hooks' +import { Icon, ICONS } from '@/Components/Icon' +import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' +import { PopoverFileSubmenu } from './PopoverFileSubmenu' export const getFileIconComponent = (iconType: string, className: string) => { - const IconComponent = ICONS[iconType as keyof typeof ICONS]; + const IconComponent = ICONS[iconType as keyof typeof ICONS] - return ; -}; + return +} export type PopoverFileItemProps = { - file: SNFile; - isAttachedToNote: boolean; - handleFileAction: (action: PopoverFileItemAction) => Promise; - getIconType(type: string): IconType; - closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; -}; + file: FileItem + isAttachedToNote: boolean + handleFileAction: (action: PopoverFileItemAction) => Promise + getIconType(type: string): IconType + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void +} export const PopoverFileItem: FunctionComponent = ({ file, @@ -32,41 +29,48 @@ export const PopoverFileItem: FunctionComponent = ({ getIconType, closeOnBlur, }) => { - const [fileName, setFileName] = useState(file.name); - const [isRenamingFile, setIsRenamingFile] = useState(false); - const itemRef = useRef(null); - const fileNameInputRef = useRef(null); + const [fileName, setFileName] = useState(file.name) + const [isRenamingFile, setIsRenamingFile] = useState(false) + const itemRef = useRef(null) + const fileNameInputRef = useRef(null) useEffect(() => { if (isRenamingFile) { - fileNameInputRef.current?.focus(); + fileNameInputRef.current?.focus() } - }, [isRenamingFile]); + }, [isRenamingFile]) - const renameFile = async (file: SNFile, name: string) => { + const renameFile = async (file: FileItem, name: string) => { await handleFileAction({ type: PopoverFileItemActionType.RenameFile, payload: { file, name, }, - }); - setIsRenamingFile(false); - }; + }) + setIsRenamingFile(false) + } const handleFileNameInput = (event: Event) => { - setFileName((event.target as HTMLInputElement).value); - }; + setFileName((event.target as HTMLInputElement).value) + } const handleFileNameInputKeyDown = (event: KeyboardEvent) => { if (event.key === KeyboardKey.Enter) { - itemRef.current?.focus(); + itemRef.current?.focus() } - }; + } const handleFileNameInputBlur = () => { - renameFile(file, fileName); - }; + renameFile(file, fileName).catch(console.error) + } + + const clickPreviewHandler = () => { + handleFileAction({ + type: PopoverFileItemActionType.PreviewFile, + payload: file, + }).catch(console.error) + } return (
= ({ className="flex items-center justify-between p-3 focus:shadow-none" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} > -
- {getFileIconComponent( - getIconType(file.mimeType), - 'w-8 h-8 flex-shrink-0' - )} +
+ {getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
{isRenamingFile ? ( = ({
{file.name} {file.protected && ( - + )}
)}
- {file.created_at.toLocaleString()} ·{' '} - {formatSizeToReadableString(file.size)} + {file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
@@ -113,7 +110,8 @@ export const PopoverFileItem: FunctionComponent = ({ handleFileAction={handleFileAction} setIsRenamingFile={setIsRenamingFile} closeOnBlur={closeOnBlur} + previewHandler={clickPreviewHandler} />
- ); -}; + ) +} diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx new file mode 100644 index 000000000..b1e9368e6 --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx @@ -0,0 +1,32 @@ +import { FileItem } from '@standardnotes/snjs' + +export enum PopoverFileItemActionType { + AttachFileToNote, + DetachFileToNote, + DeleteFile, + DownloadFile, + RenameFile, + ToggleFileProtection, + PreviewFile, +} + +export type PopoverFileItemAction = + | { + type: Exclude< + PopoverFileItemActionType, + PopoverFileItemActionType.RenameFile | PopoverFileItemActionType.ToggleFileProtection + > + payload: FileItem + } + | { + type: PopoverFileItemActionType.ToggleFileProtection + payload: FileItem + callback: (isProtected: boolean) => void + } + | { + type: PopoverFileItemActionType.RenameFile + payload: { + file: FileItem + name: string + } + } diff --git a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileSubmenu.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx similarity index 74% rename from app/assets/javascripts/components/AttachedFilesPopover/PopoverFileSubmenu.tsx rename to app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx index d9183779d..649ab903c 100644 --- a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileSubmenu.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx @@ -1,86 +1,69 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants'; -import { - calculateSubmenuStyle, - SubmenuStyle, -} from '@/utils/calculateSubmenuStyle'; -import { - Disclosure, - DisclosureButton, - DisclosurePanel, -} from '@reach/disclosure'; -import { FunctionComponent } from 'preact'; -import { - StateUpdater, - useCallback, - useEffect, - useRef, - useState, -} from 'preact/hooks'; -import { Icon } from '../Icon'; -import { Switch } from '../Switch'; -import { useCloseOnBlur } from '../utils'; -import { useFilePreviewModal } from '../Files/FilePreviewModalProvider'; -import { PopoverFileItemProps } from './PopoverFileItem'; -import { PopoverFileItemActionType } from './PopoverFileItemAction'; +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' +import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' +import { FunctionComponent } from 'preact' +import { StateUpdater, useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { Icon } from '@/Components/Icon' +import { Switch } from '@/Components/Switch' +import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import { PopoverFileItemProps } from './PopoverFileItem' +import { PopoverFileItemActionType } from './PopoverFileItemAction' type Props = Omit & { - setIsRenamingFile: StateUpdater; -}; + setIsRenamingFile: StateUpdater + previewHandler: () => void +} export const PopoverFileSubmenu: FunctionComponent = ({ file, isAttachedToNote, handleFileAction, setIsRenamingFile, + previewHandler, }) => { - const filePreviewModal = useFilePreviewModal(); + const menuContainerRef = useRef(null) + const menuButtonRef = useRef(null) + const menuRef = useRef(null) - const menuContainerRef = useRef(null); - const menuButtonRef = useRef(null); - const menuRef = useRef(null); - - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isFileProtected, setIsFileProtected] = useState(file.protected); + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isFileProtected, setIsFileProtected] = useState(file.protected) const [menuStyle, setMenuStyle] = useState({ right: 0, bottom: 0, maxHeight: 'auto', - }); - const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen); + }) + const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen) - const closeMenu = () => { - setIsMenuOpen(false); - }; + const closeMenu = useCallback(() => { + setIsMenuOpen(false) + }, []) - const toggleMenu = () => { + const toggleMenu = useCallback(() => { if (!isMenuOpen) { - const menuPosition = calculateSubmenuStyle(menuButtonRef.current); + const menuPosition = calculateSubmenuStyle(menuButtonRef.current) if (menuPosition) { - setMenuStyle(menuPosition); + setMenuStyle(menuPosition) } } - setIsMenuOpen(!isMenuOpen); - }; + setIsMenuOpen(!isMenuOpen) + }, [isMenuOpen]) const recalculateMenuStyle = useCallback(() => { - const newMenuPosition = calculateSubmenuStyle( - menuButtonRef.current, - menuRef.current - ); + const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current) if (newMenuPosition) { - setMenuStyle(newMenuPosition); + setMenuStyle(newMenuPosition) } - }, []); + }, []) useEffect(() => { if (isMenuOpen) { setTimeout(() => { - recalculateMenuStyle(); - }); + recalculateMenuStyle() + }) } - }, [isMenuOpen, recalculateMenuStyle]); + }, [isMenuOpen, recalculateMenuStyle]) return (
@@ -106,8 +89,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={() => { - filePreviewModal.activate(file); - closeMenu(); + previewHandler() + closeMenu() }} > @@ -121,8 +104,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DetachFileToNote, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -136,8 +119,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.AttachFileToNote, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -152,9 +135,9 @@ export const PopoverFileSubmenu: FunctionComponent = ({ type: PopoverFileItemActionType.ToggleFileProtection, payload: file, callback: (isProtected: boolean) => { - setIsFileProtected(isProtected); + setIsFileProtected(isProtected) }, - }); + }).catch(console.error) }} onBlur={closeOnBlur} > @@ -176,8 +159,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DownloadFile, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -187,7 +170,7 @@ export const PopoverFileSubmenu: FunctionComponent = ({ onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={() => { - setIsRenamingFile(true); + setIsRenamingFile(true) }} > @@ -200,8 +183,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DeleteFile, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -212,5 +195,5 @@ export const PopoverFileSubmenu: FunctionComponent = ({
- ); -}; + ) +} diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverTabs.ts b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverTabs.ts new file mode 100644 index 000000000..98088aed0 --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverTabs.ts @@ -0,0 +1,4 @@ +export enum PopoverTabs { + AttachedFiles = 'attached-files-tab', + AllFiles = 'all-files-tab', +} diff --git a/app/assets/javascripts/components/Bubble.tsx b/app/assets/javascripts/Components/Bubble/index.tsx similarity index 70% rename from app/assets/javascripts/components/Bubble.tsx rename to app/assets/javascripts/Components/Bubble/index.tsx index e164a4f50..5a2146823 100644 --- a/app/assets/javascripts/components/Bubble.tsx +++ b/app/assets/javascripts/Components/Bubble/index.tsx @@ -1,25 +1,23 @@ interface BubbleProperties { - label: string; - selected: boolean; - onSelect: () => void; + label: string + selected: boolean + onSelect: () => void } const styles = { base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast', unselected: 'color-neutral border-secondary', selected: 'border-info bg-info color-neutral-contrast', -}; +} const Bubble = ({ label, selected, onSelect }: BubbleProperties) => ( {label} -); +) -export default Bubble; +export default Bubble diff --git a/app/assets/javascripts/Components/Button/Button.tsx b/app/assets/javascripts/Components/Button/Button.tsx new file mode 100644 index 000000000..eca0e336e --- /dev/null +++ b/app/assets/javascripts/Components/Button/Button.tsx @@ -0,0 +1,68 @@ +import { JSXInternal } from 'preact/src/jsx' +import { ComponentChildren, FunctionComponent, Ref } from 'preact' +import { forwardRef } from 'preact/compat' + +const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content' + +type ButtonVariant = 'normal' | 'primary' + +const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean) => { + const borders = variant === 'normal' ? 'border-solid border-main border-1' : 'no-border' + const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer' + + let colors = variant === 'normal' ? 'bg-default color-text' : 'bg-info color-info-contrast' + + let focusHoverStates = + variant === 'normal' + ? 'focus:bg-contrast focus:outline-none hover:bg-contrast' + : 'hover:brightness-130 focus:outline-none focus:brightness-130' + + if (danger) { + colors = variant === 'normal' ? 'bg-default color-danger' : 'bg-danger color-info-contrast' + } + + if (disabled) { + colors = variant === 'normal' ? 'bg-default color-grey-2' : 'bg-grey-2 color-info-contrast' + focusHoverStates = + variant === 'normal' + ? 'focus:bg-default focus:outline-none hover:bg-default' + : 'focus:brightness-default focus:outline-none hover:brightness-default' + } + + return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}` +} + +type ButtonProps = JSXInternal.HTMLAttributes & { + children?: ComponentChildren + className?: string + variant?: ButtonVariant + dangerStyle?: boolean + label?: string +} + +export const Button: FunctionComponent = forwardRef( + ( + { + variant = 'normal', + label, + className = '', + dangerStyle: danger = false, + disabled = false, + children, + ...props + }: ButtonProps, + ref: Ref, + ) => { + return ( + + ) + }, +) diff --git a/app/assets/javascripts/components/IconButton.tsx b/app/assets/javascripts/Components/Button/IconButton.tsx similarity index 58% rename from app/assets/javascripts/components/IconButton.tsx rename to app/assets/javascripts/Components/Button/IconButton.tsx index c45233a99..62180d5bb 100644 --- a/app/assets/javascripts/components/IconButton.tsx +++ b/app/assets/javascripts/Components/Button/IconButton.tsx @@ -1,27 +1,27 @@ -import { FunctionComponent } from 'preact'; -import { Icon } from './Icon'; -import { IconType } from '@standardnotes/snjs'; +import { FunctionComponent } from 'preact' +import { Icon } from '@/Components/Icon' +import { IconType } from '@standardnotes/snjs' interface Props { /** * onClick - preventDefault is handled within the component */ - onClick: () => void; + onClick: () => void - className?: string; + className?: string - icon: IconType; + icon: IconType - iconClassName?: string; + iconClassName?: string /** * Button tooltip */ - title: string; + title: string - focusable: boolean; + focusable: boolean - disabled?: boolean; + disabled?: boolean } /** @@ -38,18 +38,20 @@ export const IconButton: FunctionComponent = ({ disabled = false, }) => { const click = (e: MouseEvent) => { - e.preventDefault(); - onClick(); - }; - const focusableClass = focusable ? '' : 'focus:shadow-none'; + e.preventDefault() + onClick() + } + const focusableClass = focusable ? '' : 'focus:shadow-none' return ( - ); -}; + ) +} diff --git a/app/assets/javascripts/Components/Button/RoundIconButton.tsx b/app/assets/javascripts/Components/Button/RoundIconButton.tsx new file mode 100644 index 000000000..55882f655 --- /dev/null +++ b/app/assets/javascripts/Components/Button/RoundIconButton.tsx @@ -0,0 +1,35 @@ +import { FunctionComponent } from 'preact' +import { Icon } from '@/Components/Icon' +import { IconType } from '@standardnotes/snjs' + +type ButtonType = 'normal' | 'primary' + +interface Props { + /** + * onClick - preventDefault is handled within the component + */ + onClick: () => void + + type: ButtonType + + className?: string + + icon: IconType +} + +/** + * IconButton component with an icon + * preventDefault is already handled within the component + */ +export const RoundIconButton: FunctionComponent = ({ onClick, type, className, icon: iconType }) => { + const click = (e: MouseEvent) => { + e.preventDefault() + onClick() + } + const classes = type === 'primary' ? 'info ' : '' + return ( + + ) +} diff --git a/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx b/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx new file mode 100644 index 000000000..93c4586f4 --- /dev/null +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -0,0 +1,270 @@ +import { WebApplication } from '@/UIModels/Application' +import { DialogContent, DialogOverlay } from '@reach/dialog' +import { + ButtonType, + Challenge, + ChallengePrompt, + ChallengeReason, + ChallengeValue, + removeFromArray, +} from '@standardnotes/snjs' +import { ProtectedIllustration } from '@standardnotes/icons' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { Button } from '@/Components/Button/Button' +import { Icon } from '@/Components/Icon' +import { ChallengeModalPrompt } from './ChallengePrompt' +import { LockscreenWorkspaceSwitcher } from './LockscreenWorkspaceSwitcher' +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { AppState } from '@/UIModels/AppState' + +type InputValue = { + prompt: ChallengePrompt + value: string | number | boolean + invalid: boolean +} + +export type ChallengeModalValues = Record + +type Props = { + application: WebApplication + appState: AppState + mainApplicationGroup: ApplicationGroup + challenge: Challenge + onDismiss?: (challenge: Challenge) => void +} + +const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]): ChallengeModalValues | undefined => { + let hasInvalidValues = false + const validatedValues = { ...values } + for (const prompt of prompts) { + const value = validatedValues[prompt.id] + if (typeof value.value === 'string' && value.value.length === 0) { + validatedValues[prompt.id].invalid = true + hasInvalidValues = true + } + } + if (!hasInvalidValues) { + return validatedValues + } + return undefined +} + +export const ChallengeModal: FunctionComponent = ({ + application, + appState, + mainApplicationGroup, + challenge, + onDismiss, +}) => { + const [values, setValues] = useState(() => { + const values = {} as ChallengeModalValues + for (const prompt of challenge.prompts) { + values[prompt.id] = { + prompt, + value: prompt.initialValue ?? '', + invalid: false, + } + } + return values + }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + const [, setProcessingPrompts] = useState([]) + const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false) + const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes( + challenge.reason, + ) + const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock + + const submit = useCallback(() => { + const validatedValues = validateValues(values, challenge.prompts) + if (!validatedValues) { + return + } + if (isSubmitting || isProcessing) { + return + } + setIsSubmitting(true) + setIsProcessing(true) + + const valuesToProcess: ChallengeValue[] = [] + for (const inputValue of Object.values(validatedValues)) { + const rawValue = inputValue.value + const value = { prompt: inputValue.prompt, value: rawValue } + valuesToProcess.push(value) + } + + const processingPrompts = valuesToProcess.map((v) => v.prompt) + setIsProcessing(processingPrompts.length > 0) + setProcessingPrompts(processingPrompts) + /** + * 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 (valuesToProcess.length > 0) { + application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error) + } else { + setIsProcessing(false) + } + setIsSubmitting(false) + }, 50) + }, [application, challenge, isProcessing, isSubmitting, values]) + + const onValueChange = useCallback( + (value: string | number, prompt: ChallengePrompt) => { + const newValues = { ...values } + newValues[prompt.id].invalid = false + newValues[prompt.id].value = value + setValues(newValues) + }, + [values], + ) + + const cancelChallenge = useCallback(() => { + if (challenge.cancelable) { + application.cancelChallenge(challenge) + onDismiss?.(challenge) + } + }, [application, challenge, onDismiss]) + + useEffect(() => { + const removeChallengeObserver = application.addChallengeObserver(challenge, { + onValidValue: (value) => { + setValues((values) => { + const newValues = { ...values } + newValues[value.prompt.id].invalid = false + return newValues + }) + setProcessingPrompts((currentlyProcessingPrompts) => { + const processingPrompts = currentlyProcessingPrompts.slice() + removeFromArray(processingPrompts, value.prompt) + setIsProcessing(processingPrompts.length > 0) + return processingPrompts + }) + }, + onInvalidValue: (value) => { + setValues((values) => { + const newValues = { ...values } + newValues[value.prompt.id].invalid = true + return newValues + }) + /** If custom validation, treat all values together and not individually */ + if (!value.prompt.validates) { + setProcessingPrompts([]) + setIsProcessing(false) + } else { + setProcessingPrompts((currentlyProcessingPrompts) => { + const processingPrompts = currentlyProcessingPrompts.slice() + removeFromArray(processingPrompts, value.prompt) + setIsProcessing(processingPrompts.length > 0) + return processingPrompts + }) + } + }, + onComplete: () => { + onDismiss?.(challenge) + }, + onCancel: () => { + onDismiss?.(challenge) + }, + }) + + return () => { + removeChallengeObserver() + } + }, [application, challenge, onDismiss]) + + if (!challenge.prompts) { + return null + } + + return ( + + + {challenge.cancelable && ( + + )} + +
{challenge.heading}
+ + {challenge.subheading && ( +
{challenge.subheading}
+ )} + +
{ + e.preventDefault() + submit() + }} + > + {challenge.prompts.map((prompt, index) => ( + + ))} + + + {shouldShowForgotPasscode && ( + + )} + {shouldShowWorkspaceSwitcher && ( + + )} +
+
+ ) +} diff --git a/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx b/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx new file mode 100644 index 000000000..4f2159d1a --- /dev/null +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx @@ -0,0 +1,82 @@ +import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { useEffect, useRef } from 'preact/hooks' +import { DecoratedInput } from '@/Components/Input/DecoratedInput' +import { DecoratedPasswordInput } from '@/Components/Input/DecoratedPasswordInput' +import { ChallengeModalValues } from './ChallengeModal' + +type Props = { + prompt: ChallengePrompt + values: ChallengeModalValues + index: number + onValueChange: (value: string | number, prompt: ChallengePrompt) => void + isInvalid: boolean +} + +export const ChallengeModalPrompt: FunctionComponent = ({ prompt, values, index, onValueChange, isInvalid }) => { + const inputRef = useRef(null) + + useEffect(() => { + if (index === 0) { + inputRef.current?.focus() + } + }, [index]) + + useEffect(() => { + if (isInvalid) { + inputRef.current?.focus() + } + }, [isInvalid]) + + return ( +
+ {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? ( +
+
Allow protected access for
+
+ {ProtectionSessionDurations.map((option) => { + const selected = option.valueInSeconds === values[prompt.id].value + return ( + + ) + })} +
+
+ ) : prompt.secureTextEntry ? ( + onValueChange(value, prompt)} + /> + ) : ( + onValueChange(value, prompt)} + /> + )} + {isInvalid &&
Invalid authentication, please try again.
} +
+ ) +} diff --git a/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx b/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx new file mode 100644 index 000000000..ece6735ab --- /dev/null +++ b/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx @@ -0,0 +1,67 @@ +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { AppState } from '@/UIModels/AppState' +import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { WorkspaceSwitcherMenu } from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu' +import { Button } from '@/Components/Button/Button' +import { Icon } from '@/Components/Icon' +import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' + +type Props = { + mainApplicationGroup: ApplicationGroup + appState: AppState +} + +export const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainApplicationGroup, appState }) => { + const buttonRef = useRef(null) + const menuRef = useRef(null) + const containerRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [menuStyle, setMenuStyle] = useState() + + useCloseOnClickOutside(containerRef, () => setIsOpen(false)) + + const toggleMenu = useCallback(() => { + if (!isOpen) { + const menuPosition = calculateSubmenuStyle(buttonRef.current) + if (menuPosition) { + setMenuStyle(menuPosition) + } + } + + setIsOpen(!isOpen) + }, [isOpen]) + + useEffect(() => { + if (isOpen) { + const timeToWaitBeforeCheckingMenuCollision = 0 + setTimeout(() => { + const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current) + + if (newMenuPosition) { + setMenuStyle(newMenuPosition) + } + }, timeToWaitBeforeCheckingMenuCollision) + } + }, [isOpen]) + + return ( +
+ + {isOpen && ( +
+ +
+ )} +
+ ) +} diff --git a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx new file mode 100644 index 000000000..bc77f8d65 --- /dev/null +++ b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -0,0 +1,114 @@ +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' +import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' +import VisuallyHidden from '@reach/visually-hidden' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { useRef, useState } from 'preact/hooks' +import { Icon } from '@/Components/Icon' +import { ChangeEditorMenu } from './ChangeEditorMenu' +import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import { isStateDealloced } from '@/UIModels/AppState/AbstractState' + +type Props = { + application: WebApplication + appState: AppState + onClickPreprocessing?: () => Promise +} + +export const ChangeEditorButton: FunctionComponent = observer( + ({ application, appState, onClickPreprocessing }: Props) => { + if (isStateDealloced(appState)) { + return null + } + + const note = Object.values(appState.notes.selectedNotes)[0] + const [isOpen, setIsOpen] = useState(false) + const [isVisible, setIsVisible] = useState(false) + const [position, setPosition] = useState({ + top: 0, + right: 0, + }) + const [maxHeight, setMaxHeight] = useState('auto') + const buttonRef = useRef(null) + const panelRef = useRef(null) + const containerRef = useRef(null) + const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen) + + const toggleChangeEditorMenu = async () => { + const rect = buttonRef.current?.getBoundingClientRect() + if (rect) { + const { clientHeight } = document.documentElement + const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() + const footerHeightInPx = footerElementRect?.height + + if (footerHeightInPx) { + setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) + } + + setPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }) + + const newOpenState = !isOpen + if (newOpenState && onClickPreprocessing) { + await onClickPreprocessing() + } + + setIsOpen(newOpenState) + setTimeout(() => { + setIsVisible(newOpenState) + }) + } + } + + return ( +
+ + { + if (event.key === 'Escape') { + setIsOpen(false) + } + }} + onBlur={closeOnBlur} + ref={buttonRef} + className="sn-icon-button border-contrast" + > + Change note type + + + { + if (event.key === 'Escape') { + setIsOpen(false) + buttonRef.current?.focus() + } + }} + ref={panelRef} + style={{ + ...position, + maxHeight, + }} + className="sn-dropdown sn-dropdown--animated min-w-68 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed" + onBlur={closeOnBlur} + > + {isOpen && ( + { + setIsOpen(false) + }} + /> + )} + + +
+ ) + }, +) diff --git a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx new file mode 100644 index 000000000..889a920cc --- /dev/null +++ b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -0,0 +1,212 @@ +import { Icon } from '@/Components/Icon' +import { Menu } from '@/Components/Menu/Menu' +import { MenuItem, MenuItemType } from '@/Components/Menu/MenuItem' +import { + reloadFont, + transactionForAssociateComponentWithCurrentNote, + transactionForDisassociateComponentWithCurrentNote, +} from '@/Components/NoteView/NoteView' +import { usePremiumModal } from '@/Hooks/usePremiumModal' +import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings' +import { WebApplication } from '@/UIModels/Application' +import { + ComponentArea, + ItemMutator, + NoteMutator, + PrefKey, + SNComponent, + SNNote, + TransactionalMutation, +} from '@standardnotes/snjs' +import { Fragment, FunctionComponent } from 'preact' +import { useCallback, useEffect, useState } from 'preact/hooks' +import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' +import { createEditorMenuGroups } from './createEditorMenuGroups' +import { PLAIN_EDITOR_NAME } from '@/Constants' + +type ChangeEditorMenuProps = { + application: WebApplication + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void + closeMenu: () => void + isVisible: boolean + note: SNNote +} + +const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-') + +export const ChangeEditorMenu: FunctionComponent = ({ + application, + closeOnBlur, + closeMenu, + isVisible, + note, +}) => { + const [editors] = useState(() => + application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 + }), + ) + const [groups, setGroups] = useState([]) + const [currentEditor, setCurrentEditor] = useState() + + useEffect(() => { + setGroups(createEditorMenuGroups(application, editors)) + }, [application, editors]) + + useEffect(() => { + if (note) { + setCurrentEditor(application.componentManager.editorForNote(note)) + } + }, [application, note]) + + const premiumModal = usePremiumModal() + + const isSelectedEditor = useCallback( + (item: EditorMenuItem) => { + if (currentEditor) { + if (item?.component?.identifier === currentEditor.identifier) { + return true + } + } else if (item.name === PLAIN_EDITOR_NAME) { + return true + } + return false + }, + [currentEditor], + ) + + const selectComponent = async (component: SNComponent | null, note: SNNote) => { + if (component) { + if (component.conflictOf) { + application.mutator + .changeAndSaveItem(component, (mutator) => { + mutator.conflictOf = undefined + }) + .catch(console.error) + } + } + + const transactions: TransactionalMutation[] = [] + + await application.getAppState().notesView.insertCurrentIfTemplate() + + if (note.locked) { + application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error) + return + } + + if (!component) { + if (!note.prefersPlainEditor) { + transactions.push({ + itemUuid: note.uuid, + mutate: (m: ItemMutator) => { + const noteMutator = m as NoteMutator + noteMutator.prefersPlainEditor = true + }, + }) + } + const currentEditor = application.componentManager.editorForNote(note) + if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) { + transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note)) + } + reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) + } else if (component.area === ComponentArea.Editor) { + const currentEditor = application.componentManager.editorForNote(note) + if (currentEditor && component.uuid !== currentEditor.uuid) { + transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note)) + } + const prefersPlain = note.prefersPlainEditor + if (prefersPlain) { + transactions.push({ + itemUuid: note.uuid, + mutate: (m: ItemMutator) => { + const noteMutator = m as NoteMutator + noteMutator.prefersPlainEditor = false + }, + }) + } + transactions.push(transactionForAssociateComponentWithCurrentNote(component, note)) + } + + await application.mutator.runTransactionalMutations(transactions) + /** Dirtying can happen above */ + application.sync.sync().catch(console.error) + + setCurrentEditor(application.componentManager.editorForNote(note)) + } + + const selectEditor = async (itemToBeSelected: EditorMenuItem) => { + if (!itemToBeSelected.isEntitled) { + premiumModal.activate(itemToBeSelected.name) + return + } + + const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component + + if (areBothEditorsPlain) { + return + } + + let shouldSelectEditor = true + + if (itemToBeSelected.component) { + const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert( + currentEditor, + itemToBeSelected.component, + ) + + if (changeRequiresAlert) { + shouldSelectEditor = await application.componentManager.showEditorChangeAlert() + } + } + + if (shouldSelectEditor) { + selectComponent(itemToBeSelected.component ?? null, note).catch(console.error) + } + + closeMenu() + } + + return ( + + {groups + .filter((group) => group.items && group.items.length) + .map((group, index) => { + const groupId = getGroupId(group) + + return ( + +
+ {group.icon && } +
{group.title}
+
+ {group.items.map((item) => { + const onClickEditorItem = () => { + selectEditor(item).catch(console.error) + } + + return ( + +
+ {item.name} + {!item.isEntitled && } +
+
+ ) + })} +
+ ) + })} +
+ ) +} diff --git a/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts b/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts similarity index 64% rename from app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts rename to app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts index 1b59c6358..fc7ddda90 100644 --- a/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts +++ b/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts @@ -1,41 +1,37 @@ -import { WebApplication } from '@/ui_models/application'; +import { WebApplication } from '@/UIModels/Application' import { + ContentType, + FeatureStatus, + SNComponent, ComponentArea, FeatureDescription, GetFeatures, NoteType, -} from '@standardnotes/features'; -import { ContentType, FeatureStatus, SNComponent } from '@standardnotes/snjs'; -import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption'; +} from '@standardnotes/snjs' +import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' +import { PLAIN_EDITOR_NAME } from '@/Constants' -export const PLAIN_EDITOR_NAME = 'Plain Editor'; +type EditorGroup = NoteType | 'plain' | 'others' -type EditorGroup = NoteType | 'plain' | 'others'; - -const getEditorGroup = ( - featureDescription: FeatureDescription -): EditorGroup => { +const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => { if (featureDescription.note_type) { - return featureDescription.note_type; + return featureDescription.note_type } else if (featureDescription.file_type) { switch (featureDescription.file_type) { case 'txt': - return 'plain'; + return 'plain' case 'html': - return NoteType.RichText; + return NoteType.RichText case 'md': - return NoteType.Markdown; + return NoteType.Markdown default: - return 'others'; + return 'others' } } - return 'others'; -}; + return 'others' +} -export const createEditorMenuGroups = ( - application: WebApplication, - editors: SNComponent[] -) => { +export const createEditorMenuGroups = (application: WebApplication, editors: SNComponent[]) => { const editorItems: Record = { plain: [ { @@ -50,40 +46,30 @@ export const createEditorMenuGroups = ( spreadsheet: [], authentication: [], others: [], - }; + } GetFeatures() - .filter( - (feature) => - feature.content_type === ContentType.Component && - feature.area === ComponentArea.Editor - ) + .filter((feature) => feature.content_type === ContentType.Component && feature.area === ComponentArea.Editor) .forEach((editorFeature) => { - const notInstalled = !editors.find( - (editor) => editor.identifier === editorFeature.identifier - ); - const isExperimental = application.features.isExperimentalFeature( - editorFeature.identifier - ); + const notInstalled = !editors.find((editor) => editor.identifier === editorFeature.identifier) + const isExperimental = application.features.isExperimentalFeature(editorFeature.identifier) if (notInstalled && !isExperimental) { editorItems[getEditorGroup(editorFeature)].push({ name: editorFeature.name as string, isEntitled: false, - }); + }) } - }); + }) editors.forEach((editor) => { const editorItem: EditorMenuItem = { name: editor.name, component: editor, - isEntitled: - application.features.getFeatureStatus(editor.identifier) === - FeatureStatus.Entitled, - }; + isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, + } - editorItems[getEditorGroup(editor.package_info)].push(editorItem); - }); + editorItems[getEditorGroup(editor.package_info)].push(editorItem) + }) const editorMenuGroups: EditorMenuGroup[] = [ { @@ -134,7 +120,7 @@ export const createEditorMenuGroups = ( title: 'Others', items: editorItems.others, }, - ]; + ] - return editorMenuGroups; -}; + return editorMenuGroups +} diff --git a/app/assets/javascripts/components/Checkbox.tsx b/app/assets/javascripts/Components/Checkbox/index.tsx similarity index 53% rename from app/assets/javascripts/components/Checkbox.tsx rename to app/assets/javascripts/Components/Checkbox/index.tsx index e453f55d6..4217c543b 100644 --- a/app/assets/javascripts/components/Checkbox.tsx +++ b/app/assets/javascripts/Components/Checkbox/index.tsx @@ -1,20 +1,14 @@ -import { FunctionComponent } from 'preact'; +import { FunctionComponent } from 'preact' type CheckboxProps = { - name: string; - checked: boolean; - onChange: (e: Event) => void; - disabled?: boolean; - label: string; -}; + name: string + checked: boolean + onChange: (e: Event) => void + disabled?: boolean + label: string +} -export const Checkbox: FunctionComponent = ({ - name, - checked, - onChange, - disabled, - label, -}) => { +export const Checkbox: FunctionComponent = ({ name, checked, onChange, disabled, label }) => { return ( - ); -}; + ) +} diff --git a/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx b/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx new file mode 100644 index 000000000..fab2a95f8 --- /dev/null +++ b/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx @@ -0,0 +1,25 @@ +import { FunctionalComponent } from 'preact' + +interface IProps { + deprecationMessage: string | undefined + dismissDeprecationMessage: () => void +} + +export const IsDeprecated: FunctionalComponent = ({ deprecationMessage, dismissDeprecationMessage }) => { + return ( +
+
+
+
+
{deprecationMessage || 'This extension is deprecated.'}
+
+
+
+
+ +
+
+
+
+ ) +} diff --git a/app/assets/javascripts/components/ComponentView/IsExpired.tsx b/app/assets/javascripts/Components/ComponentView/IsExpired.tsx similarity index 53% rename from app/assets/javascripts/components/ComponentView/IsExpired.tsx rename to app/assets/javascripts/Components/ComponentView/IsExpired.tsx index ce95e0750..d2df6cee1 100644 --- a/app/assets/javascripts/components/ComponentView/IsExpired.tsx +++ b/app/assets/javascripts/Components/ComponentView/IsExpired.tsx @@ -1,29 +1,25 @@ -import { FeatureStatus } from '@standardnotes/snjs'; -import { FunctionalComponent } from 'preact'; +import { FeatureStatus } from '@standardnotes/snjs' +import { FunctionalComponent } from 'preact' interface IProps { - expiredDate: string; - componentName: string; - featureStatus: FeatureStatus; - manageSubscription: () => void; + expiredDate: string + componentName: string + featureStatus: FeatureStatus + manageSubscription: () => void } -const statusString = ( - featureStatus: FeatureStatus, - expiredDate: string, - componentName: string -) => { +const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => { switch (featureStatus) { case FeatureStatus.InCurrentPlanButExpired: - return `Your subscription expired on ${expiredDate}`; + return `Your subscription expired on ${expiredDate}` case FeatureStatus.NoUserSubscription: - return `You do not have an active subscription`; + return 'You do not have an active subscription' case FeatureStatus.NotInCurrentPlan: - return `Please upgrade your plan to access ${componentName}`; + return `Please upgrade your plan to access ${componentName}` default: - return `${componentName} is valid and you should not be seeing this message`; + return `${componentName} is valid and you should not be seeing this message` } -}; +} export const IsExpired: FunctionalComponent = ({ expiredDate, @@ -41,27 +37,18 @@ export const IsExpired: FunctionalComponent = ({
- - {statusString(featureStatus, expiredDate, componentName)} - -
- {componentName} is in a read-only state. -
+ {statusString(featureStatus, expiredDate, componentName)} +
{componentName} is in a read-only state.
-
manageSubscription()} - > - +
manageSubscription()}> +
- ); -}; + ) +} diff --git a/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx b/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx similarity index 59% rename from app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx rename to app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx index b2c70b408..69f3c48ca 100644 --- a/app/assets/javascripts/components/ComponentView/IssueOnLoading.tsx +++ b/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx @@ -1,22 +1,17 @@ -import { FunctionalComponent } from 'preact'; +import { FunctionalComponent } from 'preact' interface IProps { - componentName: string; - reloadIframe: () => void; + componentName: string + reloadIframe: () => void } -export const IssueOnLoading: FunctionalComponent = ({ - componentName, - reloadIframe, -}) => { +export const IssueOnLoading: FunctionalComponent = ({ componentName, reloadIframe }) => { return (
-
- There was an issue loading {componentName}. -
+
There was an issue loading {componentName}.
@@ -26,5 +21,5 @@ export const IssueOnLoading: FunctionalComponent = ({
- ); -}; + ) +} diff --git a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx b/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx similarity index 52% rename from app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx rename to app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx index a60ee0de3..ff2fa1063 100644 --- a/app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx +++ b/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx @@ -1,4 +1,4 @@ -import { FunctionalComponent } from 'preact'; +import { FunctionalComponent } from 'preact' export const OfflineRestricted: FunctionalComponent = () => { return ( @@ -7,25 +7,17 @@ export const OfflineRestricted: FunctionalComponent = () => {
-
- You have restricted this component to not use a hosted version. -
-
- Locally-installed components are not available in the web - application. -
+
You have restricted this component to not use a hosted version.
+
Locally-installed components are not available in the web application.
-
- To continue, choose from the following options: -
+
To continue, choose from the following options:
  • - Enable the Hosted option for this component by opening the - Preferences {'>'} General {'>'} Advanced Settings menu and{' '} - toggling 'Use hosted when local is unavailable' under this - component's options. Then press Reload. + Enable the Hosted option for this component by opening the Preferences {'>'} General {'>'} Advanced + Settings menu and toggling 'Use hosted when local is unavailable' under this component's options. + Then press Reload.
  • Use the desktop application.
@@ -35,5 +27,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
- ); -}; + ) +} diff --git a/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx new file mode 100644 index 000000000..ee70ebde6 --- /dev/null +++ b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx @@ -0,0 +1,22 @@ +import { FunctionalComponent } from 'preact' + +interface IProps { + componentName: string +} + +export const UrlMissing: FunctionalComponent = ({ componentName }) => { + return ( +
+
+
+
+
This extension is missing its URL property.
+

In order to access your note immediately, please switch from {componentName} to the Plain Editor.

+
+

Please contact help@standardnotes.com to remedy this issue.

+
+
+
+
+ ) +} diff --git a/app/assets/javascripts/Components/ComponentView/index.tsx b/app/assets/javascripts/Components/ComponentView/index.tsx new file mode 100644 index 000000000..65851f376 --- /dev/null +++ b/app/assets/javascripts/Components/ComponentView/index.tsx @@ -0,0 +1,221 @@ +import { + ComponentAction, + FeatureStatus, + SNComponent, + dateToLocalizedString, + ComponentViewer, + ComponentViewerEvent, + ComponentViewerError, +} from '@standardnotes/snjs' +import { WebApplication } from '@/UIModels/Application' +import { FunctionalComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { observer } from 'mobx-react-lite' +import { OfflineRestricted } from '@/Components/ComponentView/OfflineRestricted' +import { UrlMissing } from '@/Components/ComponentView/UrlMissing' +import { IsDeprecated } from '@/Components/ComponentView/IsDeprecated' +import { IsExpired } from '@/Components/ComponentView/IsExpired' +import { IssueOnLoading } from '@/Components/ComponentView/IssueOnLoading' +import { AppState } from '@/UIModels/AppState' +import { openSubscriptionDashboard } from '@/Utils/ManageSubscription' + +interface IProps { + application: WebApplication + appState: AppState + componentViewer: ComponentViewer + requestReload?: (viewer: ComponentViewer, force?: boolean) => void + onLoad?: (component: SNComponent) => void +} + +/** + * The maximum amount of time we'll wait for a component + * to load before displaying error + */ +const MaxLoadThreshold = 4000 +const VisibilityChangeKey = 'visibilitychange' +const MSToWaitAfterIframeLoadToAvoidFlicker = 35 + +export const ComponentView: FunctionalComponent = observer( + ({ application, onLoad, componentViewer, requestReload }) => { + const iframeRef = useRef(null) + const [loadTimeout, setLoadTimeout] = useState | undefined>(undefined) + + const [hasIssueLoading, setHasIssueLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [featureStatus, setFeatureStatus] = useState(componentViewer.getFeatureStatus()) + const [isComponentValid, setIsComponentValid] = useState(true) + const [error, setError] = useState(undefined) + const [deprecationMessage, setDeprecationMessage] = useState(undefined) + const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false) + const [didAttemptReload, setDidAttemptReload] = useState(false) + + const component = componentViewer.component + + const manageSubscription = useCallback(() => { + openSubscriptionDashboard(application) + }, [application]) + + const reloadValidityStatus = useCallback(() => { + setFeatureStatus(componentViewer.getFeatureStatus()) + if (!componentViewer.lockReadonly) { + componentViewer.setReadonly(featureStatus !== FeatureStatus.Entitled) + } + setIsComponentValid(componentViewer.shouldRender()) + + if (isLoading && !isComponentValid) { + setIsLoading(false) + } + + setError(componentViewer.getError()) + setDeprecationMessage(component.deprecationMessage) + }, [componentViewer, component.deprecationMessage, featureStatus, isComponentValid, isLoading]) + + useEffect(() => { + reloadValidityStatus() + }, [reloadValidityStatus]) + + const dismissDeprecationMessage = () => { + setIsDeprecationMessageDismissed(true) + } + + const onVisibilityChange = useCallback(() => { + if (document.visibilityState === 'hidden') { + return + } + if (hasIssueLoading) { + requestReload?.(componentViewer) + } + }, [hasIssueLoading, componentViewer, requestReload]) + + useEffect(() => { + const loadTimeout = setTimeout(() => { + setIsLoading(false) + setHasIssueLoading(true) + + if (!didAttemptReload) { + setDidAttemptReload(true) + requestReload?.(componentViewer) + } else { + document.addEventListener(VisibilityChangeKey, onVisibilityChange) + } + }, MaxLoadThreshold) + + setLoadTimeout(loadTimeout) + + return () => { + if (loadTimeout) { + clearTimeout(loadTimeout) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [componentViewer]) + + const onIframeLoad = useCallback(() => { + const iframe = iframeRef.current as HTMLIFrameElement + const contentWindow = iframe.contentWindow as Window + + if (loadTimeout) { + clearTimeout(loadTimeout) + } + + try { + componentViewer.setWindow(contentWindow) + } catch (error) { + console.error(error) + } + + setTimeout(() => { + setIsLoading(false) + setHasIssueLoading(false) + onLoad?.(component) + }, MSToWaitAfterIframeLoadToAvoidFlicker) + }, [componentViewer, onLoad, component, loadTimeout]) + + useEffect(() => { + const removeFeaturesChangedObserver = componentViewer.addEventObserver((event) => { + if (event === ComponentViewerEvent.FeatureStatusUpdated) { + setFeatureStatus(componentViewer.getFeatureStatus()) + } + }) + + return () => { + removeFeaturesChangedObserver() + } + }, [componentViewer]) + + useEffect(() => { + const removeActionObserver = componentViewer.addActionObserver((action, data) => { + switch (action) { + case ComponentAction.KeyDown: + application.io.handleComponentKeyDown(data.keyboardModifier) + break + case ComponentAction.KeyUp: + application.io.handleComponentKeyUp(data.keyboardModifier) + break + case ComponentAction.Click: + application.getAppState().notes.setContextMenuOpen(false) + break + default: + return + } + }) + return () => { + removeActionObserver() + } + }, [componentViewer, application]) + + useEffect(() => { + const unregisterDesktopObserver = application + .getDesktopService() + ?.registerUpdateObserver((updatedComponent: SNComponent) => { + if (updatedComponent.uuid === component.uuid && updatedComponent.active) { + requestReload?.(componentViewer) + } + }) + + return () => { + unregisterDesktopObserver?.() + } + }, [application, requestReload, componentViewer, component.uuid]) + + return ( + <> + {hasIssueLoading && ( + { + reloadValidityStatus(), requestReload?.(componentViewer, true) + }} + /> + )} + + {featureStatus !== FeatureStatus.Entitled && ( + + )} + {deprecationMessage && !isDeprecationMessageDismissed && ( + + )} + {error === ComponentViewerError.OfflineRestricted && } + {error === ComponentViewerError.MissingUrl && } + {component.uuid && isComponentValid && ( + + )} + {isLoading &&
} + + ) + }, +) diff --git a/app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx b/app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx new file mode 100644 index 000000000..ed8679b15 --- /dev/null +++ b/app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'preact/hooks' +import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog' +import { STRING_SIGN_OUT_CONFIRMATION } from '@/Strings' +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { observer } from 'mobx-react-lite' +import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { isDesktopApplication } from '@/Utils' + +type Props = { + application: WebApplication + appState: AppState + applicationGroup: ApplicationGroup +} + +export const ConfirmSignoutContainer = observer((props: Props) => { + if (!props.appState.accountMenu.signingOut) { + return null + } + return +}) + +export const ConfirmSignoutModal = observer(({ application, appState, applicationGroup }: Props) => { + const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) + + const cancelRef = useRef(null) + function closeDialog() { + appState.accountMenu.setSigningOut(false) + } + + const [localBackupsCount, setLocalBackupsCount] = useState(0) + + useEffect(() => { + application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error) + }, [appState.accountMenu.signingOut, application.desktopDevice]) + + const workspaces = applicationGroup.getDescriptors() + const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication() + + return ( + +
+
+
+
+
+ Sign out workspace? + +
+

{STRING_SIGN_OUT_CONFIRMATION}

+ {showWorkspaceWarning && ( + <> +
+

+ Note: + Because you have other workspaces signed in, this sign out may leave logs and other metadata + of your session on this device. For a more robust sign out that performs a hard clear of all + app-related data, use the Sign out all workspaces option under Switch workspace. +

+ + )} +
+
+ + {localBackupsCount > 0 && ( +
+
+ + +
+ )} + +
+ + +
+
+
+
+
+
+
+ ) +}) diff --git a/app/assets/javascripts/components/Dropdown.tsx b/app/assets/javascripts/Components/Dropdown/index.tsx similarity index 58% rename from app/assets/javascripts/components/Dropdown.tsx rename to app/assets/javascripts/Components/Dropdown/index.tsx index 837697520..15667b7e6 100644 --- a/app/assets/javascripts/components/Dropdown.tsx +++ b/app/assets/javascripts/Components/Dropdown/index.tsx @@ -1,36 +1,29 @@ -import { - ListboxArrow, - ListboxButton, - ListboxInput, - ListboxList, - ListboxOption, - ListboxPopover, -} from '@reach/listbox'; -import VisuallyHidden from '@reach/visually-hidden'; -import { FunctionComponent } from 'preact'; -import { Icon } from './Icon'; -import { IconType } from '@standardnotes/snjs'; +import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox' +import VisuallyHidden from '@reach/visually-hidden' +import { FunctionComponent } from 'preact' +import { Icon } from '@/Components/Icon' +import { IconType } from '@standardnotes/snjs' export type DropdownItem = { - icon?: IconType; - iconClassName?: string; - label: string; - value: string; - disabled?: boolean; -}; + icon?: IconType + iconClassName?: string + label: string + value: string + disabled?: boolean +} type DropdownProps = { - id: string; - label: string; - items: DropdownItem[]; - value: string; - onChange: (value: string, item: DropdownItem) => void; - disabled?: boolean; -}; + id: string + label: string + items: DropdownItem[] + value: string + onChange: (value: string, item: DropdownItem) => void + disabled?: boolean +} type ListboxButtonProps = DropdownItem & { - isExpanded: boolean; -}; + isExpanded: boolean +} const CustomDropdownButton: FunctionComponent = ({ label, @@ -47,56 +40,38 @@ const CustomDropdownButton: FunctionComponent = ({ ) : null}
{label}
- + -); +) -export const Dropdown: FunctionComponent = ({ - id, - label, - items, - value, - onChange, - disabled, -}) => { - const labelId = `${id}-label`; +export const Dropdown: FunctionComponent = ({ id, label, items, value, onChange, disabled }) => { + const labelId = `${id}-label` const handleChange = (value: string) => { - const selectedItem = items.find( - (item) => item.value === value - ) as DropdownItem; + const selectedItem = items.find((item) => item.value === value) as DropdownItem - onChange(value, selectedItem); - }; + onChange(value, selectedItem) + } return ( <> {label} - + { - const current = items.find((item) => item.value === value); - const icon = current ? current?.icon : null; - const iconClassName = current ? current?.iconClassName : null; + const current = items.find((item) => item.value === value) + const icon = current ? current?.icon : null + const iconClassName = current ? current?.iconClassName : null return CustomDropdownButton({ value: value ? value : label.toLowerCase(), label, isExpanded, ...(icon ? { icon } : null), ...(iconClassName ? { iconClassName } : null), - }); + }) }} /> @@ -111,10 +86,7 @@ export const Dropdown: FunctionComponent = ({ > {item.icon ? (
- +
) : null}
{item.label}
@@ -125,5 +97,5 @@ export const Dropdown: FunctionComponent = ({
- ); -}; + ) +} diff --git a/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx new file mode 100644 index 000000000..6db5878cf --- /dev/null +++ b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx @@ -0,0 +1,37 @@ +import { formatSizeToReadableString } from '@standardnotes/filepicker' +import { FileItem } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { Icon } from '@/Components/Icon' + +type Props = { + file: FileItem +} + +export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { + return ( +
+
+ +
File information
+
+
+ Type: {file.mimeType} +
+
+ Decrypted Size: {formatSizeToReadableString(file.decryptedSize)} +
+
+ Encrypted Size: {formatSizeToReadableString(file.encryptedSize)} +
+
+ Created: {file.created_at.toLocaleString()} +
+
+ Last Modified: {file.userModifiedDate.toLocaleString()} +
+
+ File ID: {file.uuid} +
+
+ ) +} diff --git a/app/assets/javascripts/Components/Files/FilePreviewModal.tsx b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx new file mode 100644 index 000000000..e1facb053 --- /dev/null +++ b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx @@ -0,0 +1,247 @@ +import { WebApplication } from '@/UIModels/Application' +import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays' +import { DialogContent, DialogOverlay } from '@reach/dialog' +import { addToast, ToastType } from '@standardnotes/stylekit' +import { NoPreviewIllustration } from '@standardnotes/icons' +import { FunctionComponent } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' +import { getFileIconComponent } from '@/Components/AttachedFilesPopover/PopoverFileItem' +import { Button } from '@/Components/Button/Button' +import { Icon } from '@/Components/Icon' +import { FilePreviewInfoPanel } from './FilePreviewInfoPanel' +import { isFileTypePreviewable } from './isFilePreviewable' +import { PreviewComponent } from './PreviewComponent' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { KeyboardKey } from '@/Services/IOService' +import { AppState } from '@/UIModels/AppState' +import { observer } from 'mobx-react-lite' + +type Props = { + application: WebApplication + appState: AppState +} + +export const FilePreviewModal: FunctionComponent = observer(({ application, appState }) => { + const { currentFile, setCurrentFile, otherFiles, dismiss, isOpen } = appState.filePreviewModal + + if (!currentFile || !isOpen) { + return null + } + + const [objectUrl, setObjectUrl] = useState() + const [isFilePreviewable, setIsFilePreviewable] = useState(false) + const [isLoadingFile, setIsLoadingFile] = useState(true) + const [fileDownloadProgress, setFileDownloadProgress] = useState(0) + const [showFileInfoPanel, setShowFileInfoPanel] = useState(false) + const currentFileIdRef = useRef() + const closeButtonRef = useRef(null) + + const getObjectUrl = useCallback(async () => { + try { + const chunks: Uint8Array[] = [] + setFileDownloadProgress(0) + await application.files.downloadFile(currentFile, async (decryptedChunk, progress) => { + chunks.push(decryptedChunk) + if (progress) { + setFileDownloadProgress(Math.round(progress.percentComplete)) + } + }) + const finalDecryptedBytes = concatenateUint8Arrays(chunks) + setObjectUrl( + URL.createObjectURL( + new Blob([finalDecryptedBytes], { + type: currentFile.mimeType, + }), + ), + ) + } catch (error) { + console.error(error) + } finally { + setIsLoadingFile(false) + } + }, [application.files, currentFile]) + + useEffect(() => { + setIsLoadingFile(true) + }, [currentFile.uuid]) + + useEffect(() => { + const isPreviewable = isFileTypePreviewable(currentFile.mimeType) + setIsFilePreviewable(isPreviewable) + + if (!isPreviewable) { + setObjectUrl('') + setIsLoadingFile(false) + } + + if (currentFileIdRef.current !== currentFile.uuid && isPreviewable) { + getObjectUrl().catch(console.error) + } + + currentFileIdRef.current = currentFile.uuid + + return () => { + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + } + }, [currentFile, getObjectUrl, objectUrl]) + + const keyDownHandler = (event: KeyboardEvent) => { + if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) { + return + } + + event.preventDefault() + + const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid) + + switch (event.key) { + case KeyboardKey.Left: { + const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1 + const previousFile = otherFiles[previousFileIndex] + if (previousFile) { + setCurrentFile(previousFile) + } + break + } + case KeyboardKey.Right: { + const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0 + const nextFile = otherFiles[nextFileIndex] + if (nextFile) { + setCurrentFile(nextFile) + } + break + } + } + } + + return ( + + +
+
+
+ {getFileIconComponent( + application.iconsController.getIconForFileType(currentFile.mimeType), + 'w-6 h-6 flex-shrink-0', + )} +
+ {currentFile.name} +
+
+ + {objectUrl && ( + + )} + +
+
+
+
+ {isLoadingFile ? ( +
+
+
+
{fileDownloadProgress}%
+
+ Loading file... +
+ ) : objectUrl ? ( + + ) : ( +
+ +
This file can't be previewed.
+ {isFilePreviewable ? ( + <> +
+ There was an error loading the file. Try again, or download the file and open it using another + application. +
+
+ + +
+ + ) : ( + <> +
+ To view this file, download it and open it using another application. +
+ + + )} +
+ )} +
+ {showFileInfoPanel && } +
+
+
+ ) +}) diff --git a/app/assets/javascripts/Components/Files/ImagePreview.tsx b/app/assets/javascripts/Components/Files/ImagePreview.tsx new file mode 100644 index 000000000..7aecc46ab --- /dev/null +++ b/app/assets/javascripts/Components/Files/ImagePreview.tsx @@ -0,0 +1,71 @@ +import { IconType } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { useRef, useState } from 'preact/hooks' +import { IconButton } from '../Button/IconButton' + +type Props = { + objectUrl: string +} + +export const ImagePreview: FunctionComponent = ({ objectUrl }) => { + const initialImgHeightRef = useRef() + + const [imageZoomPercent, setImageZoomPercent] = useState(100) + + return ( +
+
+ { + if (!initialImgHeightRef.current) { + initialImgHeightRef.current = imgElement?.height + } + }} + /> +
+
+ Zoom: + { + setImageZoomPercent((percent) => { + const newPercent = percent - 10 + if (newPercent >= 10) { + return newPercent + } else { + return percent + } + }) + }} + /> + {imageZoomPercent}% + { + setImageZoomPercent((percent) => percent + 10) + }} + /> +
+
+ ) +} diff --git a/app/assets/javascripts/Components/Files/PreviewComponent.tsx b/app/assets/javascripts/Components/Files/PreviewComponent.tsx new file mode 100644 index 000000000..f673dc62d --- /dev/null +++ b/app/assets/javascripts/Components/Files/PreviewComponent.tsx @@ -0,0 +1,24 @@ +import { FileItem } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { ImagePreview } from './ImagePreview' + +type Props = { + file: FileItem + objectUrl: string +} + +export const PreviewComponent: FunctionComponent = ({ file, objectUrl }) => { + if (file.mimeType.startsWith('image/')) { + return + } + + if (file.mimeType.startsWith('video/')) { + return