diff --git a/.babelrc b/.babelrc index 643b8d31e..d5e8e32f5 100644 --- a/.babelrc +++ b/.babelrc @@ -1,12 +1,4 @@ { - "presets": [ - "@babel/preset-typescript", - "@babel/preset-env" - ], - "plugins": [ - ["@babel/plugin-transform-react-jsx", { - "pragma": "h", - "pragmaFrag": "Fragment" - }] - ] + "presets": ["@babel/preset-typescript", "@babel/preset-env"], + "plugins": [["@babel/plugin-transform-react-jsx"]] } diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index cc2afebb3..37a1ebb0a 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -6,23 +6,23 @@ on: workflow_dispatch: jobs: - tsc: - name: Check types & lint + test: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v2 - name: Install dependencies run: yarn install --pure-lockfile - - name: Typescript - run: yarn tsc + - name: Bundle + run: yarn bundle - name: ESLint - run: yarn lint --quiet + run: yarn lint + - name: Test + run: yarn test deploy: runs-on: ubuntu-latest - needs: tsc + needs: test steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 6b9a25618..f1ec79fb1 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -10,31 +10,26 @@ on: workflow_dispatch: jobs: - - tsc: - - name: Check types & lint - + test: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v2 - - name: Install dependencies run: yarn install --pure-lockfile - - - name: Typescript - run: yarn tsc - + - name: Bundle + run: yarn bundle - name: ESLint - run: yarn lint --quiet + run: yarn lint + - name: Test + run: yarn test + deploy: runs-on: ubuntu-latest - needs: tsc + needs: test steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7ad942059..c48769161 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,21 +7,16 @@ on: - main jobs: - - tsc: - + test: runs-on: ubuntu-latest - steps: - - name: Checkout code uses: actions/checkout@v2 - - name: Install dependencies run: yarn install --pure-lockfile - - - name: Typescript - run: yarn tsc - + - name: Bundle + run: yarn bundle - name: ESLint - run: yarn lint --quiet + run: yarn lint + - name: Test + run: yarn test diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index eac8cf30d..83365e653 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -9,32 +9,25 @@ on: branches: [ main ] jobs: - - tsc: - - name: Check types & lint - + test: runs-on: ubuntu-latest - steps: - - name: Checkout code uses: actions/checkout@v2 - - name: Install dependencies run: yarn install --pure-lockfile - - - name: Typescript - run: yarn tsc - + - name: Bundle + run: yarn bundle - name: ESLint - run: yarn lint --quiet + run: yarn lint + - name: Test + run: yarn test deploy: runs-on: ubuntu-latest - needs: tsc + needs: test steps: - uses: actions/checkout@v2 diff --git a/app/assets/javascripts/App.tsx b/app/assets/javascripts/App.tsx index 1222efb87..d78da57eb 100644 --- a/app/assets/javascripts/App.tsx +++ b/app/assets/javascripts/App.tsx @@ -20,16 +20,15 @@ declare global { } } -import { IsWebPlatform, WebAppVersion } from '@/Version' +import { IsWebPlatform, WebAppVersion } from '@/Constants/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' +import ApplicationGroupView from './Components/ApplicationGroupView/ApplicationGroupView' +import { WebDevice } from './Application/Device/WebDevice' +import { StartApplication } from './Application/Device/StartApplication' +import { ApplicationGroup } from './Application/ApplicationGroup' +import { WebOrDesktopDevice } from './Application/Device/WebOrDesktopDevice' +import { WebApplication } from './Application/Application' +import { createRoot, Root } from 'react-dom/client' let keyCount = 0 const getKey = () => { @@ -46,21 +45,22 @@ const startApplication: StartApplication = async function startApplication( ) { SNLog.onLog = console.log SNLog.onError = console.error + let root: Root const onDestroy = () => { - const root = document.getElementById(RootId) as HTMLElement - unmountComponentAtRoot(root) - root.remove() + const rootElement = document.getElementById(RootId) as HTMLElement + root.unmount() + rootElement.remove() renderApp() } const renderApp = () => { - const root = document.createElement('div') - root.id = RootId + const rootElement = document.createElement('div') + rootElement.id = RootId + const appendedRootNode = document.body.appendChild(rootElement) + root = createRoot(appendedRootNode) - const parentNode = document.body.appendChild(root) - - render( + root.render( , - parentNode, ) } diff --git a/app/assets/javascripts/UIModels/Application.ts b/app/assets/javascripts/Application/Application.ts similarity index 62% rename from app/assets/javascripts/UIModels/Application.ts rename to app/assets/javascripts/Application/Application.ts index defd3d991..b03157b84 100644 --- a/app/assets/javascripts/UIModels/Application.ts +++ b/app/assets/javascripts/Application/Application.ts @@ -1,12 +1,12 @@ -import { WebCrypto } from '@/Crypto' +import { WebCrypto } from '@/Application/Crypto' import { WebAlertService } from '@/Services/AlertService' import { ArchiveManager } from '@/Services/ArchiveManager' import { AutolockService } from '@/Services/AutolockService' import { DesktopManager } from '@/Services/DesktopManager' import { IOService } from '@/Services/IOService' import { ThemeManager } from '@/Services/ThemeManager' -import { AppState } from '@/UIModels/AppState' -import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice' import { DeinitSource, Platform, @@ -14,14 +14,21 @@ import { NoteGroupController, removeFromArray, IconsController, - Runtime, DesktopDeviceInterface, isDesktopDevice, DeinitMode, + PrefKey, + SNTag, + ContentType, + DecryptedItemInterface, } from '@standardnotes/snjs' +import { makeObservable, observable } from 'mobx' +import { PanelResizedData } from '@/Types/PanelResizedData' +import { WebAppEvent } from './WebAppEvent' +import { isDesktopApplication } from '@/Utils' type WebServices = { - appState: AppState + viewControllerManager: ViewControllerManager desktopService?: DesktopManager autolockService: AutolockService archiveService: ArchiveManager @@ -29,19 +36,14 @@ type WebServices = { io: IOService } -export enum WebAppEvent { - NewUpdateAvailable = 'NewUpdateAvailable', - DesktopWindowGainedFocus = 'DesktopWindowGainedFocus', - DesktopWindowLostFocus = 'DesktopWindowLostFocus', -} - -export type WebEventObserver = (event: WebAppEvent) => void +export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void export class WebApplication extends SNApplication { private webServices!: WebServices private webEventObservers: WebEventObserver[] = [] public noteControllerGroup: NoteGroupController public iconsController: IconsController + private onVisibilityChange: () => void constructor( deviceInterface: WebOrDesktopDevice, @@ -49,7 +51,6 @@ export class WebApplication extends SNApplication { identifier: string, defaultSyncServerHost: string, webSocketUrl: string, - runtime: Runtime, ) { super({ environment: deviceInterface.environment, @@ -61,12 +62,26 @@ export class WebApplication extends SNApplication { defaultHost: defaultSyncServerHost, appVersion: deviceInterface.appVersion, webSocketUrl: webSocketUrl, - runtime, + supportsFileNavigation: window.enabledUnfinishedFeatures || false, + }) + + makeObservable(this, { + dealloced: observable, }) deviceInterface.setApplication(this) this.noteControllerGroup = new NoteGroupController(this) this.iconsController = new IconsController() + + this.onVisibilityChange = () => { + const visible = document.visibilityState === 'visible' + const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur + this.notifyWebEvent(event) + } + + if (!isDesktopApplication()) { + document.addEventListener('visibilitychange', this.onVisibilityChange) + } } override deinit(mode: DeinitMode, source: DeinitSource): void { @@ -91,6 +106,9 @@ export class WebApplication extends SNApplication { ;(this.noteControllerGroup as unknown) = undefined this.webEventObservers.length = 0 + + document.removeEventListener('visibilitychange', this.onVisibilityChange) + ;(this.onVisibilityChange as unknown) = undefined } catch (error) { console.error('Error while deiniting application', error) } @@ -102,19 +120,28 @@ export class WebApplication extends SNApplication { public addWebEventObserver(observer: WebEventObserver): () => void { this.webEventObservers.push(observer) + return () => { removeFromArray(this.webEventObservers, observer) } } - public notifyWebEvent(event: WebAppEvent): void { + public notifyWebEvent(event: WebAppEvent, data?: unknown): void { for (const observer of this.webEventObservers) { - observer(event) + observer(event, data) } } - public getAppState(): AppState { - return this.webServices.appState + publishPanelDidResizeEvent(name: string, collapsed: boolean) { + const data: PanelResizedData = { + panel: name, + collapsed: collapsed, + } + this.notifyWebEvent(WebAppEvent.PanelResized, data) + } + + public getViewControllerManager(): ViewControllerManager { + return this.webServices.viewControllerManager } public getDesktopService(): DesktopManager | undefined { @@ -160,4 +187,23 @@ export class WebApplication extends SNApplication { return this.user.signOut() } + + isGlobalSpellcheckEnabled(): boolean { + return this.getPreference(PrefKey.EditorSpellcheck, true) + } + + public getItemTags(item: DecryptedItemInterface) { + return this.items.itemsReferencingItem(item).filter((ref) => { + return ref.content_type === ContentType.Tag + }) as SNTag[] + } + + public get version(): string { + return (this.deviceInterface as WebOrDesktopDevice).appVersion + } + + async toggleGlobalSpellcheck() { + const currentValue = this.isGlobalSpellcheckEnabled() + return this.setPreference(PrefKey.EditorSpellcheck, !currentValue) + } } diff --git a/app/assets/javascripts/UIModels/ApplicationGroup.ts b/app/assets/javascripts/Application/ApplicationGroup.ts similarity index 82% rename from app/assets/javascripts/UIModels/ApplicationGroup.ts rename to app/assets/javascripts/Application/ApplicationGroup.ts index 88f7a6737..a5fbca58e 100644 --- a/app/assets/javascripts/UIModels/ApplicationGroup.ts +++ b/app/assets/javascripts/Application/ApplicationGroup.ts @@ -3,25 +3,23 @@ import { ApplicationDescriptor, SNApplicationGroup, Platform, - Runtime, InternalEventBus, isDesktopDevice, } from '@standardnotes/snjs' -import { AppState } from '@/UIModels/AppState' +import { ViewControllerManager } from '@/Services/ViewControllerManager' import { getPlatform, isDesktopApplication } from '@/Utils' import { ArchiveManager } from '@/Services/ArchiveManager' import { DesktopManager } from '@/Services/DesktopManager' import { IOService } from '@/Services/IOService' import { AutolockService } from '@/Services/AutolockService' import { ThemeManager } from '@/Services/ThemeManager' -import { WebOrDesktopDevice } from '@/Device/WebOrDesktopDevice' +import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice' const createApplication = ( descriptor: ApplicationDescriptor, deviceInterface: WebOrDesktopDevice, defaultSyncServerHost: string, device: WebOrDesktopDevice, - runtime: Runtime, webSocketUrl: string, ) => { const platform = getPlatform() @@ -32,17 +30,16 @@ const createApplication = ( descriptor.identifier, defaultSyncServerHost, webSocketUrl, - runtime, ) - const appState = new AppState(application, device) + const viewControllerManager = new ViewControllerManager(application, device) const archiveService = new ArchiveManager(application) const io = new IOService(platform === Platform.MacWeb || platform === Platform.MacDesktop) const autolockService = new AutolockService(application, new InternalEventBus()) const themeService = new ThemeManager(application) application.setWebServices({ - appState, + viewControllerManager, archiveService, desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined, io, @@ -54,23 +51,17 @@ const createApplication = ( } export class ApplicationGroup extends SNApplicationGroup { - constructor( - private defaultSyncServerHost: string, - device: WebOrDesktopDevice, - private runtime: Runtime, - private webSocketUrl: string, - ) { + constructor(private defaultSyncServerHost: string, device: WebOrDesktopDevice, private webSocketUrl: string) { super(device) } override async initialize(): Promise { const defaultSyncServerHost = this.defaultSyncServerHost - const runtime = this.runtime const webSocketUrl = this.webSocketUrl await super.initialize({ applicationCreator: async (descriptor, device) => { - return createApplication(descriptor, device, defaultSyncServerHost, device, runtime, webSocketUrl) + return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl) }, }) diff --git a/app/assets/javascripts/Crypto.ts b/app/assets/javascripts/Application/Crypto.ts similarity index 100% rename from app/assets/javascripts/Crypto.ts rename to app/assets/javascripts/Application/Crypto.ts diff --git a/app/assets/javascripts/Database.ts b/app/assets/javascripts/Application/Database.ts similarity index 100% rename from app/assets/javascripts/Database.ts rename to app/assets/javascripts/Application/Database.ts diff --git a/app/assets/javascripts/Device/DesktopSnjsExports.ts b/app/assets/javascripts/Application/Device/DesktopSnjsExports.ts similarity index 100% rename from app/assets/javascripts/Device/DesktopSnjsExports.ts rename to app/assets/javascripts/Application/Device/DesktopSnjsExports.ts diff --git a/app/assets/javascripts/Device/StartApplication.ts b/app/assets/javascripts/Application/Device/StartApplication.ts similarity index 100% rename from app/assets/javascripts/Device/StartApplication.ts rename to app/assets/javascripts/Application/Device/StartApplication.ts diff --git a/app/assets/javascripts/Device/WebDevice.ts b/app/assets/javascripts/Application/Device/WebDevice.ts similarity index 100% rename from app/assets/javascripts/Device/WebDevice.ts rename to app/assets/javascripts/Application/Device/WebDevice.ts diff --git a/app/assets/javascripts/Device/WebOrDesktopDevice.ts b/app/assets/javascripts/Application/Device/WebOrDesktopDevice.ts similarity index 100% rename from app/assets/javascripts/Device/WebOrDesktopDevice.ts rename to app/assets/javascripts/Application/Device/WebOrDesktopDevice.ts diff --git a/app/assets/javascripts/Application/WebAppEvent.ts b/app/assets/javascripts/Application/WebAppEvent.ts new file mode 100644 index 000000000..ecc531981 --- /dev/null +++ b/app/assets/javascripts/Application/WebAppEvent.ts @@ -0,0 +1,9 @@ +export enum WebAppEvent { + NewUpdateAvailable = 'NewUpdateAvailable', + EditorFocused = 'EditorFocused', + BeganBackupDownload = 'BeganBackupDownload', + EndedBackupDownload = 'EndedBackupDownload', + PanelResized = 'PanelResized', + WindowDidFocus = 'WindowDidFocus', + WindowDidBlur = 'WindowDidBlur', +} diff --git a/app/assets/javascripts/Components/Abstract/PureComponent.tsx b/app/assets/javascripts/Components/Abstract/PureComponent.tsx index 341b3737b..cddb98807 100644 --- a/app/assets/javascripts/Components/Abstract/PureComponent.tsx +++ b/app/assets/javascripts/Components/Abstract/PureComponent.tsx @@ -1,16 +1,14 @@ import { ApplicationEvent } from '@standardnotes/snjs' -import { WebApplication } from '@/UIModels/Application' -import { AppState, AppStateEvent } from '@/UIModels/AppState' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' import { autorun, IReactionDisposer, IReactionPublic } from 'mobx' -import { Component } from 'preact' -import { findDOMNode, unmountComponentAtNode } from 'preact/compat' +import { Component } from 'react' 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) { @@ -19,63 +17,34 @@ export abstract class PureComponent

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) diff --git a/app/assets/javascripts/Components/AccountMenu/AccountMenu.tsx b/app/assets/javascripts/Components/AccountMenu/AccountMenu.tsx new file mode 100644 index 000000000..0aec88f39 --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/AccountMenu.tsx @@ -0,0 +1,79 @@ +import { observer } from 'mobx-react-lite' +import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { WebApplication } from '@/Application/Application' +import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react' +import { ApplicationGroup } from '@/Application/ApplicationGroup' +import { AccountMenuPane } from './AccountMenuPane' +import MenuPaneSelector from './MenuPaneSelector' + +type Props = { + viewControllerManager: ViewControllerManager + application: WebApplication + onClickOutside: () => void + mainApplicationGroup: ApplicationGroup +} + +const AccountMenu: FunctionComponent = ({ + application, + viewControllerManager, + onClickOutside, + mainApplicationGroup, +}) => { + const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController + + const closeAccountMenu = useCallback(() => { + viewControllerManager.accountMenuController.closeAccountMenu() + }, [viewControllerManager]) + + const setCurrentPane = useCallback( + (pane: AccountMenuPane) => { + viewControllerManager.accountMenuController.setCurrentPane(pane) + }, + [viewControllerManager], + ) + + const ref = useRef(null) + useCloseOnClickOutside(ref, () => { + onClickOutside() + }) + + const handleKeyDown: 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 ( +

+
+ +
+
+ ) +} + +export default observer(AccountMenu) diff --git a/app/assets/javascripts/Components/AccountMenu/AccountMenuPane.ts b/app/assets/javascripts/Components/AccountMenu/AccountMenuPane.ts new file mode 100644 index 000000000..8e9c673a3 --- /dev/null +++ b/app/assets/javascripts/Components/AccountMenu/AccountMenuPane.ts @@ -0,0 +1,6 @@ +export enum AccountMenuPane { + GeneralMenu, + SignIn, + Register, + ConfirmPassword, +} diff --git a/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx index 7aa70e5dc..474c79170 100644 --- a/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/Components/AccountMenu/AdvancedOptions.tsx @@ -1,184 +1,190 @@ -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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' +import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useState } from 'react' +import Checkbox from '@/Components/Checkbox/Checkbox' +import DecoratedInput from '@/Components/Input/DecoratedInput' +import Icon from '@/Components/Icon/Icon' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager 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 AdvancedOptions: FunctionComponent = ({ + viewControllerManager, + application, + disabled = false, + onPrivateWorkspaceChange, + onStrictSignInChange, + children, +}) => { + const { server, setServer, enableServerOption, setEnableServerOption } = viewControllerManager.accountMenuController + const [showAdvanced, setShowAdvanced] = useState(false) - const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) - const [privateWorkspaceName, setPrivateWorkspaceName] = useState('') - const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('') + const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false) + const [privateWorkspaceName, setPrivateWorkspaceName] = useState('') + const [privateWorkspaceUserphrase, setPrivateWorkspaceUserphrase] = useState('') - const [isStrictSignin, setIsStrictSignin] = useState(false) + const [isStrictSignin, setIsStrictSignin] = useState(false) - useEffect(() => { - const recomputePrivateWorkspaceIdentifier = async () => { - const identifier = await application.computePrivateWorkspaceIdentifier( - privateWorkspaceName, - privateWorkspaceUserphrase, - ) + 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 + if (!identifier) { + if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) { + application.alertService.alert('Unable to compute private workspace name.').catch(console.error) } - onPrivateWorkspaceChange?.(true, identifier) + return } + onPrivateWorkspaceChange?.(true, identifier) + } - if (privateWorkspaceName && privateWorkspaceUserphrase) { - recomputePrivateWorkspaceIdentifier().catch(console.error) + 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: ChangeEventHandler = useCallback( + (e) => { + if (e.target instanceof HTMLInputElement) { + setEnableServerOption(e.target.checked) } - }, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange]) + }, + [setEnableServerOption], + ) - useEffect(() => { - onPrivateWorkspaceChange?.(isPrivateWorkspace) - }, [isPrivateWorkspace, onPrivateWorkspaceChange]) + const handleSyncServerChange = useCallback( + (server: string) => { + setServer(server) + application.setCustomHost(server).catch(console.error) + }, + [application, setServer], + ) - const handleIsPrivateWorkspaceChange = useCallback(() => { - setIsPrivateWorkspace(!isPrivateWorkspace) - }, [isPrivateWorkspace]) + const handleStrictSigninChange = useCallback(() => { + const newValue = !isStrictSignin + setIsStrictSignin(newValue) + onStrictSignInChange?.(newValue) + }, [isStrictSignin, onStrictSignInChange]) - const handlePrivateWorkspaceNameChange = useCallback((name: string) => { - setPrivateWorkspaceName(name) - }, []) + const toggleShowAdvanced = useCallback(() => { + setShowAdvanced(!showAdvanced) + }, [showAdvanced]) - const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => { - setPrivateWorkspaceUserphrase(userphrase) - }, []) + return ( + <> + + {showAdvanced ? ( +
+ {children} - 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 && ( + )} - {isPrivateWorkspace && ( - <> - ]} - type="text" - placeholder="Userphrase" - value={privateWorkspaceUserphrase} - onChange={handlePrivateWorkspaceUserphraseChange} - disabled={disabled} - /> - ]} - type="text" - placeholder="Name" - value={privateWorkspaceName} - onChange={handlePrivateWorkspaceNameChange} - disabled={disabled} - /> - - )} + + ]} + placeholder="https://api.standardnotes.com" + value={server} + onChange={handleSyncServerChange} + disabled={!enableServerOption && !disabled} + /> +
+ ) : null} + + ) +} - {onStrictSignInChange && ( -
- - - - -
- )} - - - ]} - placeholder="https://api.standardnotes.com" - value={server} - onChange={handleSyncServerChange} - disabled={!enableServerOption && !disabled} - /> -
- ) : null} - - ) - }, -) +export default observer(AdvancedOptions) diff --git a/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx index 67774a613..5eb71f147 100644 --- a/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx +++ b/app/assets/javascripts/Components/AccountMenu/ConfirmPassword.tsx @@ -1,158 +1,163 @@ -import { STRING_NON_MATCHING_PASSWORDS } from '@/Strings' -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' +import { STRING_NON_MATCHING_PASSWORDS } from '@/Constants/Strings' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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' +import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react' +import { AccountMenuPane } from './AccountMenuPane' +import Button from '@/Components/Button/Button' +import Checkbox from '@/Components/Checkbox/Checkbox' +import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput' +import Icon from '@/Components/Icon/Icon' +import IconButton from '@/Components/Button/IconButton' type Props = { - appState: AppState + viewControllerManager: ViewControllerManager 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 ConfirmPassword: FunctionComponent = ({ + application, + viewControllerManager, + setMenuPane, + email, + password, +}) => { + const { notesAndTagsCount } = viewControllerManager.accountMenuController + 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() - }, []) + useEffect(() => { + passwordInputRef.current?.focus() + }, []) - const handlePasswordChange = useCallback((text: string) => { - setConfirmPassword(text) - }, []) + const handlePasswordChange = useCallback((text: string) => { + setConfirmPassword(text) + }, []) - const handleEphemeralChange = useCallback(() => { - setIsEphemeral(!isEphemeral) - }, [isEphemeral]) + const handleEphemeralChange = useCallback(() => { + setIsEphemeral(!isEphemeral) + }, [isEphemeral]) - const handleShouldMergeChange = useCallback(() => { - setShouldMergeLocal(!shouldMergeLocal) - }, [shouldMergeLocal]) + const handleShouldMergeChange = useCallback(() => { + setShouldMergeLocal(!shouldMergeLocal) + }, [shouldMergeLocal]) - const handleConfirmFormSubmit = useCallback( - (e: Event) => { - e.preventDefault() + const handleConfirmFormSubmit = useCallback( + (e) => { + e.preventDefault() - if (!password) { - passwordInputRef.current?.focus() - return - } + 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], - ) + if (password === confirmPassword) { + setIsRegistering(true) + application + .register(email, password, isEphemeral, shouldMergeLocal) + .then((res) => { + if (res.error) { + throw new Error(res.error.message) + } + viewControllerManager.accountMenuController.closeAccountMenu() + viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu) + }) + .catch((err) => { + console.error(err) + setError(err.message) + }) + .finally(() => { + setIsRegistering(false) + }) + } else { + setError(STRING_NON_MATCHING_PASSWORDS) + setConfirmPassword('') + passwordInputRef.current?.focus() + } + }, + [viewControllerManager, application, confirmPassword, email, isEphemeral, password, shouldMergeLocal], + ) - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (error.length) { - setError('') - } - if (e.key === 'Enter') { - handleConfirmFormSubmit(e) - } - }, - [handleConfirmFormSubmit, error], - ) + const handleKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if (error.length) { + setError('') + } + if (e.key === 'Enter') { + handleConfirmFormSubmit(e) + } + }, + [handleConfirmFormSubmit, error], + ) - const handleGoBack = useCallback(() => { - setMenuPane(AccountMenuPane.Register) - }, [setMenuPane]) + 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} - - + )} ) } + +export default WorkspaceMenuItem diff --git a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx index 9b49e1ebd..240152321 100644 --- a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx +++ b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu.tsx @@ -1,89 +1,95 @@ -import { ApplicationGroup } from '@/UIModels/ApplicationGroup' -import { AppState } from '@/UIModels/AppState' +import { ApplicationGroup } from '@/Application/ApplicationGroup' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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' +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import Menu from '@/Components/Menu/Menu' +import MenuItem from '@/Components/Menu/MenuItem' +import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator' +import { MenuItemType } from '@/Components/Menu/MenuItemType' +import WorkspaceMenuItem from './WorkspaceMenuItem' type Props = { mainApplicationGroup: ApplicationGroup - appState: AppState + viewControllerManager: ViewControllerManager isOpen: boolean hideWorkspaceOptions?: boolean } -export const WorkspaceSwitcherMenu: FunctionComponent = observer( - ({ mainApplicationGroup, appState, isOpen, hideWorkspaceOptions = false }: Props) => { - const [applicationDescriptors, setApplicationDescriptors] = useState([]) +const WorkspaceSwitcherMenu: FunctionComponent = ({ + mainApplicationGroup, + viewControllerManager, + isOpen, + hideWorkspaceOptions = false, +}: Props) => { + const [applicationDescriptors, setApplicationDescriptors] = useState([]) - useEffect(() => { - const applicationDescriptors = mainApplicationGroup.getDescriptors() - setApplicationDescriptors(applicationDescriptors) + useEffect(() => { + const applicationDescriptors = mainApplicationGroup.getDescriptors() + setApplicationDescriptors(applicationDescriptors) - const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => { - if (event === ApplicationGroupEvent.DescriptorsDataChanged) { - const applicationDescriptors = mainApplicationGroup.getDescriptors() - setApplicationDescriptors(applicationDescriptors) - } - }) - - return () => { - removeAppGroupObserver() + const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => { + if (event === ApplicationGroupEvent.DescriptorsDataChanged) { + const applicationDescriptors = mainApplicationGroup.getDescriptors() + setApplicationDescriptors(applicationDescriptors) } - }, [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]) + return () => { + removeAppGroupObserver() + } + }, [mainApplicationGroup]) - 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 - - )} - + const signoutAll = useCallback(async () => { + const confirmed = await viewControllerManager.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, viewControllerManager]) + + const destroyWorkspace = useCallback(() => { + viewControllerManager.accountMenuController.setSigningOut(true) + }, [viewControllerManager]) + + 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 + + )} + + ) +} + +export default observer(WorkspaceSwitcherMenu) diff --git a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx index a8881180c..014e50b3d 100644 --- a/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx +++ b/app/assets/javascripts/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherOption.tsx @@ -1,19 +1,18 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' -import { ApplicationGroup } from '@/UIModels/ApplicationGroup' -import { AppState } from '@/UIModels/AppState' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' +import { ApplicationGroup } from '@/Application/ApplicationGroup' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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' +import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu' type Props = { mainApplicationGroup: ApplicationGroup - appState: AppState + viewControllerManager: ViewControllerManager } -export const WorkspaceSwitcherOption: FunctionComponent = observer(({ mainApplicationGroup, appState }) => { +const WorkspaceSwitcherOption: FunctionComponent = ({ mainApplicationGroup, viewControllerManager }) => { const buttonRef = useRef(null) const menuRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -59,9 +58,15 @@ export const WorkspaceSwitcherOption: FunctionComponent = observer(({ mai {isOpen && (
- +
)} ) -}) +} + +export default observer(WorkspaceSwitcherOption) diff --git a/app/assets/javascripts/Components/AccountMenu/index.tsx b/app/assets/javascripts/Components/AccountMenu/index.tsx deleted file mode 100644 index 982231644..000000000 --- a/app/assets/javascripts/Components/AccountMenu/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -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/ApplicationGroupView.tsx similarity index 74% rename from app/assets/javascripts/Components/ApplicationGroupView/index.tsx rename to app/assets/javascripts/Components/ApplicationGroupView/ApplicationGroupView.tsx index f486a924c..83b4a6784 100644 --- a/app/assets/javascripts/Components/ApplicationGroupView/index.tsx +++ b/app/assets/javascripts/Components/ApplicationGroupView/ApplicationGroupView.tsx @@ -1,12 +1,12 @@ -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 { ApplicationGroup } from '@/Application/ApplicationGroup' +import { WebApplication } from '@/Application/Application' +import { Component } from 'react' +import ApplicationView from '@/Components/ApplicationView/ApplicationView' +import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice' +import { ApplicationGroupEvent, ApplicationGroupEventData, DeinitSource } from '@standardnotes/snjs' import { DialogContent, DialogOverlay } from '@reach/dialog' import { isDesktopApplication } from '@/Utils' +import DeallocateHandler from '../DeallocateHandler/DeallocateHandler' type Props = { server: string @@ -23,7 +23,7 @@ type State = { deviceDestroyed?: boolean } -export class ApplicationGroupView extends Component { +class ApplicationGroupView extends Component { applicationObserverRemover?: () => void private group?: ApplicationGroup private application?: WebApplication @@ -39,12 +39,7 @@ export class ApplicationGroupView extends Component { return } - this.group = new ApplicationGroup( - props.server, - props.device, - props.enableUnfinished ? Runtime.Dev : Runtime.Prod, - props.websocketUrl, - ) + this.group = new ApplicationGroup(props.server, props.device, props.websocketUrl) window.mainApplicationGroup = this.group @@ -79,17 +74,15 @@ export class ApplicationGroupView extends Component { const onDestroy = this.props.onDestroy - const node = findDOMNode(this) as Element - unmountComponentAtNode(node) - onDestroy() } - render() { + override render() { const renderDialog = (message: string) => { return ( { return (
- + + +
) } } + +export default ApplicationGroupView diff --git a/app/assets/javascripts/Components/ApplicationView/index.tsx b/app/assets/javascripts/Components/ApplicationView/ApplicationView.tsx similarity index 56% rename from app/assets/javascripts/Components/ApplicationView/index.tsx rename to app/assets/javascripts/Components/ApplicationView/ApplicationView.tsx index 80f6d1def..20d441181 100644 --- a/app/assets/javascripts/Components/ApplicationView/index.tsx +++ b/app/assets/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -1,55 +1,45 @@ -import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { ApplicationGroup } from '@/Application/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 { ApplicationEvent, Challenge, removeFromArray } from '@standardnotes/snjs' +import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/Constants/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 { WebApplication } from '@/Application/Application' +import { WebAppEvent } from '@/Application/WebAppEvent' +import Navigation from '@/Components/Navigation/Navigation' +import NoteGroupView from '@/Components/NoteGroupView/NoteGroupView' +import Footer from '@/Components/Footer/Footer' +import SessionsModal from '@/Components/SessionsModal/SessionsModal' +import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrapper' +import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal' +import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu' +import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper' +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' +import RevisionHistoryModalWrapper from '@/Components/RevisionHistoryModal/RevisionHistoryModalWrapper' +import PremiumModalProvider from '@/Hooks/usePremiumModal' +import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal' +import TagsContextMenuWrapper 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' +import FilePreviewModalWrapper from '@/Components/Files/FilePreviewModal' +import ContentListView from '@/Components/ContentListView/ContentListView' +import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu' +import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper' +import { PanelResizedData } from '@/Types/PanelResizedData' type Props = { application: WebApplication mainApplicationGroup: ApplicationGroup } -export const ApplicationView: FunctionComponent = ({ application, mainApplicationGroup }) => { +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() + const viewControllerManager = application.getViewControllerManager() useEffect(() => { - setDealloced(application.dealloced) - }, [application.dealloced]) - - useEffect(() => { - if (dealloced) { - return - } - const desktopService = application.getDesktopService() if (desktopService) { @@ -69,7 +59,7 @@ export const ApplicationView: FunctionComponent = ({ application, mainApp }) .catch(console.error) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [application, dealloced]) + }, [application]) const removeChallenge = useCallback( (challenge: Challenge) => { @@ -80,29 +70,9 @@ export const ApplicationView: FunctionComponent = ({ application, mainApp [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]) + }, [application]) const handleDemoSignInFromParams = useCallback(() => { const token = getWindowUrlParams().get('demo-token') @@ -150,8 +120,8 @@ export const ApplicationView: FunctionComponent = ({ application, mainApp }, [application, onAppLaunch, onAppStart]) useEffect(() => { - const removeObserver = application.getAppState().addObserver(async (eventName, data) => { - if (eventName === AppStateEvent.PanelResized) { + const removeObserver = application.addWebEventObserver(async (eventName, data) => { + if (eventName === WebAppEvent.PanelResized) { const { panel, collapsed } = data as PanelResizedData let appClass = '' if (panel === PANEL_NAME_NOTES && collapsed) { @@ -161,7 +131,7 @@ export const ApplicationView: FunctionComponent = ({ application, mainApp appClass += ' collapsed-navigation' } setAppClass(appClass) - } else if (eventName === AppStateEvent.WindowDidFocus) { + } else if (eventName === WebAppEvent.WindowDidFocus) { if (!(await application.isLocked())) { application.sync.sync().catch(console.error) } @@ -182,11 +152,11 @@ export const ApplicationView: FunctionComponent = ({ application, mainApp <> {challenges.map((challenge) => { return ( -
+
= ({ application, mainApp })} ) - }, [appState, challenges, mainApplicationGroup, removeChallenge, application]) - - if (dealloced || isStateDealloced(appState)) { - return null - } + }, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application]) if (!renderAppContents) { return renderChallenges() } return ( - +
- +
<>
- - - + + + {renderChallenges()} <> - - - + + + + - + +
) } + +export default ApplicationView diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx index 988dd2b6a..5fdb5e54f 100644 --- a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx @@ -1,154 +1,135 @@ -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' -import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/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 { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import Icon from '@/Components/Icon/Icon' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { ChallengeReason, CollectionSort, ContentType, FileItem, SNNote } from '@standardnotes/snjs' +import { ChallengeReason, 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 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 + viewControllerManager: ViewControllerManager onClickPreprocessing?: () => Promise } -export const AttachedFilesButton: FunctionComponent = observer( - ({ application, appState, onClickPreprocessing }: Props) => { - if (isStateDealloced(appState)) { - return null +const AttachedFilesButton: FunctionComponent = ({ + application, + viewControllerManager, + onClickPreprocessing, +}: Props) => { + const premiumModal = usePremiumModal() + const note: SNNote | undefined = viewControllerManager.notesController.firstSelectedNote + + 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 (viewControllerManager.filePreviewModalController.isOpen) { + keepMenuOpen(true) + } else { + keepMenuOpen(false) } + }, [viewControllerManager.filePreviewModalController.isOpen, keepMenuOpen]) - const premiumModal = usePremiumModal() - const note: SNNote | undefined = Object.values(appState.notes.selectedNotes)[0] + const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles) + const [allFiles, setAllFiles] = useState([]) + const [attachedFiles, setAttachedFiles] = useState([]) + const attachedFilesCount = attachedFiles.length - const [open, setOpen] = useState(false) - const [position, setPosition] = useState({ - top: 0, - right: 0, + useEffect(() => { + const unregisterFileStream = application.streamItems(ContentType.File, () => { + setAllFiles(application.items.getDisplayableFiles()) + if (note) { + setAttachedFiles(application.items.getFilesForNote(note)) + } }) - 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) + 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) } - }, [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)) - } + setPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, }) - return () => { - unregisterFileStream() + const newOpenState = !open + if (newOpenState && onClickPreprocessing) { + await onClickPreprocessing() } - }, [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 + setOpen(newOpenState) + } + }, [onClickPreprocessing, open]) - if (footerHeightInPx) { - setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) - } + const prospectivelyShowFilesPremiumModal = useCallback(() => { + if (!viewControllerManager.featuresController.hasFiles) { + premiumModal.activate('Files') + } + }, [viewControllerManager.featuresController.hasFiles, premiumModal]) - setPosition({ - top: rect.bottom, - right: document.body.clientWidth - rect.right, - }) + const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => { + prospectivelyShowFilesPremiumModal() - const newOpenState = !open - if (newOpenState && onClickPreprocessing) { - await onClickPreprocessing() - } + await toggleAttachedFilesMenu() + }, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal]) - 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', + 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}"...`, }) - 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) - } + 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 downloadFile = async (file: FileItem) => { + viewControllerManager.filesController.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) => { + const attachFileToNote = useCallback( + async (file: FileItem) => { if (!note) { addToast({ type: ToastType.Error, @@ -156,268 +137,283 @@ export const AttachedFilesButton: FunctionComponent = observer( }) return } - await application.items.disassociateFileWithNote(file, note) + + 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.authorizeProtectedActionForItems([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() } - const toggleFileProtection = async (file: FileItem) => { - let result: FileItem | undefined - if (file.protected) { + 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) - result = await application.mutator.unprotectFile(file) - keepMenuOpen(false) - buttonRef.current?.focus() - } else { - result = await application.mutator.protectFile(file) + const otherFiles = currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles + viewControllerManager.filePreviewModalController.activate( + file, + otherFiles.filter((file) => !file.protected), + ) + break } - 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 + if ( + action.type !== PopoverFileItemActionType.DownloadFile && + action.type !== PopoverFileItemActionType.PreviewFile + ) { + application.sync.sync().catch(console.error) } - const renameFile = async (file: FileItem, fileName: string) => { - await application.items.renameFile(file, fileName) - } + return true + } - 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 - } + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + const dragCounter = useRef(0) + const handleDrag = useCallback( + (event: DragEvent) => { + if (isHandlingFileDrag(event, application)) { event.preventDefault() event.stopPropagation() + } + }, + [application], + ) - switch ((event.target as HTMLElement).id) { - case PopoverTabs.AllFiles: - setCurrentTab(PopoverTabs.AllFiles) - break - case PopoverTabs.AttachedFiles: - setCurrentTab(PopoverTabs.AttachedFiles) - break + 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], + ) - dragCounter.current = dragCounter.current + 1 + const handleDragOut = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } - if (event.dataTransfer?.items.length) { - setIsDraggingFiles(true) - if (!open) { - toggleAttachedFilesMenu().catch(console.error) + 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 (!viewControllerManager.featuresController.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 } - } - }, - [open, toggleAttachedFilesMenu, application], - ) - const handleDragOut = useCallback( - (event: DragEvent) => { - if (!isHandlingFileDrag(event, application)) { - return - } + const uploadedFiles = await viewControllerManager.filesController.uploadNewFile(fileOrHandle) - event.preventDefault() - event.stopPropagation() + if (!uploadedFiles) { + return + } - dragCounter.current = dragCounter.current - 1 + if (currentTab === PopoverTabs.AttachedFiles) { + uploadedFiles.forEach((file) => { + attachFileToNote(file).catch(console.error) + }) + } + }) - 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) + event.dataTransfer.clearData() + dragCounter.current = 0 } - }, [handleDragIn, handleDrop, handleDrag, handleDragOut]) + }, + [ + viewControllerManager.filesController, + viewControllerManager.featuresController.hasFiles, + attachFileToNote, + currentTab, + application, + prospectivelyShowFilesPremiumModal, + ], + ) - 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 && ( - - )} - - -
- ) - }, -) + 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 && ( + + )} + + +
+ ) +} + +export default observer(AttachedFilesButton) diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx index 6c978617e..12197f05a 100644 --- a/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/AttachedFilesPopover.tsx @@ -1,173 +1,172 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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 { Dispatch, FunctionComponent, SetStateAction, useRef, useState } from 'react' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import PopoverFileItem from './PopoverFileItem' import { PopoverFileItemAction, PopoverFileItemActionType } from './PopoverFileItemAction' import { PopoverTabs } from './PopoverTabs' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager allFiles: FileItem[] attachedFiles: FileItem[] closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void currentTab: PopoverTabs handleFileAction: (action: PopoverFileItemAction) => Promise isDraggingFiles: boolean - setCurrentTab: StateUpdater + setCurrentTab: Dispatch> } -export const AttachedFilesPopover: FunctionComponent = observer( - ({ - application, - appState, - allFiles, - attachedFiles, - closeOnBlur, - currentTab, - handleFileAction, - isDraggingFiles, - setCurrentTab, - }) => { - const [searchQuery, setSearchQuery] = useState('') - const searchInputRef = useRef(null) +const AttachedFilesPopover: FunctionComponent = ({ + application, + viewControllerManager, + allFiles, + attachedFiles, + closeOnBlur, + currentTab, + handleFileAction, + isDraggingFiles, + setCurrentTab, +}) => { + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) - const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles + const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles - const filteredList = - searchQuery.length > 0 - ? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1) - : filesList + 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) - }) - } + const handleAttachFilesClick = async () => { + const uploadedFiles = await viewControllerManager.filesController.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) + 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
+
+ ) : null} + {filteredList.length > 0 ? ( + filteredList.map((file: FileItem) => { + return ( + + ) + }) + ) : ( +
+
+
- )} -
- {filteredList.length > 0 && ( - +
+ {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 && ( + + )} +
+ ) +} + +export default observer(AttachedFilesPopover) diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx index 1516b4f3a..5715ce576 100644 --- a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItem.tsx @@ -1,28 +1,15 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/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' +import { FileItem } from '@standardnotes/snjs' +import { FormEventHandler, FunctionComponent, KeyboardEventHandler, useEffect, useRef, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import { PopoverFileItemActionType } from './PopoverFileItemAction' +import PopoverFileSubmenu from './PopoverFileSubmenu' +import { getFileIconComponent } from './getFileIconComponent' +import { PopoverFileItemProps } from './PopoverFileItemProps' -export const getFileIconComponent = (iconType: string, className: string) => { - const IconComponent = ICONS[iconType as keyof typeof ICONS] - - return -} - -export type PopoverFileItemProps = { - file: FileItem - isAttachedToNote: boolean - handleFileAction: (action: PopoverFileItemAction) => Promise - getIconType(type: string): IconType - closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void -} - -export const PopoverFileItem: FunctionComponent = ({ +const PopoverFileItem: FunctionComponent = ({ file, isAttachedToNote, handleFileAction, @@ -51,11 +38,11 @@ export const PopoverFileItem: FunctionComponent = ({ setIsRenamingFile(false) } - const handleFileNameInput = (event: Event) => { + const handleFileNameInput: FormEventHandler = (event) => { setFileName((event.target as HTMLInputElement).value) } - const handleFileNameInputKeyDown = (event: KeyboardEvent) => { + const handleFileNameInputKeyDown: KeyboardEventHandler = (event) => { if (event.key === KeyboardKey.Enter) { itemRef.current?.focus() } @@ -99,7 +86,7 @@ export const PopoverFileItem: FunctionComponent = ({ )}
)} -
+
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
@@ -115,3 +102,5 @@ export const PopoverFileItem: FunctionComponent = ({
) } + +export default PopoverFileItem diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemProps.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemProps.tsx new file mode 100644 index 000000000..35cc40679 --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileItemProps.tsx @@ -0,0 +1,10 @@ +import { IconType, FileItem } from '@standardnotes/snjs' +import { PopoverFileItemAction } from './PopoverFileItemAction' + +export type PopoverFileItemProps = { + file: FileItem + isAttachedToNote: boolean + handleFileAction: (action: PopoverFileItemAction) => Promise + getIconType(type: string): IconType + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void +} diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx index 649ab903c..29d055f2b 100644 --- a/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx +++ b/app/assets/javascripts/Components/AttachedFilesPopover/PopoverFileSubmenu.tsx @@ -1,20 +1,19 @@ -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/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 { Dispatch, FunctionComponent, SetStateAction, useCallback, useEffect, useRef, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import Switch from '@/Components/Switch/Switch' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { PopoverFileItemProps } from './PopoverFileItem' +import { PopoverFileItemProps } from './PopoverFileItemProps' import { PopoverFileItemActionType } from './PopoverFileItemAction' type Props = Omit & { - setIsRenamingFile: StateUpdater + setIsRenamingFile: Dispatch> previewHandler: () => void } -export const PopoverFileSubmenu: FunctionComponent = ({ +const PopoverFileSubmenu: FunctionComponent = ({ file, isAttachedToNote, handleFileAction, @@ -197,3 +196,5 @@ export const PopoverFileSubmenu: FunctionComponent = ({
) } + +export default PopoverFileSubmenu diff --git a/app/assets/javascripts/Components/AttachedFilesPopover/getFileIconComponent.tsx b/app/assets/javascripts/Components/AttachedFilesPopover/getFileIconComponent.tsx new file mode 100644 index 000000000..bd1e0e867 --- /dev/null +++ b/app/assets/javascripts/Components/AttachedFilesPopover/getFileIconComponent.tsx @@ -0,0 +1,7 @@ +import { ICONS } from '@/Components/Icon/Icon' + +export const getFileIconComponent = (iconType: string, className: string) => { + const IconComponent = ICONS[iconType as keyof typeof ICONS] + + return +} diff --git a/app/assets/javascripts/Components/Bubble/index.tsx b/app/assets/javascripts/Components/Bubble/Bubble.tsx similarity index 79% rename from app/assets/javascripts/Components/Bubble/index.tsx rename to app/assets/javascripts/Components/Bubble/Bubble.tsx index 5a2146823..99c8f7bd8 100644 --- a/app/assets/javascripts/Components/Bubble/index.tsx +++ b/app/assets/javascripts/Components/Bubble/Bubble.tsx @@ -1,4 +1,6 @@ -interface BubbleProperties { +import { FunctionComponent } from 'react' + +type Props = { label: string selected: boolean onSelect: () => void @@ -10,7 +12,7 @@ const styles = { selected: 'border-info bg-info color-neutral-contrast', } -const Bubble = ({ label, selected, onSelect }: BubbleProperties) => ( +const Bubble: FunctionComponent = ({ label, selected, onSelect }) => ( & { - children?: ComponentChildren +interface ButtonProps extends ComponentPropsWithoutRef<'button'> { + children?: ReactNode className?: string variant?: ButtonVariant dangerStyle?: boolean label?: string } -export const Button: FunctionComponent = forwardRef( +const Button = forwardRef( ( { variant = 'normal', @@ -66,3 +64,5 @@ export const Button: FunctionComponent = forwardRef( ) }, ) + +export default Button diff --git a/app/assets/javascripts/Components/Button/IconButton.tsx b/app/assets/javascripts/Components/Button/IconButton.tsx index 62180d5bb..726c5564c 100644 --- a/app/assets/javascripts/Components/Button/IconButton.tsx +++ b/app/assets/javascripts/Components/Button/IconButton.tsx @@ -1,34 +1,18 @@ -import { FunctionComponent } from 'preact' -import { Icon } from '@/Components/Icon' +import { FunctionComponent, MouseEventHandler } from 'react' +import Icon from '@/Components/Icon/Icon' import { IconType } from '@standardnotes/snjs' -interface Props { - /** - * onClick - preventDefault is handled within the component - */ +type Props = { onClick: () => void - className?: string - icon: IconType - iconClassName?: string - - /** - * Button tooltip - */ title: string - focusable: boolean - disabled?: boolean } -/** - * IconButton component with an icon - * preventDefault is already handled within the component - */ -export const IconButton: FunctionComponent = ({ +const IconButton: FunctionComponent = ({ onClick, className = '', icon, @@ -37,7 +21,7 @@ export const IconButton: FunctionComponent = ({ iconClassName = '', disabled = false, }) => { - const click = (e: MouseEvent) => { + const click: MouseEventHandler = (e) => { e.preventDefault() onClick() } @@ -55,3 +39,5 @@ export const IconButton: FunctionComponent = ({ ) } + +export default IconButton diff --git a/app/assets/javascripts/Components/Button/RoundIconButton.tsx b/app/assets/javascripts/Components/Button/RoundIconButton.tsx index 55882f655..91ca24ca7 100644 --- a/app/assets/javascripts/Components/Button/RoundIconButton.tsx +++ b/app/assets/javascripts/Components/Button/RoundIconButton.tsx @@ -1,28 +1,18 @@ -import { FunctionComponent } from 'preact' -import { Icon } from '@/Components/Icon' +import { FunctionComponent, MouseEventHandler } from 'react' +import Icon from '@/Components/Icon/Icon' import { IconType } from '@standardnotes/snjs' type ButtonType = 'normal' | 'primary' -interface Props { - /** - * onClick - preventDefault is handled within the component - */ +type Props = { 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) => { +const RoundIconButton: FunctionComponent = ({ onClick, type, className, icon: iconType }) => { + const click: MouseEventHandler = (e) => { e.preventDefault() onClick() } @@ -33,3 +23,5 @@ export const RoundIconButton: FunctionComponent = ({ onClick, type, class ) } + +export default RoundIconButton diff --git a/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx b/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx index 93c4586f4..7622a159a 100644 --- a/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -1,4 +1,4 @@ -import { WebApplication } from '@/UIModels/Application' +import { WebApplication } from '@/Application/Application' import { DialogContent, DialogOverlay } from '@reach/dialog' import { ButtonType, @@ -9,26 +9,18 @@ import { 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 +import { FunctionComponent, useCallback, useEffect, useState } from 'react' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import ChallengeModalPrompt from './ChallengePrompt' +import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher' +import { ApplicationGroup } from '@/Application/ApplicationGroup' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { ChallengeModalValues } from './ChallengeModalValues' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager mainApplicationGroup: ApplicationGroup challenge: Challenge onDismiss?: (challenge: Challenge) => void @@ -50,9 +42,9 @@ const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[] return undefined } -export const ChallengeModal: FunctionComponent = ({ +const ChallengeModal: FunctionComponent = ({ application, - appState, + viewControllerManager, mainApplicationGroup, challenge, onDismiss, @@ -191,6 +183,7 @@ export const ChallengeModal: FunctionComponent = ({ key={challenge.id} > = ({ )} {shouldShowWorkspaceSwitcher && ( - + )} ) } + +export default ChallengeModal diff --git a/app/assets/javascripts/Components/ChallengeModal/ChallengeModalValues.tsx b/app/assets/javascripts/Components/ChallengeModal/ChallengeModalValues.tsx new file mode 100644 index 000000000..458c6007e --- /dev/null +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengeModalValues.tsx @@ -0,0 +1,4 @@ +import { ChallengePrompt } from '@standardnotes/snjs' +import { InputValue } from './InputValue' + +export type ChallengeModalValues = Record diff --git a/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx b/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx index 4f2159d1a..c1d07b476 100644 --- a/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx +++ b/app/assets/javascripts/Components/ChallengeModal/ChallengePrompt.tsx @@ -1,9 +1,8 @@ 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' +import { FunctionComponent, useEffect, useRef } from 'react' +import DecoratedInput from '@/Components/Input/DecoratedInput' +import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput' +import { ChallengeModalValues } from './ChallengeModalValues' type Props = { prompt: ChallengePrompt @@ -13,7 +12,7 @@ type Props = { isInvalid: boolean } -export const ChallengeModalPrompt: FunctionComponent = ({ prompt, values, index, onValueChange, isInvalid }) => { +const ChallengeModalPrompt: FunctionComponent = ({ prompt, values, index, onValueChange, isInvalid }) => { const inputRef = useRef(null) useEffect(() => { @@ -33,13 +32,14 @@ export const ChallengeModalPrompt: FunctionComponent = ({ prompt, values, {prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
Allow protected access for
-
+
{ProtectionSessionDurations.map((option) => { const selected = option.valueInSeconds === values[prompt.id].value return (
) } + +export default ChallengeModalPrompt diff --git a/app/assets/javascripts/Components/ChallengeModal/InputValue.tsx b/app/assets/javascripts/Components/ChallengeModal/InputValue.tsx new file mode 100644 index 000000000..fa327652b --- /dev/null +++ b/app/assets/javascripts/Components/ChallengeModal/InputValue.tsx @@ -0,0 +1,7 @@ +import { ChallengePrompt } from '@standardnotes/snjs' + +export type InputValue = { + prompt: ChallengePrompt + value: string | number | boolean + invalid: boolean +} diff --git a/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx b/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx index ece6735ab..a534cf018 100644 --- a/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx +++ b/app/assets/javascripts/Components/ChallengeModal/LockscreenWorkspaceSwitcher.tsx @@ -1,19 +1,18 @@ -import { ApplicationGroup } from '@/UIModels/ApplicationGroup' -import { AppState } from '@/UIModels/AppState' +import { ApplicationGroup } from '@/Application/ApplicationGroup' +import { ViewControllerManager } from '@/Services/ViewControllerManager' 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 { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' type Props = { mainApplicationGroup: ApplicationGroup - appState: AppState + viewControllerManager: ViewControllerManager } -export const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainApplicationGroup, appState }) => { +const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainApplicationGroup, viewControllerManager }) => { const buttonRef = useRef(null) const menuRef = useRef(null) const containerRef = useRef(null) @@ -56,7 +55,7 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainAppl
@@ -65,3 +64,5 @@ export const LockscreenWorkspaceSwitcher: FunctionComponent = ({ mainAppl
) } + +export default LockscreenWorkspaceSwitcher diff --git a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index bc77f8d65..e94c08124 100644 --- a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -1,114 +1,112 @@ -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' -import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/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 { FunctionComponent, useRef, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import ChangeEditorMenu from './ChangeEditorMenu' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { isStateDealloced } from '@/UIModels/AppState/AbstractState' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager onClickPreprocessing?: () => Promise } -export const ChangeEditorButton: FunctionComponent = observer( - ({ application, appState, onClickPreprocessing }: Props) => { - if (isStateDealloced(appState)) { - return null - } +const ChangeEditorButton: FunctionComponent = ({ + application, + viewControllerManager, + onClickPreprocessing, +}: Props) => { + const note = viewControllerManager.notesController.firstSelectedNote + 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 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 - 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) - }) + if (footerHeightInPx) { + setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER) } - } - return ( -
- - { - if (event.key === 'Escape') { + 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) - } - }} - 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) - }} - /> - )} - - -
- ) - }, -) + }} + /> + )} + +
+
+ ) +} + +export default observer(ChangeEditorButton) diff --git a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 889a920cc..2bc3d8a72 100644 --- a/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/app/assets/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -1,14 +1,10 @@ -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 Icon from '@/Components/Icon/Icon' +import Menu from '@/Components/Menu/Menu' +import MenuItem from '@/Components/Menu/MenuItem' +import { MenuItemType } from '@/Components/Menu/MenuItemType' import { usePremiumModal } from '@/Hooks/usePremiumModal' -import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Strings' -import { WebApplication } from '@/UIModels/Application' +import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' +import { WebApplication } from '@/Application/Application' import { ComponentArea, ItemMutator, @@ -18,23 +14,28 @@ import { 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 { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react' +import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' +import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' import { createEditorMenuGroups } from './createEditorMenuGroups' -import { PLAIN_EDITOR_NAME } from '@/Constants' +import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { + transactionForAssociateComponentWithCurrentNote, + transactionForDisassociateComponentWithCurrentNote, +} from '../NoteView/TransactionFunctions' +import { reloadFont } from '../NoteView/FontFunctions' type ChangeEditorMenuProps = { application: WebApplication closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void closeMenu: () => void isVisible: boolean - note: SNNote + note: SNNote | undefined } const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-') -export const ChangeEditorMenu: FunctionComponent = ({ +const ChangeEditorMenu: FunctionComponent = ({ application, closeOnBlur, closeMenu, @@ -43,7 +44,7 @@ export const ChangeEditorMenu: FunctionComponent = ({ }) => { const [editors] = useState(() => application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => { - return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1 + return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1 }), ) const [groups, setGroups] = useState([]) @@ -75,97 +76,103 @@ export const ChangeEditorMenu: FunctionComponent = ({ [currentEditor], ) - const selectComponent = async (component: SNComponent | null, note: SNNote) => { - if (component) { - if (component.conflictOf) { - application.mutator - .changeAndSaveItem(component, (mutator) => { - mutator.conflictOf = undefined + const selectComponent = useCallback( + 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.getViewControllerManager().itemListController.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 + }, }) - .catch(console.error) + } + 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)) } - } - const transactions: TransactionalMutation[] = [] + await application.mutator.runTransactionalMutations(transactions) + /** Dirtying can happen above */ + application.sync.sync().catch(console.error) - await application.getAppState().notesView.insertCurrentIfTemplate() + setCurrentEditor(application.componentManager.editorForNote(note)) + }, + [application], + ) - 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 selectEditor = useCallback( + async (itemToBeSelected: EditorMenuItem) => { + if (!itemToBeSelected.isEntitled) { + premiumModal.activate(itemToBeSelected.name) + return } - const currentEditor = application.componentManager.editorForNote(note) - if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) { - transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note)) + + const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component + + if (areBothEditorsPlain) { + return } - 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)) + + let shouldSelectEditor = true + + if (itemToBeSelected.component) { + const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert( + currentEditor, + itemToBeSelected.component, + ) + + if (changeRequiresAlert) { + shouldSelectEditor = await application.componentManager.showEditorChangeAlert() + } } - const prefersPlain = note.prefersPlainEditor - if (prefersPlain) { - transactions.push({ - itemUuid: note.uuid, - mutate: (m: ItemMutator) => { - const noteMutator = m as NoteMutator - noteMutator.prefersPlainEditor = false - }, - }) + + if (shouldSelectEditor && note) { + selectComponent(itemToBeSelected.component ?? null, note).catch(console.error) } - 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() - } + closeMenu() + }, + [application.componentManager, closeMenu, currentEditor, note, premiumModal, selectComponent], + ) return ( @@ -176,37 +183,38 @@ export const ChangeEditorMenu: FunctionComponent = ({ return ( -
- {group.icon && } -
{group.title}
+
+ {group.items.map((item) => { + const onClickEditorItem = () => { + selectEditor(item).catch(console.error) + } + return ( + +
+
+ {group.icon && } + {item.name} +
+ {!item.isEntitled && } +
+
+ ) + })}
- {group.items.map((item) => { - const onClickEditorItem = () => { - selectEditor(item).catch(console.error) - } - - return ( - -
- {item.name} - {!item.isEntitled && } -
-
- ) - })} ) })}
) } + +export default ChangeEditorMenu diff --git a/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts b/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts index fc7ddda90..f9ff4b28e 100644 --- a/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts +++ b/app/assets/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts @@ -1,4 +1,4 @@ -import { WebApplication } from '@/UIModels/Application' +import { WebApplication } from '@/Application/Application' import { ContentType, FeatureStatus, @@ -8,8 +8,9 @@ import { GetFeatures, NoteType, } from '@standardnotes/snjs' -import { EditorMenuItem, EditorMenuGroup } from '@/Components/NotesOptions/ChangeEditorOption' -import { PLAIN_EDITOR_NAME } from '@/Constants' +import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' +import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' +import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' type EditorGroup = NoteType | 'plain' | 'others' @@ -63,7 +64,7 @@ export const createEditorMenuGroups = (application: WebApplication, editors: SNC editors.forEach((editor) => { const editorItem: EditorMenuItem = { - name: editor.name, + name: editor.displayName, component: editor, isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, } diff --git a/app/assets/javascripts/Components/Checkbox/index.tsx b/app/assets/javascripts/Components/Checkbox/Checkbox.tsx similarity index 63% rename from app/assets/javascripts/Components/Checkbox/index.tsx rename to app/assets/javascripts/Components/Checkbox/Checkbox.tsx index 4217c543b..f06a73fa8 100644 --- a/app/assets/javascripts/Components/Checkbox/index.tsx +++ b/app/assets/javascripts/Components/Checkbox/Checkbox.tsx @@ -1,14 +1,14 @@ -import { FunctionComponent } from 'preact' +import { ChangeEventHandler, FunctionComponent } from 'react' type CheckboxProps = { name: string checked: boolean - onChange: (e: Event) => void + onChange: ChangeEventHandler disabled?: boolean label: string } -export const Checkbox: FunctionComponent = ({ name, checked, onChange, disabled, label }) => { +const Checkbox: FunctionComponent = ({ name, checked, onChange, disabled, label }) => { return ( ) } + +export default Checkbox diff --git a/app/assets/javascripts/Components/ComponentView/ComponentView.tsx b/app/assets/javascripts/Components/ComponentView/ComponentView.tsx new file mode 100644 index 000000000..2471ed0f5 --- /dev/null +++ b/app/assets/javascripts/Components/ComponentView/ComponentView.tsx @@ -0,0 +1,220 @@ +import { + ComponentAction, + FeatureStatus, + SNComponent, + dateToLocalizedString, + ComponentViewer, + ComponentViewerEvent, + ComponentViewerError, +} from '@standardnotes/snjs' +import { WebApplication } from '@/Application/Application' +import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +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 { ViewControllerManager } from '@/Services/ViewControllerManager' +import { openSubscriptionDashboard } from '@/Utils/ManageSubscription' + +interface IProps { + application: WebApplication + viewControllerManager: ViewControllerManager + 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 + +const ComponentView: FunctionComponent = ({ 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: SNComponent = 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.getViewControllerManager().notesController.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 &&
} + + ) +} + +export default observer(ComponentView) diff --git a/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx b/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx index fab2a95f8..ee0125870 100644 --- a/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx +++ b/app/assets/javascripts/Components/ComponentView/IsDeprecated.tsx @@ -1,11 +1,11 @@ -import { FunctionalComponent } from 'preact' +import { FunctionComponent } from 'react' -interface IProps { +type Props = { deprecationMessage: string | undefined dismissDeprecationMessage: () => void } -export const IsDeprecated: FunctionalComponent = ({ deprecationMessage, dismissDeprecationMessage }) => { +const IsDeprecated: FunctionComponent = ({ deprecationMessage, dismissDeprecationMessage }) => { return (
@@ -23,3 +23,5 @@ export const IsDeprecated: FunctionalComponent = ({ deprecationMessage,
) } + +export default IsDeprecated diff --git a/app/assets/javascripts/Components/ComponentView/IsExpired.tsx b/app/assets/javascripts/Components/ComponentView/IsExpired.tsx index d2df6cee1..61d88aa54 100644 --- a/app/assets/javascripts/Components/ComponentView/IsExpired.tsx +++ b/app/assets/javascripts/Components/ComponentView/IsExpired.tsx @@ -1,7 +1,7 @@ import { FeatureStatus } from '@standardnotes/snjs' -import { FunctionalComponent } from 'preact' +import { FunctionComponent } from 'react' -interface IProps { +type Props = { expiredDate: string componentName: string featureStatus: FeatureStatus @@ -21,12 +21,7 @@ const statusString = (featureStatus: FeatureStatus, expiredDate: string, compone } } -export const IsExpired: FunctionalComponent = ({ - expiredDate, - featureStatus, - componentName, - manageSubscription, -}) => { +const IsExpired: FunctionComponent = ({ expiredDate, featureStatus, componentName, manageSubscription }) => { return (
@@ -52,3 +47,5 @@ export const IsExpired: FunctionalComponent = ({
) } + +export default IsExpired diff --git a/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx b/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx index 69f3c48ca..3f9a0f4c0 100644 --- a/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx +++ b/app/assets/javascripts/Components/ComponentView/IssueOnLoading.tsx @@ -1,11 +1,11 @@ -import { FunctionalComponent } from 'preact' +import { FunctionComponent } from 'react' -interface IProps { +type Props = { componentName: string reloadIframe: () => void } -export const IssueOnLoading: FunctionalComponent = ({ componentName, reloadIframe }) => { +const IssueOnLoading: FunctionComponent = ({ componentName, reloadIframe }) => { return (
@@ -23,3 +23,5 @@ export const IssueOnLoading: FunctionalComponent = ({ componentName, rel
) } + +export default IssueOnLoading diff --git a/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx b/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx index ff2fa1063..bb9258c9e 100644 --- a/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx +++ b/app/assets/javascripts/Components/ComponentView/OfflineRestricted.tsx @@ -1,6 +1,6 @@ -import { FunctionalComponent } from 'preact' +import { FunctionComponent } from 'react' -export const OfflineRestricted: FunctionalComponent = () => { +const OfflineRestricted: FunctionComponent = () => { return (
@@ -29,3 +29,5 @@ export const OfflineRestricted: FunctionalComponent = () => {
) } + +export default OfflineRestricted diff --git a/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx index ee70ebde6..cd74f3bcc 100644 --- a/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx +++ b/app/assets/javascripts/Components/ComponentView/UrlMissing.tsx @@ -1,10 +1,10 @@ -import { FunctionalComponent } from 'preact' +import { FunctionComponent } from 'react' -interface IProps { +type Props = { componentName: string } -export const UrlMissing: FunctionalComponent = ({ componentName }) => { +const UrlMissing: FunctionComponent = ({ componentName }) => { return (
@@ -20,3 +20,5 @@ export const UrlMissing: FunctionalComponent = ({ componentName }) => {
) } + +export default UrlMissing diff --git a/app/assets/javascripts/Components/ComponentView/index.tsx b/app/assets/javascripts/Components/ComponentView/index.tsx deleted file mode 100644 index 65851f376..000000000 --- a/app/assets/javascripts/Components/ComponentView/index.tsx +++ /dev/null @@ -1,221 +0,0 @@ -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/ConfirmSignoutModal.tsx similarity index 82% rename from app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx rename to app/assets/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx index ed8679b15..e0662b529 100644 --- a/app/assets/javascripts/Components/ConfirmSignoutModal/index.tsx +++ b/app/assets/javascripts/Components/ConfirmSignoutModal/ConfirmSignoutModal.tsx @@ -1,38 +1,31 @@ -import { useEffect, useRef, useState } from 'preact/hooks' +import { FunctionComponent, useEffect, useRef, useState } from 'react' 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 { STRING_SIGN_OUT_CONFIRMATION } from '@/Constants/Strings' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' import { observer } from 'mobx-react-lite' -import { ApplicationGroup } from '@/UIModels/ApplicationGroup' +import { ApplicationGroup } from '@/Application/ApplicationGroup' import { isDesktopApplication } from '@/Utils' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager 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 ConfirmSignoutModal: FunctionComponent = ({ application, viewControllerManager, applicationGroup }) => { const [deleteLocalBackups, setDeleteLocalBackups] = useState(false) const cancelRef = useRef(null) function closeDialog() { - appState.accountMenu.setSigningOut(false) + viewControllerManager.accountMenuController.setSigningOut(false) } const [localBackupsCount, setLocalBackupsCount] = useState(0) useEffect(() => { application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error) - }, [appState.accountMenu.signingOut, application.desktopDevice]) + }, [viewControllerManager.accountMenuController.signingOut, application.desktopDevice]) const workspaces = applicationGroup.getDescriptors() const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication() @@ -114,4 +107,15 @@ export const ConfirmSignoutModal = observer(({ application, appState, applicatio
) -}) +} + +ConfirmSignoutModal.displayName = 'ConfirmSignoutModal' + +const ConfirmSignoutContainer = (props: Props) => { + if (!props.viewControllerManager.accountMenuController.signingOut) { + return null + } + return +} + +export default observer(ConfirmSignoutContainer) diff --git a/app/assets/javascripts/Components/ContentListView/ContentList.tsx b/app/assets/javascripts/Components/ContentListView/ContentList.tsx new file mode 100644 index 000000000..8e5709e75 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ContentList.tsx @@ -0,0 +1,81 @@ +import { WebApplication } from '@/Application/Application' +import { KeyboardKey } from '@/Services/IOService' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { UuidString } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react' +import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants' +import { ListableContentItem } from './Types/ListableContentItem' +import ContentListItem from './ContentListItem' + +type Props = { + application: WebApplication + viewControllerManager: ViewControllerManager + items: ListableContentItem[] + selectedItems: Record + paginate: () => void +} + +const ContentList: FunctionComponent = ({ + application, + viewControllerManager, + items, + selectedItems, + paginate, +}) => { + const { selectPreviousItem, selectNextItem } = viewControllerManager.itemListController + const { hideTags, hideDate, hideNotePreview, hideEditorIcon } = + viewControllerManager.itemListController.webDisplayOptions + const { sortBy } = viewControllerManager.itemListController.displayOptions + + const onScroll: UIEventHandler = useCallback( + (e) => { + const offset = NOTES_LIST_SCROLL_THRESHOLD + const element = e.target as HTMLElement + if (element.scrollTop + element.offsetHeight >= element.scrollHeight - offset) { + paginate() + } + }, + [paginate], + ) + + const onKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if (e.key === KeyboardKey.Up) { + e.preventDefault() + selectPreviousItem() + } else if (e.key === KeyboardKey.Down) { + e.preventDefault() + selectNextItem() + } + }, + [selectNextItem, selectPreviousItem], + ) + + return ( +
+ {items.map((item) => ( + + ))} +
+ ) +} + +export default observer(ContentList) diff --git a/app/assets/javascripts/Components/ContentListView/ContentListItem.tsx b/app/assets/javascripts/Components/ContentListView/ContentListItem.tsx new file mode 100644 index 000000000..ab4ebcbaf --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ContentListItem.tsx @@ -0,0 +1,38 @@ +import { ContentType, SNTag } from '@standardnotes/snjs' +import { FunctionComponent } from 'react' +import FileListItem from './FileListItem' +import NoteListItem from './NoteListItem' +import { AbstractListItemProps } from './Types/AbstractListItemProps' + +const ContentListItem: FunctionComponent = (props) => { + const getTags = () => { + if (props.hideTags) { + return [] + } + + const selectedTag = props.viewControllerManager.navigationController.selected + if (!selectedTag) { + return [] + } + + const tags = props.application.getItemTags(props.item) + + const isNavigatingOnlyTag = selectedTag instanceof SNTag && tags.length === 1 + if (isNavigatingOnlyTag) { + return [] + } + + return tags + } + + switch (props.item.content_type) { + case ContentType.Note: + return + case ContentType.File: + return + default: + return null + } +} + +export default ContentListItem diff --git a/app/assets/javascripts/Components/ContentListView/ContentListOptionsMenu.tsx b/app/assets/javascripts/Components/ContentListView/ContentListOptionsMenu.tsx new file mode 100644 index 000000000..ce6243c9b --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ContentListOptionsMenu.tsx @@ -0,0 +1,257 @@ +import { WebApplication } from '@/Application/Application' +import { CollectionSort, CollectionSortProperty, PrefKey, SystemViewId } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent, useCallback, useState } from 'react' +import Icon from '@/Components/Icon/Icon' +import Menu from '@/Components/Menu/Menu' +import MenuItem from '@/Components/Menu/MenuItem' +import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator' +import { MenuItemType } from '@/Components/Menu/MenuItemType' +import { ViewControllerManager } from '@/Services/ViewControllerManager' + +type Props = { + application: WebApplication + viewControllerManager: ViewControllerManager + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void + closeDisplayOptionsMenu: () => void + isOpen: boolean +} + +const ContentListOptionsMenu: FunctionComponent = ({ + closeDisplayOptionsMenu, + closeOnBlur, + application, + viewControllerManager, + isOpen, +}) => { + const [sortBy, setSortBy] = useState(() => application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)) + const [sortReverse, setSortReverse] = useState(() => application.getPreference(PrefKey.SortNotesReverse, false)) + const [hidePreview, setHidePreview] = useState(() => application.getPreference(PrefKey.NotesHideNotePreview, false)) + const [hideDate, setHideDate] = useState(() => application.getPreference(PrefKey.NotesHideDate, false)) + const [hideTags, setHideTags] = useState(() => application.getPreference(PrefKey.NotesHideTags, true)) + const [hidePinned, setHidePinned] = useState(() => application.getPreference(PrefKey.NotesHidePinned, false)) + const [showArchived, setShowArchived] = useState(() => application.getPreference(PrefKey.NotesShowArchived, false)) + const [showTrashed, setShowTrashed] = useState(() => application.getPreference(PrefKey.NotesShowTrashed, false)) + const [hideProtected, setHideProtected] = useState(() => application.getPreference(PrefKey.NotesHideProtected, false)) + const [hideEditorIcon, setHideEditorIcon] = useState(() => + application.getPreference(PrefKey.NotesHideEditorIcon, false), + ) + + const toggleSortReverse = useCallback(() => { + application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error) + setSortReverse(!sortReverse) + }, [application, sortReverse]) + + const toggleSortBy = useCallback( + (sort: CollectionSortProperty) => { + if (sortBy === sort) { + toggleSortReverse() + } else { + setSortBy(sort) + application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error) + } + }, + [application, sortBy, toggleSortReverse], + ) + + const toggleSortByDateModified = useCallback(() => { + toggleSortBy(CollectionSort.UpdatedAt) + }, [toggleSortBy]) + + const toggleSortByCreationDate = useCallback(() => { + toggleSortBy(CollectionSort.CreatedAt) + }, [toggleSortBy]) + + const toggleSortByTitle = useCallback(() => { + toggleSortBy(CollectionSort.Title) + }, [toggleSortBy]) + + const toggleHidePreview = useCallback(() => { + setHidePreview(!hidePreview) + application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error) + }, [application, hidePreview]) + + const toggleHideDate = useCallback(() => { + setHideDate(!hideDate) + application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error) + }, [application, hideDate]) + + const toggleHideTags = useCallback(() => { + setHideTags(!hideTags) + application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error) + }, [application, hideTags]) + + const toggleHidePinned = useCallback(() => { + setHidePinned(!hidePinned) + application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error) + }, [application, hidePinned]) + + const toggleShowArchived = useCallback(() => { + setShowArchived(!showArchived) + application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error) + }, [application, showArchived]) + + const toggleShowTrashed = useCallback(() => { + setShowTrashed(!showTrashed) + application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error) + }, [application, showTrashed]) + + const toggleHideProtected = useCallback(() => { + setHideProtected(!hideProtected) + application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error) + }, [application, hideProtected]) + + const toggleEditorIcon = useCallback(() => { + setHideEditorIcon(!hideEditorIcon) + application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error) + }, [application, hideEditorIcon]) + + return ( + +
Sort by
+ +
+ Date modified + {sortBy === CollectionSort.UpdatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
+ Creation date + {sortBy === CollectionSort.CreatedAt ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
+ Title + {sortBy === CollectionSort.Title ? ( + sortReverse ? ( + + ) : ( + + ) + ) : null} +
+
+ +
View
+ {viewControllerManager.navigationController.selectedUuid !== SystemViewId.Files && ( + +
Show note preview
+
+ )} + + Show date + + + Show tags + + + Show icon + +
+
Other
+ + Show pinned + + + Show protected + + + Show archived + + + Show trashed + +
+ ) +} + +export default observer(ContentListOptionsMenu) diff --git a/app/assets/javascripts/Components/NotesView/index.tsx b/app/assets/javascripts/Components/ContentListView/ContentListView.tsx similarity index 59% rename from app/assets/javascripts/Components/NotesView/index.tsx rename to app/assets/javascripts/Components/ContentListView/ContentListView.tsx index ada7d5408..53bdb6884 100644 --- a/app/assets/javascripts/Components/NotesView/index.tsx +++ b/app/assets/javascripts/Components/ContentListView/ContentListView.tsx @@ -1,59 +1,73 @@ import { KeyboardKey, KeyboardModifier } from '@/Services/IOService' -import { WebApplication } from '@/UIModels/Application' -import { AppState } from '@/UIModels/AppState' -import { PANEL_NAME_NOTES } from '@/Constants' -import { PrefKey } from '@standardnotes/snjs' +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { PANEL_NAME_NOTES } from '@/Constants/Constants' +import { PrefKey, SystemViewId } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' -import { FunctionComponent } from 'preact' -import { useCallback, useEffect, useRef, useState } from 'preact/hooks' -import { NoAccountWarning } from '@/Components/NoAccountWarning' -import { NotesList } from '@/Components/NotesList' -import { NotesListOptionsMenu } from '@/Components/NotesList/NotesListOptionsMenu' -import { SearchOptions } from '@/Components/SearchOptions' -import { PanelSide, ResizeFinishCallback, PanelResizer, PanelResizeType } from '@/Components/PanelResizer' +import { + ChangeEventHandler, + FunctionComponent, + KeyboardEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import ContentList from '@/Components/ContentListView/ContentList' +import NoAccountWarningWrapper from '@/Components/NoAccountWarning/NoAccountWarning' +import SearchOptions from '@/Components/SearchOptions/SearchOptions' +import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer' import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { isStateDealloced } from '@/UIModels/AppState/AbstractState' +import ContentListOptionsMenu from './ContentListOptionsMenu' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager } -export const NotesView: FunctionComponent = observer(({ application, appState }: Props) => { - if (isStateDealloced(appState)) { - return null - } - - const notesViewPanelRef = useRef(null) +const ContentListView: FunctionComponent = ({ application, viewControllerManager }) => { + const itemsViewPanelRef = useRef(null) const displayOptionsMenuRef = useRef(null) const { completedFullSync, - displayOptions, noteFilterText, optionsSubtitle, panelTitle, - renderedNotes, + renderedItems, + setNoteFilterText, searchBarElement, + selectNextItem, + selectPreviousItem, + onFilterEnter, + clearFilterText, paginate, panelWidth, - } = appState.notesView + createNewNote, + } = viewControllerManager.itemListController - const { selectedNotes } = appState.notes - - const createNewNote = useCallback(() => appState.notesView.createNewNote(), [appState]) - const onFilterEnter = useCallback(() => appState.notesView.onFilterEnter(), [appState]) - const clearFilterText = useCallback(() => appState.notesView.clearFilterText(), [appState]) - const setNoteFilterText = useCallback((text: string) => appState.notesView.setNoteFilterText(text), [appState]) - const selectNextNote = useCallback(() => appState.notesView.selectNextNote(), [appState]) - const selectPreviousNote = useCallback(() => appState.notesView.selectPreviousNote(), [appState]) + const { selectedItems } = viewControllerManager.selectionController const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false) const [focusedSearch, setFocusedSearch] = useState(false) const [closeDisplayOptMenuOnBlur] = useCloseOnBlur(displayOptionsMenuRef, setShowDisplayOptionsMenu) + const isFilesSmartView = useMemo( + () => viewControllerManager.navigationController.selected?.uuid === SystemViewId.Files, + [viewControllerManager.navigationController.selected?.uuid], + ) + + const addNewItem = useCallback(() => { + if (isFilesSmartView) { + void viewControllerManager.filesController.uploadNewFile() + } else { + void createNewNote() + } + }, [viewControllerManager.filesController, createNewNote, isFilesSmartView]) + useEffect(() => { /** * In the browser we're not allowed to override cmd/ctrl + n, so we have to @@ -65,7 +79,7 @@ export const NotesView: FunctionComponent = observer(({ application, appS modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl], onKeyDown: (event) => { event.preventDefault() - void createNewNote() + addNewItem() }, }) @@ -76,7 +90,7 @@ export const NotesView: FunctionComponent = observer(({ application, appS if (searchBarElement === document.activeElement) { searchBarElement?.blur() } - selectNextNote() + selectNextItem() }, }) @@ -84,7 +98,7 @@ export const NotesView: FunctionComponent = observer(({ application, appS key: KeyboardKey.Up, element: document.body, onKeyDown: () => { - selectPreviousNote() + selectPreviousItem() }, }) @@ -104,11 +118,11 @@ export const NotesView: FunctionComponent = observer(({ application, appS previousNoteKeyObserver() searchKeyObserver() } - }, [application, createNewNote, selectPreviousNote, searchBarElement, selectNextNote]) + }, [addNewItem, application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem]) - const onNoteFilterTextChange = useCallback( - (e: Event) => { - setNoteFilterText((e.target as HTMLInputElement).value) + const onNoteFilterTextChange: ChangeEventHandler = useCallback( + (e) => { + setNoteFilterText(e.target.value) }, [setNoteFilterText], ) @@ -116,8 +130,8 @@ export const NotesView: FunctionComponent = observer(({ application, appS const onSearchFocused = useCallback(() => setFocusedSearch(true), []) const onSearchBlurred = useCallback(() => setFocusedSearch(false), []) - const onNoteFilterKeyUp = useCallback( - (e: KeyboardEvent) => { + const onNoteFilterKeyUp: KeyboardEventHandler = useCallback( + (e) => { if (e.key === KeyboardKey.Enter) { onFilterEnter() } @@ -128,37 +142,42 @@ export const NotesView: FunctionComponent = observer(({ application, appS const panelResizeFinishCallback: ResizeFinishCallback = useCallback( (width, _lastLeft, _isMaxWidth, isCollapsed) => { application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error) - appState.noteTags.reloadTagsContainerMaxWidth() - appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed) + viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth() + application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed) }, - [appState, application], + [viewControllerManager, application], ) const panelWidthEventCallback = useCallback(() => { - appState.noteTags.reloadTagsContainerMaxWidth() - }, [appState]) + viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth() + }, [viewControllerManager]) const toggleDisplayOptionsMenu = useCallback(() => { setShowDisplayOptionsMenu(!showDisplayOptionsMenu) }, [showDisplayOptionsMenu]) + const addButtonLabel = useMemo( + () => (isFilesSmartView ? 'Upload file' : 'Create a new note in the selected tag'), + [isFilesSmartView], + ) + return (
-
-
+
+
{panelTitle}
)} @@ -189,13 +208,13 @@ export const NotesView: FunctionComponent = observer(({ application, appS {(focusedSearch || noteFilterText) && (
- +
)}
- +
-
+
@@ -214,8 +233,9 @@ export const NotesView: FunctionComponent = observer(({ application, appS {showDisplayOptionsMenu && ( - = observer(({ application, appS
- {completedFullSync && !renderedNotes.length ?

No notes.

: null} - {!completedFullSync && !renderedNotes.length ? ( -

Loading notes...

- ) : null} - {renderedNotes.length ? ( - No items.

: null} + {!completedFullSync && !renderedItems.length ?

Loading...

: null} + {renderedItems.length ? ( + ) : null}
- {notesViewPanelRef.current && ( + {itemsViewPanelRef.current && ( = observer(({ application, appS )}
) -}) +} + +export default observer(ContentListView) diff --git a/app/assets/javascripts/Components/ContentListView/FileListItem.tsx b/app/assets/javascripts/Components/ContentListView/FileListItem.tsx new file mode 100644 index 000000000..b1c32c664 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/FileListItem.tsx @@ -0,0 +1,97 @@ +import { FileItem } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent, useCallback } from 'react' +import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent' +import ListItemConflictIndicator from './ListItemConflictIndicator' +import ListItemFlagIcons from './ListItemFlagIcons' +import ListItemTags from './ListItemTags' +import ListItemMetadata from './ListItemMetadata' +import { DisplayableListItemProps } from './Types/DisplayableListItemProps' + +const FileListItem: FunctionComponent = ({ + application, + viewControllerManager, + hideDate, + hideIcon, + hideTags, + item, + selected, + sortBy, + tags, +}) => { + const openFileContextMenu = useCallback( + (posX: number, posY: number) => { + viewControllerManager.filesController.setFileContextMenuLocation({ + x: posX, + y: posY, + }) + viewControllerManager.filesController.setShowFileContextMenu(true) + }, + [viewControllerManager.filesController], + ) + + const openContextMenu = useCallback( + async (posX: number, posY: number) => { + const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid) + if (didSelect) { + openFileContextMenu(posX, posY) + } + }, + [viewControllerManager.selectionController, item.uuid, openFileContextMenu], + ) + + const onClick = useCallback(() => { + void viewControllerManager.selectionController.selectItem(item.uuid, true).then(({ didSelect }) => { + if (didSelect && viewControllerManager.selectionController.selectedItemsCount < 2) { + viewControllerManager.filePreviewModalController.activate( + item as FileItem, + viewControllerManager.filesController.allFiles, + ) + } + }) + }, [ + viewControllerManager.filePreviewModalController, + viewControllerManager.filesController.allFiles, + viewControllerManager.selectionController, + item, + ]) + + const IconComponent = () => + getFileIconComponent( + application.iconsController.getIconForFileType((item as FileItem).mimeType), + 'w-5 h-5 flex-shrink-0', + ) + + return ( +
{ + event.preventDefault() + void openContextMenu(event.clientX, event.clientY) + }} + > + {!hideIcon ? ( +
+ +
+ ) : ( +
+ )} +
+
+
{item.title}
+
+ + + +
+ +
+ ) +} + +export default observer(FileListItem) diff --git a/app/assets/javascripts/Components/ContentListView/ListItemConflictIndicator.tsx b/app/assets/javascripts/Components/ContentListView/ListItemConflictIndicator.tsx new file mode 100644 index 000000000..1849b286d --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ListItemConflictIndicator.tsx @@ -0,0 +1,20 @@ +import { FunctionComponent } from 'react' +import { ListableContentItem } from './Types/ListableContentItem' + +type Props = { + item: { + conflictOf?: ListableContentItem['conflictOf'] + } +} + +const ListItemConflictIndicator: FunctionComponent = ({ item }) => { + return item.conflictOf ? ( +
+
+
Conflicted Copy
+
+
+ ) : null +} + +export default ListItemConflictIndicator diff --git a/app/assets/javascripts/Components/ContentListView/ListItemFlagIcons.tsx b/app/assets/javascripts/Components/ContentListView/ListItemFlagIcons.tsx new file mode 100644 index 000000000..9fd7b2d28 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ListItemFlagIcons.tsx @@ -0,0 +1,47 @@ +import { FunctionComponent } from 'react' +import Icon from '@/Components/Icon/Icon' +import { ListableContentItem } from './Types/ListableContentItem' + +type Props = { + item: { + locked: ListableContentItem['locked'] + trashed: ListableContentItem['trashed'] + archived: ListableContentItem['archived'] + pinned: ListableContentItem['pinned'] + } + hasFiles?: boolean +} + +const ListItemFlagIcons: FunctionComponent = ({ item, hasFiles = false }) => { + return ( +
+ {item.locked && ( + + + + )} + {item.trashed && ( + + + + )} + {item.archived && ( + + + + )} + {item.pinned && ( + + + + )} + {hasFiles && ( + + + + )} +
+ ) +} + +export default ListItemFlagIcons diff --git a/app/assets/javascripts/Components/ContentListView/ListItemMetadata.tsx b/app/assets/javascripts/Components/ContentListView/ListItemMetadata.tsx new file mode 100644 index 000000000..51c812542 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ListItemMetadata.tsx @@ -0,0 +1,31 @@ +import { CollectionSort, SortableItem } from '@standardnotes/snjs' +import { FunctionComponent } from 'react' +import { ListableContentItem } from './Types/ListableContentItem' + +type Props = { + item: { + protected: ListableContentItem['protected'] + updatedAtString?: ListableContentItem['updatedAtString'] + createdAtString?: ListableContentItem['createdAtString'] + } + hideDate: boolean + sortBy: keyof SortableItem | undefined +} + +const ListItemMetadata: FunctionComponent = ({ item, hideDate, sortBy }) => { + const showModifiedDate = sortBy === CollectionSort.UpdatedAt + + if (hideDate && !item.protected) { + return null + } + + return ( +
+ {item.protected && Protected {hideDate ? '' : ' • '}} + {!hideDate && showModifiedDate && Modified {item.updatedAtString || 'Now'}} + {!hideDate && !showModifiedDate && {item.createdAtString || 'Now'}} +
+ ) +} + +export default ListItemMetadata diff --git a/app/assets/javascripts/Components/ContentListView/ListItemTags.tsx b/app/assets/javascripts/Components/ContentListView/ListItemTags.tsx new file mode 100644 index 000000000..fac7f8b3d --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/ListItemTags.tsx @@ -0,0 +1,30 @@ +import { FunctionComponent } from 'react' +import Icon from '@/Components/Icon/Icon' +import { DisplayableListItemProps } from './Types/DisplayableListItemProps' + +type Props = { + hideTags: boolean + tags: DisplayableListItemProps['tags'] +} + +const ListItemTags: FunctionComponent = ({ hideTags, tags }) => { + if (hideTags || !tags.length) { + return null + } + + return ( +
+ {tags.map((tag) => ( + + + {tag.title} + + ))} +
+ ) +} + +export default ListItemTags diff --git a/app/assets/javascripts/Components/ContentListView/NoteListItem.tsx b/app/assets/javascripts/Components/ContentListView/NoteListItem.tsx new file mode 100644 index 000000000..7672fe7e8 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/NoteListItem.tsx @@ -0,0 +1,97 @@ +import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'react' +import Icon from '@/Components/Icon/Icon' +import ListItemConflictIndicator from './ListItemConflictIndicator' +import ListItemFlagIcons from './ListItemFlagIcons' +import ListItemTags from './ListItemTags' +import ListItemMetadata from './ListItemMetadata' +import { DisplayableListItemProps } from './Types/DisplayableListItemProps' + +const NoteListItem: FunctionComponent = ({ + application, + viewControllerManager, + hideDate, + hideIcon, + hideTags, + hidePreview, + item, + selected, + sortBy, + tags, +}) => { + const editorForNote = application.componentManager.editorForNote(item as SNNote) + const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME + const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type) + const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0 + + const openNoteContextMenu = (posX: number, posY: number) => { + viewControllerManager.notesController.setContextMenuClickLocation({ + x: posX, + y: posY, + }) + viewControllerManager.notesController.reloadContextMenuLayout() + viewControllerManager.notesController.setContextMenuOpen(true) + } + + const openContextMenu = async (posX: number, posY: number) => { + const { didSelect } = await viewControllerManager.selectionController.selectItem(item.uuid, true) + if (didSelect) { + openNoteContextMenu(posX, posY) + } + } + + return ( +
{ + void viewControllerManager.selectionController.selectItem(item.uuid, true) + }} + onContextMenu={(event) => { + event.preventDefault() + void openContextMenu(event.clientX, event.clientY) + }} + > + {!hideIcon ? ( +
+ +
+ ) : ( +
+ )} +
+
+
{item.title}
+
+ {!hidePreview && !item.hidePreview && !item.protected && ( +
+ {item.preview_html && ( +
+ )} + {!item.preview_html && item.preview_plain && ( +
{item.preview_plain}
+ )} + {!item.preview_html && !item.preview_plain && item.text && ( +
{item.text}
+ )} +
+ )} + + + +
+ +
+ ) +} + +export default observer(NoteListItem) diff --git a/app/assets/javascripts/Components/ContentListView/Types/AbstractListItemProps.ts b/app/assets/javascripts/Components/ContentListView/Types/AbstractListItemProps.ts new file mode 100644 index 000000000..8b1b2ded4 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/Types/AbstractListItemProps.ts @@ -0,0 +1,16 @@ +import { WebApplication } from '@/Application/Application' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { SortableItem } from '@standardnotes/snjs' +import { ListableContentItem } from './ListableContentItem' + +export type AbstractListItemProps = { + application: WebApplication + viewControllerManager: ViewControllerManager + hideDate: boolean + hideIcon: boolean + hideTags: boolean + hidePreview: boolean + item: ListableContentItem + selected: boolean + sortBy: keyof SortableItem | undefined +} diff --git a/app/assets/javascripts/Components/ContentListView/Types/DisplayableListItemProps.ts b/app/assets/javascripts/Components/ContentListView/Types/DisplayableListItemProps.ts new file mode 100644 index 000000000..e0d04d994 --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/Types/DisplayableListItemProps.ts @@ -0,0 +1,9 @@ +import { SNTag } from '@standardnotes/snjs' +import { AbstractListItemProps } from './AbstractListItemProps' + +export type DisplayableListItemProps = AbstractListItemProps & { + tags: { + uuid: SNTag['uuid'] + title: SNTag['title'] + }[] +} diff --git a/app/assets/javascripts/Components/ContentListView/Types/ListableContentItem.ts b/app/assets/javascripts/Components/ContentListView/Types/ListableContentItem.ts new file mode 100644 index 000000000..797bc9bcc --- /dev/null +++ b/app/assets/javascripts/Components/ContentListView/Types/ListableContentItem.ts @@ -0,0 +1,14 @@ +import { ContentType, DecryptedItem, ItemContent } from '@standardnotes/snjs' + +export type ListableContentItem = DecryptedItem & { + title: string + protected: boolean + uuid: string + content_type: ContentType + updatedAtString?: string + createdAtString?: string + hidePreview?: boolean + preview_html?: string + preview_plain?: string + text?: string +} diff --git a/app/assets/javascripts/Components/DeallocateHandler/DeallocateHandler.tsx b/app/assets/javascripts/Components/DeallocateHandler/DeallocateHandler.tsx new file mode 100644 index 000000000..bdc99e4e5 --- /dev/null +++ b/app/assets/javascripts/Components/DeallocateHandler/DeallocateHandler.tsx @@ -0,0 +1,17 @@ +import { WebApplication } from '@/Application/Application' +import { observer } from 'mobx-react-lite' +import { FunctionComponent } from 'react' + +type Props = { + application: WebApplication +} + +const DeallocateHandler: FunctionComponent = ({ application, children }) => { + if (application.dealloced) { + return null + } + + return <>{children} +} + +export default observer(DeallocateHandler) diff --git a/app/assets/javascripts/Components/Dropdown/index.tsx b/app/assets/javascripts/Components/Dropdown/Dropdown.tsx similarity index 88% rename from app/assets/javascripts/Components/Dropdown/index.tsx rename to app/assets/javascripts/Components/Dropdown/Dropdown.tsx index 15667b7e6..806be187f 100644 --- a/app/assets/javascripts/Components/Dropdown/index.tsx +++ b/app/assets/javascripts/Components/Dropdown/Dropdown.tsx @@ -1,16 +1,8 @@ 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 -} +import { FunctionComponent } from 'react' +import Icon from '@/Components/Icon/Icon' +import { DropdownItem } from './DropdownItem' type DropdownProps = { id: string @@ -41,12 +33,12 @@ const CustomDropdownButton: FunctionComponent = ({
{label}
- + ) -export const Dropdown: FunctionComponent = ({ id, label, items, value, onChange, disabled }) => { +const Dropdown: FunctionComponent = ({ id, label, items, value, onChange, disabled }) => { const labelId = `${id}-label` const handleChange = (value: string) => { @@ -79,6 +71,7 @@ export const Dropdown: FunctionComponent = ({ id, label, items, v {items.map((item) => ( = ({ id, label, items, v ) } + +export default Dropdown diff --git a/app/assets/javascripts/Components/Dropdown/DropdownItem.tsx b/app/assets/javascripts/Components/Dropdown/DropdownItem.tsx new file mode 100644 index 000000000..05aaf556d --- /dev/null +++ b/app/assets/javascripts/Components/Dropdown/DropdownItem.tsx @@ -0,0 +1,9 @@ +import { IconType } from '@standardnotes/snjs' + +export type DropdownItem = { + icon?: IconType + iconClassName?: string + label: string + value: string + disabled?: boolean +} diff --git a/app/assets/javascripts/Components/FileContextMenu/FileContextMenu.tsx b/app/assets/javascripts/Components/FileContextMenu/FileContextMenu.tsx new file mode 100644 index 000000000..8861bd965 --- /dev/null +++ b/app/assets/javascripts/Components/FileContextMenu/FileContextMenu.tsx @@ -0,0 +1,135 @@ +import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants' +import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' +import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' +import { ViewControllerManager } from '@/Services/ViewControllerManager' +import { observer } from 'mobx-react-lite' +import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' +import { PopoverFileItemAction } from '../AttachedFilesPopover/PopoverFileItemAction' +import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs' +import FileMenuOptions from './FileMenuOptions' + +type Props = { + viewControllerManager: ViewControllerManager +} + +const FileContextMenu: FunctionComponent = observer(({ viewControllerManager }) => { + const { selectedFiles, showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = + viewControllerManager.filesController + + const [contextMenuStyle, setContextMenuStyle] = useState({ + top: 0, + left: 0, + visibility: 'hidden', + }) + const [contextMenuMaxHeight, setContextMenuMaxHeight] = useState('auto') + const contextMenuRef = useRef(null) + const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => setShowFileContextMenu(open)) + useCloseOnClickOutside(contextMenuRef, () => viewControllerManager.filesController.setShowFileContextMenu(false)) + + const selectedFile = selectedFiles[0] + + const reloadContextMenuLayout = useCallback(() => { + const { clientHeight } = document.documentElement + const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize + const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER + const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect() + const footerHeightInPx = footerElementRect?.height + + let openUpBottom = true + + if (footerHeightInPx) { + const bottomSpace = clientHeight - footerHeightInPx - fileContextMenuLocation.y + const upSpace = fileContextMenuLocation.y + + if (maxContextMenuHeight > bottomSpace) { + if (upSpace > maxContextMenuHeight) { + openUpBottom = false + setContextMenuMaxHeight('auto') + } else { + if (upSpace > bottomSpace) { + setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER) + openUpBottom = false + } else { + setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER) + } + } + } else { + setContextMenuMaxHeight('auto') + } + } + + if (openUpBottom) { + setContextMenuStyle({ + top: fileContextMenuLocation.y, + left: fileContextMenuLocation.x, + visibility: 'visible', + }) + } else { + setContextMenuStyle({ + bottom: clientHeight - fileContextMenuLocation.y, + left: fileContextMenuLocation.x, + visibility: 'visible', + }) + } + }, [fileContextMenuLocation.x, fileContextMenuLocation.y]) + + useEffect(() => { + if (showFileContextMenu) { + reloadContextMenuLayout() + } + }, [reloadContextMenuLayout, showFileContextMenu]) + + useEffect(() => { + window.addEventListener('resize', reloadContextMenuLayout) + return () => { + window.removeEventListener('resize', reloadContextMenuLayout) + } + }, [reloadContextMenuLayout]) + + const handleFileAction = useCallback( + async (action: PopoverFileItemAction) => { + const { didHandleAction } = await viewControllerManager.filesController.handleFileAction( + action, + PopoverTabs.AllFiles, + ) + return didHandleAction + }, + [viewControllerManager.filesController], + ) + + return ( +
+ setShowFileContextMenu(false)} + shouldShowRenameOption={false} + shouldShowAttachOption={false} + /> +
+ ) +}) + +FileContextMenu.displayName = 'FileContextMenu' + +const FileContextMenuWrapper: FunctionComponent = ({ viewControllerManager }) => { + const { selectedFiles, showFileContextMenu } = viewControllerManager.filesController + + const selectedFile = selectedFiles[0] + + if (!showFileContextMenu || !selectedFile) { + return null + } + + return +} + +export default observer(FileContextMenuWrapper) diff --git a/app/assets/javascripts/Components/FileContextMenu/FileMenuOptions.tsx b/app/assets/javascripts/Components/FileContextMenu/FileMenuOptions.tsx new file mode 100644 index 000000000..136312de4 --- /dev/null +++ b/app/assets/javascripts/Components/FileContextMenu/FileMenuOptions.tsx @@ -0,0 +1,143 @@ +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' +import { FileItem } from '@standardnotes/snjs' +import { FunctionComponent } from 'react' +import { PopoverFileItemAction, PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction' +import Icon from '@/Components/Icon/Icon' +import Switch from '@/Components/Switch/Switch' + +type Props = { + closeMenu: () => void + closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void + file: FileItem + fileProtectionToggleCallback?: (isProtected: boolean) => void + handleFileAction: (action: PopoverFileItemAction) => Promise + isFileAttachedToNote?: boolean + renameToggleCallback?: (isRenamingFile: boolean) => void + shouldShowRenameOption: boolean + shouldShowAttachOption: boolean +} + +const FileMenuOptions: FunctionComponent = ({ + closeMenu, + closeOnBlur, + file, + fileProtectionToggleCallback, + handleFileAction, + isFileAttachedToNote, + renameToggleCallback, + shouldShowRenameOption, + shouldShowAttachOption, +}) => { + return ( + <> + + {isFileAttachedToNote ? ( + + ) : shouldShowAttachOption ? ( + + ) : null} +
+ +
+ + {shouldShowRenameOption && ( + + )} + + + ) +} + +export default FileMenuOptions diff --git a/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx index 6db5878cf..20afa53d2 100644 --- a/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx +++ b/app/assets/javascripts/Components/Files/FilePreviewInfoPanel.tsx @@ -1,13 +1,13 @@ import { formatSizeToReadableString } from '@standardnotes/filepicker' import { FileItem } from '@standardnotes/snjs' -import { FunctionComponent } from 'preact' -import { Icon } from '@/Components/Icon' +import { FunctionComponent } from 'react' +import Icon from '@/Components/Icon/Icon' type Props = { file: FileItem } -export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { +const FilePreviewInfoPanel: FunctionComponent = ({ file }) => { return (
@@ -35,3 +35,5 @@ export const FilePreviewInfoPanel: FunctionComponent = ({ file }) => {
) } + +export default FilePreviewInfoPanel diff --git a/app/assets/javascripts/Components/Files/FilePreviewModal.tsx b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx index e1facb053..2798e4500 100644 --- a/app/assets/javascripts/Components/Files/FilePreviewModal.tsx +++ b/app/assets/javascripts/Components/Files/FilePreviewModal.tsx @@ -1,30 +1,29 @@ -import { WebApplication } from '@/UIModels/Application' +import { WebApplication } from '@/Application/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 { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent' +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import FilePreviewInfoPanel from './FilePreviewInfoPanel' import { isFileTypePreviewable } from './isFilePreviewable' -import { PreviewComponent } from './PreviewComponent' -import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants' +import PreviewComponent from './PreviewComponent' +import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { KeyboardKey } from '@/Services/IOService' -import { AppState } from '@/UIModels/AppState' +import { ViewControllerManager } from '@/Services/ViewControllerManager' import { observer } from 'mobx-react-lite' type Props = { application: WebApplication - appState: AppState + viewControllerManager: ViewControllerManager } -export const FilePreviewModal: FunctionComponent = observer(({ application, appState }) => { - const { currentFile, setCurrentFile, otherFiles, dismiss, isOpen } = appState.filePreviewModal +const FilePreviewModal: FunctionComponent = observer(({ application, viewControllerManager }) => { + const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController - if (!currentFile || !isOpen) { + if (!currentFile) { return null } @@ -87,34 +86,46 @@ export const FilePreviewModal: FunctionComponent = observer(({ applicatio } }, [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 + const keyDownHandler: KeyboardEventHandler = useCallback( + (event) => { + if (event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right) { + return } - case KeyboardKey.Right: { - const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0 - const nextFile = otherFiles[nextFileIndex] - if (nextFile) { - setCurrentFile(nextFile) + + 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 } - break } - } - } + }, + [currentFile.uuid, otherFiles, setCurrentFile], + ) + + const IconComponent = useMemo( + () => + getFileIconComponent( + application.iconsController.getIconForFileType(currentFile.mimeType), + 'w-6 h-6 flex-shrink-0', + ), + [application.iconsController, currentFile.mimeType], + ) return ( = observer(({ applicatio dangerouslyBypassScrollLock >
= observer(({ applicatio onKeyDown={keyDownHandler} >
-
- {getFileIconComponent( - application.iconsController.getIconForFileType(currentFile.mimeType), - 'w-6 h-6 flex-shrink-0', - )} -
+
{IconComponent}
{currentFile.name}
@@ -197,7 +204,7 @@ export const FilePreviewModal: FunctionComponent = observer(({ applicatio
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.
@@ -214,7 +221,10 @@ export const FilePreviewModal: FunctionComponent = observer(({ applicatio
) } + +export default ImagePreview diff --git a/app/assets/javascripts/Components/Files/PreviewComponent.tsx b/app/assets/javascripts/Components/Files/PreviewComponent.tsx index f673dc62d..b0729bbd0 100644 --- a/app/assets/javascripts/Components/Files/PreviewComponent.tsx +++ b/app/assets/javascripts/Components/Files/PreviewComponent.tsx @@ -1,19 +1,19 @@ import { FileItem } from '@standardnotes/snjs' -import { FunctionComponent } from 'preact' -import { ImagePreview } from './ImagePreview' +import { FunctionComponent } from 'react' +import ImagePreview from './ImagePreview' type Props = { file: FileItem objectUrl: string } -export const PreviewComponent: FunctionComponent = ({ file, objectUrl }) => { +const PreviewComponent: FunctionComponent = ({ file, objectUrl }) => { if (file.mimeType.startsWith('image/')) { return } if (file.mimeType.startsWith('video/')) { - return