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 9e74d98a6..cb8ee2671 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,6 @@ { "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": false } diff --git a/app/assets/javascripts/@types/modules.ts b/app/assets/javascripts/@types/modules.ts index b5518926f..8b139eba2 100644 --- a/app/assets/javascripts/@types/modules.ts +++ b/app/assets/javascripts/@types/modules.ts @@ -1,3 +1,3 @@ declare module '*.svg' { - export default function SvgComponent(props: React.SVGProps): JSX.Element; + 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..0cb0d0f49 --- /dev/null +++ b/app/assets/javascripts/App.tsx @@ -0,0 +1,79 @@ +'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 + } +} + +import { IsWebPlatform, WebAppVersion } from '@/Version' +import { Runtime, SNLog } from '@standardnotes/snjs' +import { render } from 'preact' +import { ApplicationGroupView } from './Components/ApplicationGroupView' +import { Bridge } from './Services/Bridge' +import { BrowserBridge } from './Services/BrowserBridge' +import { StartApplication } from './StartApplication' +import { ApplicationGroup } from './UIModels/ApplicationGroup' +import { isDev } from './Utils' + +const startApplication: StartApplication = async function startApplication( + defaultSyncServerHost: string, + bridge: Bridge, + enableUnfinishedFeatures: boolean, + webSocketUrl: string, +) { + SNLog.onLog = console.log + SNLog.onError = console.error + + const mainApplicationGroup = new ApplicationGroup( + defaultSyncServerHost, + bridge, + enableUnfinishedFeatures ? Runtime.Dev : Runtime.Prod, + webSocketUrl, + ) + + if (isDev) { + Object.defineProperties(window, { + application: { + get: () => mainApplicationGroup.primaryApplication, + }, + }) + } + + const renderApp = () => { + render( + , + document.body.appendChild(document.createElement('div')), + ) + } + + const domReady = document.readyState === 'complete' || document.readyState === 'interactive' + if (domReady) { + renderApp() + } else { + window.addEventListener('DOMContentLoaded', () => { + renderApp() + }) + } +} + +if (IsWebPlatform) { + startApplication( + window.defaultSyncServer, + new BrowserBridge(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..6351b53bd --- /dev/null +++ b/app/assets/javascripts/Components/Abstract/PureComponent.tsx @@ -0,0 +1,135 @@ +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< + P = PureComponentProps, + S = PureComponentState, +> 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 + } + + 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() + } + + render() { + return
Must override
+ } + + 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) => { + 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 similarity index 71% rename from app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx rename to app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx index ec67e0ed3..266660f11 100644 --- a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx @@ -1,96 +1,85 @@ -import { WebApplication } from '@/ui_models/application'; -import { AppState } from '@/ui_models/app_state'; -import { observer } from 'mobx-react-lite'; -import { FunctionComponent } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; -import { Checkbox } from '../Checkbox'; -import { DecoratedInput } from '../DecoratedInput'; -import { Icon } from '../Icon'; +import { WebApplication } from '@/UIModels/Application' +import { AppState } from '@/UIModels/AppState' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'preact' +import { 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; - onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void; - onStrictSignInChange?: (isStrictSignIn: boolean) => void; -}; + application: WebApplication + appState: AppState + disabled?: boolean + onVaultChange?: (isVault: boolean, vaultedEmail?: string) => void + onStrictSignInChange?: (isStrictSignIn: boolean) => void +} export const AdvancedOptions: FunctionComponent = observer( - ({ - appState, - application, - disabled = false, - onVaultChange, - onStrictSignInChange, - children, - }) => { - const { server, setServer, enableServerOption, setEnableServerOption } = - appState.accountMenu; - const [showAdvanced, setShowAdvanced] = useState(false); + ({ appState, application, disabled = false, onVaultChange, onStrictSignInChange, children }) => { + const { server, setServer, enableServerOption, setEnableServerOption } = appState.accountMenu + const [showAdvanced, setShowAdvanced] = useState(false) - const [isVault, setIsVault] = useState(false); - const [vaultName, setVaultName] = useState(''); - const [vaultUserphrase, setVaultUserphrase] = useState(''); + const [isVault, setIsVault] = useState(false) + const [vaultName, setVaultName] = useState('') + const [vaultUserphrase, setVaultUserphrase] = useState('') - const [isStrictSignin, setIsStrictSignin] = useState(false); + const [isStrictSignin, setIsStrictSignin] = useState(false) useEffect(() => { const recomputeVaultedEmail = async () => { - const vaultedEmail = await application.vaultToEmail( - vaultName, - vaultUserphrase - ); + const vaultedEmail = await application.vaultToEmail(vaultName, vaultUserphrase) if (!vaultedEmail) { if (vaultName?.length > 0 && vaultUserphrase?.length > 0) { - application.alertService.alert('Unable to compute vault name.'); + application.alertService.alert('Unable to compute vault name.').catch(console.error) } - return; + return } - onVaultChange?.(true, vaultedEmail); - }; + onVaultChange?.(true, vaultedEmail) + } if (vaultName && vaultUserphrase) { - recomputeVaultedEmail(); + recomputeVaultedEmail().catch(console.error) } - }, [vaultName, vaultUserphrase, application, onVaultChange]); + }, [vaultName, vaultUserphrase, application, onVaultChange]) useEffect(() => { - onVaultChange?.(isVault); - }, [isVault, onVaultChange]); + onVaultChange?.(isVault) + }, [isVault, onVaultChange]) const handleIsVaultChange = () => { - setIsVault(!isVault); - }; + setIsVault(!isVault) + } const handleVaultNameChange = (name: string) => { - setVaultName(name); - }; + setVaultName(name) + } const handleVaultUserphraseChange = (userphrase: string) => { - setVaultUserphrase(userphrase); - }; + setVaultUserphrase(userphrase) + } const handleServerOptionChange = (e: Event) => { if (e.target instanceof HTMLInputElement) { - setEnableServerOption(e.target.checked); + setEnableServerOption(e.target.checked) } - }; + } const handleSyncServerChange = (server: string) => { - setServer(server); - application.setCustomHost(server); - }; + setServer(server) + application.setCustomHost(server).catch(console.error) + } const handleStrictSigninChange = () => { - const newValue = !isStrictSignin; - setIsStrictSignin(newValue); - onStrictSignInChange?.(newValue); - }; + const newValue = !isStrictSignin + setIsStrictSignin(newValue) + onStrictSignInChange?.(newValue) + } const toggleShowAdvanced = () => { - setShowAdvanced(!showAdvanced); - }; + setShowAdvanced(!showAdvanced) + } return ( <> @@ -130,7 +119,7 @@ export const AdvancedOptions: FunctionComponent = observer( {appState.enableUnfinishedFeatures && isVault && ( <> ]} type="text" placeholder="Vault name" @@ -139,7 +128,7 @@ export const AdvancedOptions: FunctionComponent = observer( disabled={disabled} /> ]} type="text" placeholder="Vault userphrase" @@ -188,6 +177,6 @@ export const AdvancedOptions: FunctionComponent = observer( ) : null} - ); - } -); + ) + }, +) diff --git a/app/assets/javascripts/components/AccountMenu/ConfirmPassword.tsx b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx similarity index 60% rename from app/assets/javascripts/components/AccountMenu/ConfirmPassword.tsx rename to app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx index cbb981c8c..bf633af22 100644 --- a/app/assets/javascripts/components/AccountMenu/ConfirmPassword.tsx +++ b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx @@ -1,96 +1,96 @@ -import { STRING_NON_MATCHING_PASSWORDS } from '@/strings'; -import { WebApplication } from '@/ui_models/application'; -import { AppState } from '@/ui_models/app_state'; -import { observer } from 'mobx-react-lite'; -import { FunctionComponent } from 'preact'; -import { useEffect, useRef, useState } from 'preact/hooks'; -import { AccountMenuPane } from '.'; -import { Button } from '../Button'; -import { Checkbox } from '../Checkbox'; -import { DecoratedPasswordInput } from '../DecoratedPasswordInput'; -import { Icon } from '../Icon'; -import { IconButton } from '../IconButton'; +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 { 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; -}; + 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 { 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); + const passwordInputRef = useRef(null) useEffect(() => { - passwordInputRef.current?.focus(); - }, []); + passwordInputRef.current?.focus() + }, []) const handlePasswordChange = (text: string) => { - setConfirmPassword(text); - }; + setConfirmPassword(text) + } const handleEphemeralChange = () => { - setIsEphemeral(!isEphemeral); - }; + setIsEphemeral(!isEphemeral) + } const handleShouldMergeChange = () => { - setShouldMergeLocal(!shouldMergeLocal); - }; + setShouldMergeLocal(!shouldMergeLocal) + } const handleKeyDown = (e: KeyboardEvent) => { if (error.length) { - setError(''); + setError('') } if (e.key === 'Enter') { - handleConfirmFormSubmit(e); + handleConfirmFormSubmit(e) } - }; + } const handleConfirmFormSubmit = (e: Event) => { - e.preventDefault(); + e.preventDefault() if (!password) { - passwordInputRef.current?.focus(); - return; + passwordInputRef.current?.focus() + return } if (password === confirmPassword) { - setIsRegistering(true); + setIsRegistering(true) application .register(email, password, isEphemeral, shouldMergeLocal) .then((res) => { if (res.error) { - throw new Error(res.error.message); + throw new Error(res.error.message) } - appState.accountMenu.closeAccountMenu(); - appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu); + appState.accountMenu.closeAccountMenu() + appState.accountMenu.setCurrentPane(AccountMenuPane.GeneralMenu) }) .catch((err) => { - console.error(err); - setError(err.message); + console.error(err) + setError(err.message) }) .finally(() => { - setIsRegistering(false); - }); + setIsRegistering(false) + }) } else { - setError(STRING_NON_MATCHING_PASSWORDS); - setConfirmPassword(''); - passwordInputRef.current?.focus(); + setError(STRING_NON_MATCHING_PASSWORDS) + setConfirmPassword('') + passwordInputRef.current?.focus() } - }; + } const handleGoBack = () => { - setMenuPane(AccountMenuPane.Register); - }; + setMenuPane(AccountMenuPane.Register) + } return ( <> @@ -110,8 +110,7 @@ export const ConfirmPassword: FunctionComponent = observer( Standard Notes does not have a password reset option - . If you forget your password, you will permanently lose access to - your data. + . If you forget your password, you will permanently lose access to your data.
= observer( {error ?
{error}
: null} )} @@ -179,7 +164,7 @@ export const AttachedFilesPopover: FunctionComponent = observer( getIconType={application.iconsController.getIconForFileType} closeOnBlur={closeOnBlur} /> - ); + ) }) ) : (
@@ -193,17 +178,10 @@ export const AttachedFilesPopover: FunctionComponent = observer( ? 'No files attached to this note' : 'No files found in this account'}
- -
- Or drop your files here -
+
Or drop your files here
)} @@ -214,13 +192,10 @@ export const AttachedFilesPopover: FunctionComponent = observer( onBlur={closeOnBlur} > - {currentTab === PopoverTabs.AttachedFiles - ? 'Attach' - : 'Upload'}{' '} - files + {currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files )} - ); - } -); + ) + }, +) diff --git a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileItem.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx similarity index 62% rename from app/assets/javascripts/components/AttachedFilesPopover/PopoverFileItem.tsx rename to app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx index 1f6981f47..d5b61ceb3 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, SNFile } 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: SNFile + isAttachedToNote: boolean + handleFileAction: (action: PopoverFileItemAction) => Promise + getIconType(type: string): IconType + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void +} export const PopoverFileItem: FunctionComponent = ({ file, @@ -32,16 +29,16 @@ 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) => { await handleFileAction({ @@ -50,23 +47,23 @@ export const PopoverFileItem: FunctionComponent = ({ 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) + } return (
= ({ 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.created_at.toLocaleString()} ·{' '} - {formatSizeToReadableString(file.size)} + {file.created_at.toLocaleString()} · {formatSizeToReadableString(file.size)}
@@ -115,5 +108,5 @@ export const PopoverFileItem: FunctionComponent = ({ closeOnBlur={closeOnBlur} /> - ); -}; + ) +} diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx new file mode 100644 index 000000000..b774c6b90 --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx @@ -0,0 +1,31 @@ +import { SNFile } from '@standardnotes/snjs' + +export enum PopoverFileItemActionType { + AttachFileToNote, + DetachFileToNote, + DeleteFile, + DownloadFile, + RenameFile, + ToggleFileProtection, +} + +export type PopoverFileItemAction = + | { + type: Exclude< + PopoverFileItemActionType, + PopoverFileItemActionType.RenameFile | PopoverFileItemActionType.ToggleFileProtection + > + payload: SNFile + } + | { + type: PopoverFileItemActionType.ToggleFileProtection + payload: SNFile + callback: (isProtected: boolean) => void + } + | { + type: PopoverFileItemActionType.RenameFile + payload: { + file: SNFile + 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..fe5a94e5f 100644 --- a/app/assets/javascripts/components/AttachedFilesPopover/PopoverFileSubmenu.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx @@ -1,31 +1,18 @@ -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 { useFilePreviewModal } from '@/Components/Files/FilePreviewModalProvider' +import { PopoverFileItemProps } from './PopoverFileItem' +import { PopoverFileItemActionType } from './PopoverFileItemAction' type Props = Omit & { - setIsRenamingFile: StateUpdater; -}; + setIsRenamingFile: StateUpdater +} export const PopoverFileSubmenu: FunctionComponent = ({ file, @@ -33,54 +20,51 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction, setIsRenamingFile, }) => { - const filePreviewModal = useFilePreviewModal(); + 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); - }; + setIsMenuOpen(false) + } const toggleMenu = () => { if (!isMenuOpen) { - const menuPosition = calculateSubmenuStyle(menuButtonRef.current); + const menuPosition = calculateSubmenuStyle(menuButtonRef.current) if (menuPosition) { - setMenuStyle(menuPosition); + setMenuStyle(menuPosition) } } - setIsMenuOpen(!isMenuOpen); - }; + setIsMenuOpen(!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 +90,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={() => { - filePreviewModal.activate(file); - closeMenu(); + filePreviewModal.activate(file) + closeMenu() }} > @@ -121,8 +105,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DetachFileToNote, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -136,8 +120,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.AttachFileToNote, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -152,9 +136,9 @@ export const PopoverFileSubmenu: FunctionComponent = ({ type: PopoverFileItemActionType.ToggleFileProtection, payload: file, callback: (isProtected: boolean) => { - setIsFileProtected(isProtected); + setIsFileProtected(isProtected) }, - }); + }).catch(console.error) }} onBlur={closeOnBlur} > @@ -176,8 +160,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DownloadFile, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -187,7 +171,7 @@ export const PopoverFileSubmenu: FunctionComponent = ({ onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={() => { - setIsRenamingFile(true); + setIsRenamingFile(true) }} > @@ -200,8 +184,8 @@ export const PopoverFileSubmenu: FunctionComponent = ({ handleFileAction({ type: PopoverFileItemActionType.DeleteFile, payload: file, - }); - closeMenu(); + }).catch(console.error) + closeMenu() }} > @@ -212,5 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent = ({
- ); -}; + ) +} 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.tsx b/app/assets/javascripts/Components/Button/Button.tsx similarity index 54% rename from app/assets/javascripts/components/Button.tsx rename to app/assets/javascripts/Components/Button/Button.tsx index 1afc15904..eca0e336e 100644 --- a/app/assets/javascripts/components/Button.tsx +++ b/app/assets/javascripts/Components/Button/Button.tsx @@ -1,58 +1,44 @@ -import { JSXInternal } from 'preact/src/jsx'; -import { ComponentChildren, FunctionComponent, Ref } from 'preact'; -import { forwardRef } from 'preact/compat'; +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`; +const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content' -type ButtonVariant = 'normal' | 'primary'; +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'; +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 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'; + : 'hover:brightness-130 focus:outline-none focus:brightness-130' if (danger) { - colors = - variant === 'normal' - ? 'bg-default color-danger' - : 'bg-danger color-info-contrast'; + 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'; + 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'; + : 'focus:brightness-default focus:outline-none hover:brightness-default' } - return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`; -}; + return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}` +} type ButtonProps = JSXInternal.HTMLAttributes & { - children?: ComponentChildren; - className?: string; - variant?: ButtonVariant; - dangerStyle?: boolean; - label?: string; -}; + children?: ComponentChildren + className?: string + variant?: ButtonVariant + dangerStyle?: boolean + label?: string +} export const Button: FunctionComponent = forwardRef( ( @@ -65,7 +51,7 @@ export const Button: FunctionComponent = forwardRef( children, ...props }: ButtonProps, - ref: Ref + ref: Ref, ) => { return ( - ); - } -); + ) + }, +) diff --git a/app/assets/javascripts/components/IconButton.tsx b/app/assets/javascripts/Components/Button/IconButton.tsx similarity index 66% rename from app/assets/javascripts/components/IconButton.tsx rename to app/assets/javascripts/Components/Button/IconButton.tsx index 6349be7bc..f74bec08c 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,10 +38,10 @@ 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..ee3d20325 --- /dev/null +++ b/app/assets/javascripts/Components/Button/RoundIconButton.tsx @@ -0,0 +1,40 @@ +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 similarity index 54% rename from app/assets/javascripts/components/ChallengeModal/ChallengeModal.tsx rename to app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx index 4f006c1db..c3990d784 100644 --- a/app/assets/javascripts/components/ChallengeModal/ChallengeModal.tsx +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -1,5 +1,5 @@ -import { WebApplication } from '@/ui_models/application'; -import { DialogContent, DialogOverlay } from '@reach/dialog'; +import { WebApplication } from '@/UIModels/Application' +import { DialogContent, DialogOverlay } from '@reach/dialog' import { ButtonType, Challenge, @@ -7,90 +7,87 @@ import { ChallengeReason, ChallengeValue, removeFromArray, -} from '@standardnotes/snjs'; -import { ProtectedIllustration } from '@standardnotes/stylekit'; -import { FunctionComponent } from 'preact'; -import { useCallback, useEffect, useState } from 'preact/hooks'; -import { Button } from '../Button'; -import { Icon } from '../Icon'; -import { ChallengeModalPrompt } from './ChallengePrompt'; +} from '@standardnotes/snjs' +import { ProtectedIllustration } from '@standardnotes/stylekit' +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' type InputValue = { - prompt: ChallengePrompt; - value: string | number | boolean; - invalid: boolean; -}; + prompt: ChallengePrompt + value: string | number | boolean + invalid: boolean +} -export type ChallengeModalValues = Record; +export type ChallengeModalValues = Record type Props = { - application: WebApplication; - challenge: Challenge; - onDismiss: (challenge: Challenge) => Promise; -}; + application: WebApplication + challenge: Challenge + onDismiss: (challenge: Challenge) => Promise +} const validateValues = ( values: ChallengeModalValues, - prompts: ChallengePrompt[] + prompts: ChallengePrompt[], ): ChallengeModalValues | undefined => { - let hasInvalidValues = false; - const validatedValues = { ...values }; + let hasInvalidValues = false + const validatedValues = { ...values } for (const prompt of prompts) { - const value = validatedValues[prompt.id]; + const value = validatedValues[prompt.id] if (typeof value.value === 'string' && value.value.length === 0) { - validatedValues[prompt.id].invalid = true; - hasInvalidValues = true; + validatedValues[prompt.id].invalid = true + hasInvalidValues = true } } if (!hasInvalidValues) { - return validatedValues; + return validatedValues } -}; + return undefined +} -export const ChallengeModal: FunctionComponent = ({ - application, - challenge, - onDismiss, -}) => { +export const ChallengeModal: FunctionComponent = ({ application, challenge, onDismiss }) => { const [values, setValues] = useState(() => { - const values = {} as ChallengeModalValues; + 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); + 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); + ].includes(challenge.reason) const submit = async () => { - const validatedValues = validateValues(values, challenge.prompts); + const validatedValues = validateValues(values, challenge.prompts) if (!validatedValues) { - return; + return } if (isSubmitting || isProcessing) { - return; + return } - setIsSubmitting(true); - setIsProcessing(true); - const valuesToProcess: ChallengeValue[] = []; + setIsSubmitting(true) + setIsProcessing(true) + const valuesToProcess: ChallengeValue[] = [] for (const inputValue of Object.values(validatedValues)) { - const rawValue = inputValue.value; - const value = new ChallengeValue(inputValue.prompt, rawValue); - valuesToProcess.push(value); + const rawValue = inputValue.value + const value = new ChallengeValue(inputValue.prompt, rawValue) + valuesToProcess.push(value) } - const processingPrompts = valuesToProcess.map((v) => v.prompt); - setIsProcessing(processingPrompts.length > 0); - setProcessingPrompts(processingPrompts); + 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 @@ -98,90 +95,85 @@ export const ChallengeModal: FunctionComponent = ({ */ setTimeout(() => { if (valuesToProcess.length > 0) { - application.submitValuesForChallenge(challenge, valuesToProcess); + application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error) } else { - setIsProcessing(false); + setIsProcessing(false) } - setIsSubmitting(false); - }, 50); - }; + setIsSubmitting(false) + }, 50) + } const onValueChange = useCallback( (value: string | number, prompt: ChallengePrompt) => { - const newValues = { ...values }; - newValues[prompt.id].invalid = false; - newValues[prompt.id].value = value; - setValues(newValues); + const newValues = { ...values } + newValues[prompt.id].invalid = false + newValues[prompt.id].value = value + setValues(newValues) }, - [values] - ); + [values], + ) const closeModal = () => { if (challenge.cancelable) { - onDismiss(challenge); + onDismiss(challenge).catch(console.error) } - }; + } useEffect(() => { - const removeChallengeObserver = application.addChallengeObserver( - challenge, - { - onValidValue: (value) => { - setValues((values) => { - const newValues = { ...values }; - newValues[value.prompt.id].invalid = false; - return newValues; - }); + 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; - }); - }, - 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); - }, - } - ); + const processingPrompts = currentlyProcessingPrompts.slice() + removeFromArray(processingPrompts, value.prompt) + setIsProcessing(processingPrompts.length > 0) + return processingPrompts + }) + } + }, + onComplete: () => { + onDismiss(challenge).catch(console.error) + }, + onCancel: () => { + onDismiss(challenge).catch(console.error) + }, + }) return () => { - removeChallengeObserver(); - }; - }, [application, challenge, onDismiss]); + removeChallengeObserver() + } + }, [application, challenge, onDismiss]) if (!challenge.prompts) { - return null; + return null } return ( = ({ )} -
- {challenge.heading} -
-
- {challenge.subheading} -
+
{challenge.heading}
+
{challenge.subheading}
{ - e.preventDefault(); - submit(); + e.preventDefault() + submit().catch(console.error) }} > {challenge.prompts.map((prompt, index) => ( @@ -232,7 +220,7 @@ export const ChallengeModal: FunctionComponent = ({ disabled={isProcessing} className="min-w-76 mb-3.5" onClick={() => { - submit(); + submit().catch(console.error) }} > {isProcessing ? 'Generating Keys...' : 'Unlock'} @@ -241,23 +229,23 @@ export const ChallengeModal: FunctionComponent = ({ + - ); -}; + ) +} 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 69% rename from app/assets/javascripts/components/ComponentView/OfflineRestricted.tsx rename to app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx index a60ee0de3..290ef05d0 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 ( @@ -11,21 +11,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. + 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 +31,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
- ); -}; + ) +} diff --git a/app/assets/javascripts/components/ComponentView/UrlMissing.tsx b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx similarity index 60% rename from app/assets/javascripts/components/ComponentView/UrlMissing.tsx rename to app/assets/javascripts/Components/ComponentView/UrlMissing.tsx index fa6a56af2..c077671f6 100644 --- a/app/assets/javascripts/components/ComponentView/UrlMissing.tsx +++ b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx @@ -1,7 +1,7 @@ -import { FunctionalComponent } from 'preact'; +import { FunctionalComponent } from 'preact' interface IProps { - componentName: string; + componentName: string } export const UrlMissing: FunctionalComponent = ({ componentName }) => { @@ -14,16 +14,14 @@ export const UrlMissing: FunctionalComponent = ({ componentName }) => { 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. + 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..be26e251d --- /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, useMemo, 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 + manualDealloc?: boolean +} + +/** + * 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 excessiveLoadingTimeout = useRef | 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]) + + const handleIframeTakingTooLongToLoad = useCallback(async () => { + setIsLoading(false) + setHasIssueLoading(true) + + if (!didAttemptReload) { + setDidAttemptReload(true) + requestReload?.(componentViewer) + } else { + document.addEventListener(VisibilityChangeKey, onVisibilityChange) + } + }, [didAttemptReload, onVisibilityChange, componentViewer, requestReload]) + + useMemo(() => { + const loadTimeout = setTimeout(() => { + handleIframeTakingTooLongToLoad().catch(console.error) + }, MaxLoadThreshold) + + excessiveLoadingTimeout.current = loadTimeout + + return () => { + excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current) + } + }, [handleIframeTakingTooLongToLoad]) + + const onIframeLoad = useCallback(() => { + const iframe = iframeRef.current as HTMLIFrameElement + const contentWindow = iframe.contentWindow as Window + excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current) + + componentViewer.setWindow(contentWindow).catch(console.error) + + setTimeout(() => { + setIsLoading(false) + setHasIssueLoading(false) + onLoad?.(component) + }, MSToWaitAfterIframeLoadToAvoidFlicker) + }, [componentViewer, onLoad, component, excessiveLoadingTimeout]) + + 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..132b42574 --- /dev/null +++ b/app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx @@ -0,0 +1,98 @@ +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' + +type Props = { + application: WebApplication + appState: AppState +} + +export const ConfirmSignoutContainer = observer((props: Props) => { + if (!props.appState.accountMenu.signingOut) { + return null + } + return +}) + +export const ConfirmSignoutModal = observer(({ application, appState }: Props) => { + const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) + + const cancelRef = useRef(null) + function closeDialog() { + appState.accountMenu.setSigningOut(false) + } + + const [localBackupsCount, setLocalBackupsCount] = useState(0) + + useEffect(() => { + application.bridge.localBackupsCount().then(setLocalBackupsCount).catch(console.error) + }, [appState.accountMenu.signingOut, application.bridge]) + + return ( + +
+
+
+
+
+ + Sign out workspace? + + +

{STRING_SIGN_OUT_CONFIRMATION}

+
+ {localBackupsCount > 0 && ( +
+
+ + +
+ )} +
+ + +
+
+
+
+
+
+
+ ) +}) diff --git a/app/assets/javascripts/components/Dropdown.tsx b/app/assets/javascripts/Components/Dropdown/index.tsx similarity index 74% rename from app/assets/javascripts/components/Dropdown.tsx rename to app/assets/javascripts/Components/Dropdown/index.tsx index 837697520..9bc9b6b22 100644 --- a/app/assets/javascripts/components/Dropdown.tsx +++ b/app/assets/javascripts/Components/Dropdown/index.tsx @@ -5,32 +5,32 @@ import { 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'; +} 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,15 +47,11 @@ const CustomDropdownButton: FunctionComponent = ({ ) : null}
{label}
- + -); +) export const Dropdown: FunctionComponent = ({ id, @@ -65,15 +61,13 @@ export const Dropdown: FunctionComponent = ({ onChange, disabled, }) => { - const labelId = `${id}-label`; + 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 ( <> @@ -87,16 +81,16 @@ export const Dropdown: FunctionComponent = ({ { - 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), - }); + }) }} /> @@ -125,5 +119,5 @@ export const Dropdown: FunctionComponent = ({ - ); -}; + ) +} diff --git a/app/assets/javascripts/components/Files/FilePreviewInfoPanel.tsx b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx similarity index 70% rename from app/assets/javascripts/components/Files/FilePreviewInfoPanel.tsx rename to app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx index cf215c80d..5a12704e9 100644 --- a/app/assets/javascripts/components/Files/FilePreviewInfoPanel.tsx +++ b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx @@ -1,11 +1,11 @@ -import { formatSizeToReadableString } from '@standardnotes/filepicker'; -import { SNFile } from '@standardnotes/snjs'; -import { FunctionComponent } from 'preact'; -import { Icon } from '../Icon'; +import { formatSizeToReadableString } from '@standardnotes/filepicker' +import { SNFile } from '@standardnotes/snjs' +import { FunctionComponent } from 'preact' +import { Icon } from '@/Components/Icon' type Props = { - file: SNFile; -}; + file: SNFile +} export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { return ( @@ -18,12 +18,10 @@ export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { Type: {file.mimeType}
- Size:{' '} - {formatSizeToReadableString(file.size)} + Size: {formatSizeToReadableString(file.size)}
- Created:{' '} - {file.created_at.toLocaleString()} + Created: {file.created_at.toLocaleString()}
Last Modified:{' '} @@ -33,5 +31,5 @@ export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { File ID: {file.uuid}
- ); -}; + ) +} diff --git a/app/assets/javascripts/components/Files/FilePreviewModal.tsx b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx similarity index 68% rename from app/assets/javascripts/components/Files/FilePreviewModal.tsx rename to app/assets/javascripts/Components/Files/FilePreviewModal.tsx index 6ea7f46e0..f709fde05 100644 --- a/app/assets/javascripts/components/Files/FilePreviewModal.tsx +++ b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx @@ -1,88 +1,81 @@ -import { WebApplication } from '@/ui_models/application'; -import { concatenateUint8Arrays } from '@/utils/concatenateUint8Arrays'; -import { DialogContent, DialogOverlay } from '@reach/dialog'; -import { SNFile } from '@standardnotes/snjs'; -import { NoPreviewIllustration } from '@standardnotes/stylekit'; -import { FunctionComponent } from 'preact'; -import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; -import { getFileIconComponent } from '../AttachedFilesPopover/PopoverFileItem'; -import { Button } from '../Button'; -import { Icon } from '../Icon'; -import { FilePreviewInfoPanel } from './FilePreviewInfoPanel'; -import { isFileTypePreviewable } from './isFilePreviewable'; +import { WebApplication } from '@/UIModels/Application' +import { concatenateUint8Arrays } from '@/Utils/ConcatenateUint8Arrays' +import { DialogContent, DialogOverlay } from '@reach/dialog' +import { SNFile } from '@standardnotes/snjs' +import { NoPreviewIllustration } from '@standardnotes/stylekit' +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' type Props = { - application: WebApplication; - file: SNFile; - onDismiss: () => void; -}; + application: WebApplication + file: SNFile + onDismiss: () => void +} const getPreviewComponentForFile = (file: SNFile, objectUrl: string) => { if (file.mimeType.startsWith('image/')) { - return ; + return } if (file.mimeType.startsWith('video/')) { - return