refactor: repo (#1070)
This commit is contained in:
97
packages/web/src/javascripts/App.tsx
Normal file
97
packages/web/src/javascripts/App.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use strict'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dashboardUrl?: string
|
||||
defaultSyncServer: string
|
||||
devAccountEmail?: string
|
||||
devAccountPassword?: string
|
||||
devAccountServer?: string
|
||||
enabledUnfinishedFeatures: boolean
|
||||
plansUrl?: string
|
||||
purchaseUrl?: string
|
||||
startApplication?: StartApplication
|
||||
websocketUrl: string
|
||||
electronAppVersion?: string
|
||||
webClient?: DesktopManagerInterface
|
||||
|
||||
application?: WebApplication
|
||||
mainApplicationGroup?: ApplicationGroup
|
||||
}
|
||||
}
|
||||
|
||||
import { IsWebPlatform, WebAppVersion } from '@/Constants/Version'
|
||||
import { DesktopManagerInterface, SNLog } from '@standardnotes/snjs'
|
||||
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 = () => {
|
||||
return keyCount++
|
||||
}
|
||||
|
||||
const RootId = 'app-group-root'
|
||||
|
||||
const startApplication: StartApplication = async function startApplication(
|
||||
defaultSyncServerHost: string,
|
||||
device: WebOrDesktopDevice,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
webSocketUrl: string,
|
||||
) {
|
||||
SNLog.onLog = console.log
|
||||
SNLog.onError = console.error
|
||||
let root: Root
|
||||
|
||||
const onDestroy = () => {
|
||||
const rootElement = document.getElementById(RootId) as HTMLElement
|
||||
root.unmount()
|
||||
rootElement.remove()
|
||||
renderApp()
|
||||
}
|
||||
|
||||
const renderApp = () => {
|
||||
const rootElement = document.createElement('div')
|
||||
rootElement.id = RootId
|
||||
const appendedRootNode = document.body.appendChild(rootElement)
|
||||
root = createRoot(appendedRootNode)
|
||||
|
||||
root.render(
|
||||
<ApplicationGroupView
|
||||
key={getKey()}
|
||||
server={defaultSyncServerHost}
|
||||
device={device}
|
||||
enableUnfinished={enableUnfinishedFeatures}
|
||||
websocketUrl={webSocketUrl}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
const domReady = document.readyState === 'complete' || document.readyState === 'interactive'
|
||||
|
||||
if (domReady) {
|
||||
renderApp()
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', function callback() {
|
||||
renderApp()
|
||||
|
||||
window.removeEventListener('DOMContentLoaded', callback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (IsWebPlatform) {
|
||||
startApplication(
|
||||
window.defaultSyncServer,
|
||||
new WebDevice(WebAppVersion),
|
||||
window.enabledUnfinishedFeatures,
|
||||
window.websocketUrl,
|
||||
).catch(console.error)
|
||||
} else {
|
||||
window.startApplication = startApplication
|
||||
}
|
||||
209
packages/web/src/javascripts/Application/Application.ts
Normal file
209
packages/web/src/javascripts/Application/Application.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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 { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice'
|
||||
import {
|
||||
DeinitSource,
|
||||
Platform,
|
||||
SNApplication,
|
||||
NoteGroupController,
|
||||
removeFromArray,
|
||||
IconsController,
|
||||
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 = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
desktopService?: DesktopManager
|
||||
autolockService: AutolockService
|
||||
archiveService: ArchiveManager
|
||||
themeService: ThemeManager
|
||||
io: IOService
|
||||
}
|
||||
|
||||
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,
|
||||
platform: Platform,
|
||||
identifier: string,
|
||||
defaultSyncServerHost: string,
|
||||
webSocketUrl: string,
|
||||
) {
|
||||
super({
|
||||
environment: deviceInterface.environment,
|
||||
platform: platform,
|
||||
deviceInterface: deviceInterface,
|
||||
crypto: WebCrypto,
|
||||
alertService: new WebAlertService(),
|
||||
identifier,
|
||||
defaultHost: defaultSyncServerHost,
|
||||
appVersion: deviceInterface.appVersion,
|
||||
webSocketUrl: webSocketUrl,
|
||||
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 {
|
||||
super.deinit(mode, source)
|
||||
|
||||
try {
|
||||
for (const service of Object.values(this.webServices)) {
|
||||
if (!service) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ('deinit' in service) {
|
||||
service.deinit?.(source)
|
||||
}
|
||||
|
||||
;(service as { application?: WebApplication }).application = undefined
|
||||
}
|
||||
|
||||
this.webServices = {} as WebServices
|
||||
|
||||
this.noteControllerGroup.deinit()
|
||||
;(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)
|
||||
}
|
||||
}
|
||||
|
||||
setWebServices(services: WebServices): void {
|
||||
this.webServices = services
|
||||
}
|
||||
|
||||
public addWebEventObserver(observer: WebEventObserver): () => void {
|
||||
this.webEventObservers.push(observer)
|
||||
|
||||
return () => {
|
||||
removeFromArray(this.webEventObservers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
public notifyWebEvent(event: WebAppEvent, data?: unknown): void {
|
||||
for (const observer of this.webEventObservers) {
|
||||
observer(event, data)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.webServices.desktopService
|
||||
}
|
||||
|
||||
public getAutolockService() {
|
||||
return this.webServices.autolockService
|
||||
}
|
||||
|
||||
public getArchiveService() {
|
||||
return this.webServices.archiveService
|
||||
}
|
||||
|
||||
public get desktopDevice(): DesktopDeviceInterface | undefined {
|
||||
if (isDesktopDevice(this.deviceInterface)) {
|
||||
return this.deviceInterface
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
public getThemeService() {
|
||||
return this.webServices.themeService
|
||||
}
|
||||
|
||||
public get io() {
|
||||
return this.webServices.io
|
||||
}
|
||||
|
||||
async checkForSecurityUpdate() {
|
||||
return this.protocolUpgradeAvailable()
|
||||
}
|
||||
|
||||
downloadBackup(): void | Promise<void> {
|
||||
if (isDesktopDevice(this.deviceInterface)) {
|
||||
return this.deviceInterface.downloadBackup()
|
||||
}
|
||||
}
|
||||
|
||||
async signOutAndDeleteLocalBackups(): Promise<void> {
|
||||
isDesktopDevice(this.deviceInterface) && (await this.deviceInterface.deleteLocalBackups())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
80
packages/web/src/javascripts/Application/ApplicationGroup.ts
Normal file
80
packages/web/src/javascripts/Application/ApplicationGroup.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { WebApplication } from './Application'
|
||||
import {
|
||||
ApplicationDescriptor,
|
||||
SNApplicationGroup,
|
||||
Platform,
|
||||
InternalEventBus,
|
||||
isDesktopDevice,
|
||||
} from '@standardnotes/snjs'
|
||||
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 '@/Application/Device/WebOrDesktopDevice'
|
||||
|
||||
const createApplication = (
|
||||
descriptor: ApplicationDescriptor,
|
||||
deviceInterface: WebOrDesktopDevice,
|
||||
defaultSyncServerHost: string,
|
||||
device: WebOrDesktopDevice,
|
||||
webSocketUrl: string,
|
||||
) => {
|
||||
const platform = getPlatform()
|
||||
|
||||
const application = new WebApplication(
|
||||
deviceInterface,
|
||||
platform,
|
||||
descriptor.identifier,
|
||||
defaultSyncServerHost,
|
||||
webSocketUrl,
|
||||
)
|
||||
|
||||
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({
|
||||
viewControllerManager,
|
||||
archiveService,
|
||||
desktopService: isDesktopDevice(device) ? new DesktopManager(application, device) : undefined,
|
||||
io,
|
||||
autolockService,
|
||||
themeService,
|
||||
})
|
||||
|
||||
return application
|
||||
}
|
||||
|
||||
export class ApplicationGroup extends SNApplicationGroup<WebOrDesktopDevice> {
|
||||
constructor(private defaultSyncServerHost: string, device: WebOrDesktopDevice, private webSocketUrl: string) {
|
||||
super(device)
|
||||
}
|
||||
|
||||
override async initialize(): Promise<void> {
|
||||
const defaultSyncServerHost = this.defaultSyncServerHost
|
||||
const webSocketUrl = this.webSocketUrl
|
||||
|
||||
await super.initialize({
|
||||
applicationCreator: async (descriptor, device) => {
|
||||
return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl)
|
||||
},
|
||||
})
|
||||
|
||||
if (isDesktopApplication()) {
|
||||
window.webClient = (this.primaryApplication as WebApplication).getDesktopService()
|
||||
}
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
super.deinit()
|
||||
|
||||
if (isDesktopApplication()) {
|
||||
delete window.webClient
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/web/src/javascripts/Application/Crypto.ts
Normal file
3
packages/web/src/javascripts/Application/Crypto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SNWebCrypto } from '@standardnotes/sncrypto-web'
|
||||
|
||||
export const WebCrypto = new SNWebCrypto()
|
||||
263
packages/web/src/javascripts/Application/Database.ts
Normal file
263
packages/web/src/javascripts/Application/Database.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { isString, AlertService, uniqueArray } from '@standardnotes/snjs'
|
||||
|
||||
const STORE_NAME = 'items'
|
||||
const READ_WRITE = 'readwrite'
|
||||
|
||||
const OUT_OF_SPACE =
|
||||
'Unable to save changes locally because your device is out of space. ' +
|
||||
'Please free up some disk space and try again, otherwise, your data may end ' +
|
||||
'up in an inconsistent state.'
|
||||
|
||||
const DB_DELETION_BLOCKED =
|
||||
'Your browser is blocking Standard Notes from deleting the local database. ' +
|
||||
'Make sure there are no other open windows of this app and try again. ' +
|
||||
'If the issue persists, please manually delete app data to sign out.'
|
||||
|
||||
const QUOTE_EXCEEDED_ERROR = 'QuotaExceededError'
|
||||
|
||||
export class Database {
|
||||
private locked = true
|
||||
private db?: IDBDatabase
|
||||
|
||||
constructor(public databaseName: string, private alertService?: AlertService) {}
|
||||
|
||||
public deinit(): void {
|
||||
;(this.alertService as unknown) = undefined
|
||||
this.db = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Relinquishes the lock and allows db operations to proceed
|
||||
*/
|
||||
public unlock(): void {
|
||||
this.locked = false
|
||||
}
|
||||
|
||||
static async getAllDatabaseNames(): Promise<string[] | undefined> {
|
||||
if (!window.indexedDB.databases) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rawDatabases = await window.indexedDB.databases()
|
||||
return rawDatabases.map((db) => db.name).filter((name) => name && name.length > 0) as string[]
|
||||
}
|
||||
|
||||
static async deleteAll(databaseNames: string[]): Promise<void> {
|
||||
if (window.indexedDB.databases != undefined) {
|
||||
const idbNames = await this.getAllDatabaseNames()
|
||||
|
||||
if (idbNames) {
|
||||
databaseNames = uniqueArray([...idbNames, ...databaseNames])
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of databaseNames) {
|
||||
const db = new Database(name)
|
||||
|
||||
await db.clearAllPayloads()
|
||||
|
||||
db.deinit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the database natively, or returns the existing database object if already opened.
|
||||
* @param onNewDatabase - Callback to invoke when a database has been created
|
||||
* as part of the open process. This can happen on new application sessions, or if the
|
||||
* browser deleted the database without the user being aware.
|
||||
*/
|
||||
public async openDatabase(onNewDatabase?: () => void): Promise<IDBDatabase | undefined> {
|
||||
if (this.locked) {
|
||||
throw Error('Attempting to open locked database')
|
||||
}
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
const request = window.indexedDB.open(this.databaseName, 1)
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onerror = (event) => {
|
||||
const target = event.target as any
|
||||
if (target.errorCode) {
|
||||
this.showAlert('Offline database issue: ' + target.errorCode)
|
||||
} else {
|
||||
this.displayOfflineAlert()
|
||||
}
|
||||
reject(new Error('Unable to open db'))
|
||||
}
|
||||
request.onblocked = (_event) => {
|
||||
reject(Error('IndexedDB open request blocked'))
|
||||
}
|
||||
request.onsuccess = (event) => {
|
||||
const target = event.target as IDBOpenDBRequest
|
||||
const db = target.result
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
}
|
||||
db.onerror = (errorEvent) => {
|
||||
const target = errorEvent?.target as any
|
||||
throw Error('Database error: ' + target.errorCode)
|
||||
}
|
||||
this.db = db
|
||||
resolve(db)
|
||||
}
|
||||
request.onupgradeneeded = (event) => {
|
||||
const target = event.target as IDBOpenDBRequest
|
||||
const db = target.result
|
||||
db.onversionchange = () => {
|
||||
db.close()
|
||||
}
|
||||
/* Create an objectStore for this database */
|
||||
const objectStore = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: 'uuid',
|
||||
})
|
||||
objectStore.createIndex('uuid', 'uuid', { unique: true })
|
||||
objectStore.transaction.oncomplete = () => {
|
||||
/* Ready to store values in the newly created objectStore. */
|
||||
if (db.version === 1 && onNewDatabase) {
|
||||
onNewDatabase && onNewDatabase()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async getAllPayloads(): Promise<any[]> {
|
||||
const db = (await this.openDatabase()) as IDBDatabase
|
||||
return new Promise((resolve) => {
|
||||
const objectStore = db.transaction(STORE_NAME).objectStore(STORE_NAME)
|
||||
const payloads: any = []
|
||||
const cursorRequest = objectStore.openCursor()
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const target = event.target as any
|
||||
const cursor = target.result
|
||||
if (cursor) {
|
||||
payloads.push(cursor.value)
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(payloads)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async getAllKeys(): Promise<string[]> {
|
||||
const db = (await this.openDatabase()) as IDBDatabase
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const objectStore = db.transaction(STORE_NAME).objectStore(STORE_NAME)
|
||||
const getAllKeysRequest = objectStore.getAllKeys()
|
||||
getAllKeysRequest.onsuccess = function () {
|
||||
const result = getAllKeysRequest.result
|
||||
|
||||
const strings = result.map((key) => {
|
||||
if (isString(key)) {
|
||||
return key
|
||||
} else {
|
||||
return JSON.stringify(key)
|
||||
}
|
||||
})
|
||||
|
||||
resolve(strings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async savePayload(payload: any): Promise<void> {
|
||||
return this.savePayloads([payload])
|
||||
}
|
||||
|
||||
public async savePayloads(payloads: any[]): Promise<void> {
|
||||
if (payloads.length === 0) {
|
||||
return
|
||||
}
|
||||
const db = (await this.openDatabase()) as IDBDatabase
|
||||
const transaction = db.transaction(STORE_NAME, READ_WRITE)
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
transaction.oncomplete = () => {}
|
||||
transaction.onerror = (event) => {
|
||||
const target = event.target as any
|
||||
this.showGenericError(target.error)
|
||||
}
|
||||
transaction.onabort = (event) => {
|
||||
const target = event.target as any
|
||||
const error = target.error
|
||||
if (error.name === QUOTE_EXCEEDED_ERROR) {
|
||||
this.showAlert(OUT_OF_SPACE)
|
||||
} else {
|
||||
this.showGenericError(error)
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
const objectStore = transaction.objectStore(STORE_NAME)
|
||||
this.putItems(objectStore, payloads).then(resolve).catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
private async putItems(objectStore: IDBObjectStore, items: any[]): Promise<void> {
|
||||
await Promise.all(
|
||||
items.map((item) => {
|
||||
return new Promise((resolve) => {
|
||||
const request = objectStore.put(item)
|
||||
request.onerror = resolve
|
||||
request.onsuccess = resolve
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
public async deletePayload(uuid: string): Promise<void> {
|
||||
const db = (await this.openDatabase()) as IDBDatabase
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = db.transaction(STORE_NAME, READ_WRITE).objectStore(STORE_NAME).delete(uuid)
|
||||
request.onsuccess = () => {
|
||||
resolve()
|
||||
}
|
||||
request.onerror = reject
|
||||
})
|
||||
}
|
||||
|
||||
public async clearAllPayloads(): Promise<void> {
|
||||
const deleteRequest = window.indexedDB.deleteDatabase(this.databaseName)
|
||||
return new Promise((resolve, reject) => {
|
||||
deleteRequest.onerror = () => {
|
||||
reject(Error('Error deleting database.'))
|
||||
}
|
||||
deleteRequest.onsuccess = () => {
|
||||
this.db = undefined
|
||||
resolve()
|
||||
}
|
||||
deleteRequest.onblocked = (_event) => {
|
||||
this.showAlert(DB_DELETION_BLOCKED)
|
||||
reject(Error('Delete request blocked'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showAlert(message: string) {
|
||||
if (this.alertService) {
|
||||
this.alertService.alert(message).catch(console.error)
|
||||
} else {
|
||||
window.alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
private showGenericError(error: { code: number; name: string }) {
|
||||
const message =
|
||||
'Unable to save changes locally due to an unknown system issue. ' +
|
||||
`Issue Code: ${error.code} Issue Name: ${error.name}.`
|
||||
|
||||
this.showAlert(message)
|
||||
}
|
||||
|
||||
private displayOfflineAlert() {
|
||||
const message =
|
||||
'There was an issue loading your offline database. This could happen for two reasons:' +
|
||||
"\n\n1. You're in a private window in your browser. We can't save your data without " +
|
||||
'access to the local database. Please use a non-private window.' +
|
||||
'\n\n2. You have two windows of the app open at the same time. ' +
|
||||
'Please close any other app instances and reload the page.'
|
||||
|
||||
this.showAlert(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
Environment,
|
||||
RawKeychainValue,
|
||||
DesktopDeviceInterface,
|
||||
WebOrDesktopDeviceInterface,
|
||||
DesktopClientRequiresWebMethods,
|
||||
FileBackupsMapping,
|
||||
FileBackupsDevice,
|
||||
} from '@standardnotes/snjs'
|
||||
@@ -0,0 +1,8 @@
|
||||
import { WebOrDesktopDevice } from './WebOrDesktopDevice'
|
||||
|
||||
export type StartApplication = (
|
||||
defaultSyncServerHost: string,
|
||||
device: WebOrDesktopDevice,
|
||||
enableUnfinishedFeatures: boolean,
|
||||
webSocketUrl: string,
|
||||
) => Promise<void>
|
||||
41
packages/web/src/javascripts/Application/Device/WebDevice.ts
Normal file
41
packages/web/src/javascripts/Application/Device/WebDevice.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Environment, RawKeychainValue } from '@standardnotes/snjs'
|
||||
import { WebOrDesktopDevice } from './WebOrDesktopDevice'
|
||||
|
||||
const KEYCHAIN_STORAGE_KEY = 'keychain'
|
||||
const DESTROYED_DEVICE_URL_PARAM = 'destroyed'
|
||||
const DESTROYED_DEVICE_URL_VALUE = 'true'
|
||||
|
||||
export class WebDevice extends WebOrDesktopDevice {
|
||||
environment = Environment.Web
|
||||
|
||||
async getKeychainValue(): Promise<RawKeychainValue> {
|
||||
const value = localStorage.getItem(KEYCHAIN_STORAGE_KEY)
|
||||
|
||||
if (value) {
|
||||
return JSON.parse(value)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
async setKeychainValue(value: RawKeychainValue): Promise<void> {
|
||||
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value))
|
||||
}
|
||||
|
||||
async clearRawKeychainValue(): Promise<void> {
|
||||
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
|
||||
}
|
||||
|
||||
async performHardReset(): Promise<void> {
|
||||
const url = new URL(window.location.href)
|
||||
const params = url.searchParams
|
||||
params.append(DESTROYED_DEVICE_URL_PARAM, DESTROYED_DEVICE_URL_VALUE)
|
||||
window.location.replace(url.href)
|
||||
}
|
||||
|
||||
public isDeviceDestroyed(): boolean {
|
||||
const url = new URL(window.location.href)
|
||||
const params = url.searchParams
|
||||
return params.get(DESTROYED_DEVICE_URL_PARAM) === DESTROYED_DEVICE_URL_VALUE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
SNApplication,
|
||||
ApplicationIdentifier,
|
||||
Environment,
|
||||
LegacyRawKeychainValue,
|
||||
RawKeychainValue,
|
||||
TransferPayload,
|
||||
NamespacedRootKeyInKeychain,
|
||||
extendArray,
|
||||
WebOrDesktopDeviceInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Database } from '../Database'
|
||||
|
||||
export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface {
|
||||
constructor(public appVersion: string) {}
|
||||
|
||||
private databases: Database[] = []
|
||||
|
||||
abstract environment: Environment
|
||||
|
||||
setApplication(application: SNApplication): void {
|
||||
const database = new Database(application.identifier, application.alertService)
|
||||
|
||||
this.databases.push(database)
|
||||
}
|
||||
|
||||
public async getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined> {
|
||||
const value = await this.getRawStorageValue(key)
|
||||
if (value == undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private databaseForIdentifier(identifier: ApplicationIdentifier) {
|
||||
return this.databases.find((database) => database.databaseName === identifier) as Database
|
||||
}
|
||||
|
||||
async clearAllDataFromDevice(workspaceIdentifiers: ApplicationIdentifier[]): Promise<{ killsApplication: boolean }> {
|
||||
await this.clearRawKeychainValue()
|
||||
|
||||
await this.removeAllRawStorageValues()
|
||||
|
||||
await Database.deleteAll(workspaceIdentifiers)
|
||||
|
||||
return { killsApplication: false }
|
||||
}
|
||||
|
||||
deinit() {
|
||||
for (const database of this.databases) {
|
||||
database.deinit()
|
||||
}
|
||||
|
||||
this.databases = []
|
||||
}
|
||||
|
||||
async getRawStorageValue(key: string): Promise<string | undefined> {
|
||||
const result = localStorage.getItem(key)
|
||||
|
||||
if (result == undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getAllRawStorageKeyValues() {
|
||||
const results = []
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
results.push({
|
||||
key: key,
|
||||
value: localStorage[key],
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async setRawStorageValue(key: string, value: string) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
async removeRawStorageValue(key: string) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
async removeAllRawStorageValues() {
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
async openDatabase(identifier: ApplicationIdentifier) {
|
||||
this.databaseForIdentifier(identifier).unlock()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.databaseForIdentifier(identifier)
|
||||
.openDatabase(() => {
|
||||
resolve({ isNewDatabase: true })
|
||||
})
|
||||
.then(() => {
|
||||
resolve({ isNewDatabase: false })
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
})
|
||||
}) as Promise<{ isNewDatabase?: boolean } | undefined>
|
||||
}
|
||||
|
||||
async getAllRawDatabasePayloads(identifier: ApplicationIdentifier) {
|
||||
return this.databaseForIdentifier(identifier).getAllPayloads()
|
||||
}
|
||||
|
||||
async saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier) {
|
||||
return this.databaseForIdentifier(identifier).savePayload(payload)
|
||||
}
|
||||
|
||||
async saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier) {
|
||||
return this.databaseForIdentifier(identifier).savePayloads(payloads)
|
||||
}
|
||||
|
||||
async removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier) {
|
||||
return this.databaseForIdentifier(identifier).deletePayload(id)
|
||||
}
|
||||
|
||||
async removeAllRawDatabasePayloads(identifier: ApplicationIdentifier) {
|
||||
return this.databaseForIdentifier(identifier).clearAllPayloads()
|
||||
}
|
||||
|
||||
async getNamespacedKeychainValue(identifier: ApplicationIdentifier) {
|
||||
const keychain = await this.getKeychainValue()
|
||||
|
||||
if (!keychain) {
|
||||
return
|
||||
}
|
||||
|
||||
return keychain[identifier]
|
||||
}
|
||||
|
||||
async getDatabaseKeys(): Promise<string[]> {
|
||||
const keys: string[] = []
|
||||
|
||||
for (const database of this.databases) {
|
||||
extendArray(keys, await database.getAllKeys())
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
async setNamespacedKeychainValue(value: NamespacedRootKeyInKeychain, identifier: ApplicationIdentifier) {
|
||||
let keychain = await this.getKeychainValue()
|
||||
|
||||
if (!keychain) {
|
||||
keychain = {}
|
||||
}
|
||||
|
||||
return this.setKeychainValue({
|
||||
...keychain,
|
||||
[identifier]: value,
|
||||
})
|
||||
}
|
||||
|
||||
async clearNamespacedKeychainValue(identifier: ApplicationIdentifier) {
|
||||
const keychain = await this.getKeychainValue()
|
||||
if (!keychain) {
|
||||
return
|
||||
}
|
||||
|
||||
delete keychain[identifier]
|
||||
|
||||
return this.setKeychainValue(keychain)
|
||||
}
|
||||
|
||||
setRawKeychainValue(value: unknown): Promise<void> {
|
||||
return this.setKeychainValue(value)
|
||||
}
|
||||
|
||||
openUrl(url: string) {
|
||||
const win = window.open(url, '_blank')
|
||||
if (win) {
|
||||
win.focus()
|
||||
}
|
||||
}
|
||||
|
||||
setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
|
||||
return this.setKeychainValue(value)
|
||||
}
|
||||
|
||||
abstract getKeychainValue(): Promise<RawKeychainValue>
|
||||
|
||||
abstract setKeychainValue(value: unknown): Promise<void>
|
||||
|
||||
abstract clearRawKeychainValue(): Promise<void>
|
||||
|
||||
abstract isDeviceDestroyed(): boolean
|
||||
|
||||
abstract performHardReset(): Promise<void>
|
||||
|
||||
async performSoftReset(): Promise<void> {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
9
packages/web/src/javascripts/Application/WebAppEvent.ts
Normal file
9
packages/web/src/javascripts/Application/WebAppEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum WebAppEvent {
|
||||
NewUpdateAvailable = 'NewUpdateAvailable',
|
||||
EditorFocused = 'EditorFocused',
|
||||
BeganBackupDownload = 'BeganBackupDownload',
|
||||
EndedBackupDownload = 'EndedBackupDownload',
|
||||
PanelResized = 'PanelResized',
|
||||
WindowDidFocus = 'WindowDidFocus',
|
||||
WindowDidBlur = 'WindowDidBlur',
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ApplicationEvent } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { autorun, IReactionDisposer, IReactionPublic } from 'mobx'
|
||||
import { Component } from 'react'
|
||||
|
||||
export type PureComponentState = Partial<Record<string, any>>
|
||||
export type PureComponentProps = Partial<Record<string, any>>
|
||||
|
||||
export abstract class PureComponent<P = PureComponentProps, S = PureComponentState> extends Component<P, S> {
|
||||
private unsubApp!: () => void
|
||||
private reactionDisposers: IReactionDisposer[] = []
|
||||
|
||||
constructor(props: P, protected application: WebApplication) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
this.addAppEventObserver()
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.unsubApp?.()
|
||||
|
||||
for (const disposer of this.reactionDisposers) {
|
||||
disposer()
|
||||
}
|
||||
|
||||
this.reactionDisposers.length = 0
|
||||
;(this.unsubApp as unknown) = undefined
|
||||
;(this.application as unknown) = undefined
|
||||
;(this.props as unknown) = undefined
|
||||
;(this.state as unknown) = undefined
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.deinit()
|
||||
}
|
||||
|
||||
public get viewControllerManager(): ViewControllerManager {
|
||||
return this.application.getViewControllerManager()
|
||||
}
|
||||
|
||||
autorun(view: (r: IReactionPublic) => void): void {
|
||||
this.reactionDisposers.push(autorun(view))
|
||||
}
|
||||
|
||||
addAppEventObserver() {
|
||||
if (this.application.isStarted()) {
|
||||
this.onAppStart().catch(console.error)
|
||||
}
|
||||
|
||||
if (this.application.isLaunched()) {
|
||||
this.onAppLaunch().catch(console.error)
|
||||
}
|
||||
|
||||
this.unsubApp = this.application.addEventObserver(async (eventName, data: unknown) => {
|
||||
if (!this.application) {
|
||||
return
|
||||
}
|
||||
|
||||
this.onAppEvent(eventName, data)
|
||||
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
await this.onAppStart()
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
await this.onAppLaunch()
|
||||
} else if (eventName === ApplicationEvent.CompletedIncrementalSync) {
|
||||
this.onAppIncrementalSync()
|
||||
} else if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
this.onAppFullSync()
|
||||
} else if (eventName === ApplicationEvent.KeyStatusChanged) {
|
||||
this.onAppKeyChange().catch(console.error)
|
||||
} else if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.onLocalDataLoaded()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onAppEvent(_eventName: ApplicationEvent, _data?: unknown) {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppStart() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onLocalDataLoaded() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppIncrementalSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
|
||||
onAppFullSync() {
|
||||
/** Optional override */
|
||||
}
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
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<HTMLDivElement>(null)
|
||||
useCloseOnClickOutside(ref, () => {
|
||||
onClickOutside()
|
||||
})
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = 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 (
|
||||
<div ref={ref} id="account-menu" className="sn-component">
|
||||
<div
|
||||
className={`sn-account-menu sn-dropdown ${
|
||||
shouldAnimateCloseMenu ? 'slide-up-animation' : 'sn-dropdown--animated'
|
||||
} min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto absolute`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MenuPaneSelector
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
menuPane={currentPane}
|
||||
setMenuPane={setCurrentPane}
|
||||
closeMenu={closeAccountMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AccountMenu)
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum AccountMenuPane {
|
||||
GeneralMenu,
|
||||
SignIn,
|
||||
Register,
|
||||
ConfirmPassword,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
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
|
||||
viewControllerManager: ViewControllerManager
|
||||
disabled?: boolean
|
||||
onPrivateWorkspaceChange?: (isPrivate: boolean, identifier?: string) => void
|
||||
onStrictSignInChange?: (isStrictSignIn: boolean) => void
|
||||
}
|
||||
|
||||
const AdvancedOptions: FunctionComponent<Props> = ({
|
||||
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 [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const recomputePrivateWorkspaceIdentifier = async () => {
|
||||
const identifier = await application.computePrivateWorkspaceIdentifier(
|
||||
privateWorkspaceName,
|
||||
privateWorkspaceUserphrase,
|
||||
)
|
||||
|
||||
if (!identifier) {
|
||||
if (privateWorkspaceName?.length > 0 && privateWorkspaceUserphrase?.length > 0) {
|
||||
application.alertService.alert('Unable to compute private workspace name.').catch(console.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
onPrivateWorkspaceChange?.(true, identifier)
|
||||
}
|
||||
|
||||
if (privateWorkspaceName && privateWorkspaceUserphrase) {
|
||||
recomputePrivateWorkspaceIdentifier().catch(console.error)
|
||||
}
|
||||
}, [privateWorkspaceName, privateWorkspaceUserphrase, application, onPrivateWorkspaceChange])
|
||||
|
||||
useEffect(() => {
|
||||
onPrivateWorkspaceChange?.(isPrivateWorkspace)
|
||||
}, [isPrivateWorkspace, onPrivateWorkspaceChange])
|
||||
|
||||
const handleIsPrivateWorkspaceChange = useCallback(() => {
|
||||
setIsPrivateWorkspace(!isPrivateWorkspace)
|
||||
}, [isPrivateWorkspace])
|
||||
|
||||
const handlePrivateWorkspaceNameChange = useCallback((name: string) => {
|
||||
setPrivateWorkspaceName(name)
|
||||
}, [])
|
||||
|
||||
const handlePrivateWorkspaceUserphraseChange = useCallback((userphrase: string) => {
|
||||
setPrivateWorkspaceUserphrase(userphrase)
|
||||
}, [])
|
||||
|
||||
const handleServerOptionChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none font-bold"
|
||||
onClick={toggleShowAdvanced}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Advanced options
|
||||
<Icon type="chevron-down" className="color-passive-1 ml-1" />
|
||||
</div>
|
||||
</button>
|
||||
{showAdvanced ? (
|
||||
<div className="px-3 my-2">
|
||||
{children}
|
||||
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="private-workspace"
|
||||
label="Private workspace"
|
||||
checked={isPrivateWorkspace}
|
||||
disabled={disabled}
|
||||
onChange={handleIsPrivateWorkspaceChange}
|
||||
/>
|
||||
<a href="https://standardnotes.com/help/80" target="_blank" rel="noopener noreferrer" title="Learn more">
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isPrivateWorkspace && (
|
||||
<>
|
||||
<DecoratedInput
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="server" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Userphrase"
|
||||
value={privateWorkspaceUserphrase}
|
||||
onChange={handlePrivateWorkspaceUserphraseChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DecoratedInput
|
||||
className={'mb-2'}
|
||||
left={[<Icon type="folder" className="color-neutral" />]}
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={privateWorkspaceName}
|
||||
onChange={handlePrivateWorkspaceNameChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onStrictSignInChange && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<Checkbox
|
||||
name="use-strict-signin"
|
||||
label="Use strict sign-in"
|
||||
checked={isStrictSignin}
|
||||
disabled={disabled}
|
||||
onChange={handleStrictSigninChange}
|
||||
/>
|
||||
<a
|
||||
href="https://standardnotes.com/help/security"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Learn more"
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
name="custom-sync-server"
|
||||
label="Custom sync server"
|
||||
checked={enableServerOption}
|
||||
onChange={handleServerOptionChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DecoratedInput
|
||||
type="text"
|
||||
left={[<Icon type="server" className="color-neutral" />]}
|
||||
placeholder="https://api.standardnotes.com"
|
||||
value={server}
|
||||
onChange={handleSyncServerChange}
|
||||
disabled={!enableServerOption && !disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AdvancedOptions)
|
||||
@@ -0,0 +1,160 @@
|
||||
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, 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 = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const ConfirmPassword: FunctionComponent<Props> = ({
|
||||
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<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
passwordInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handlePasswordChange = useCallback((text: string) => {
|
||||
setConfirmPassword(text)
|
||||
}, [])
|
||||
|
||||
const handleEphemeralChange = useCallback(() => {
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}, [isEphemeral])
|
||||
|
||||
const handleShouldMergeChange = useCallback(() => {
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}, [shouldMergeLocal])
|
||||
|
||||
const handleConfirmFormSubmit = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!password) {
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (password === confirmPassword) {
|
||||
setIsRegistering(true)
|
||||
application
|
||||
.register(email, password, isEphemeral, shouldMergeLocal)
|
||||
.then(() => {
|
||||
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: KeyboardEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirmFormSubmit(e)
|
||||
}
|
||||
},
|
||||
[handleConfirmFormSubmit, error],
|
||||
)
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}, [setMenuPane])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral p-0"
|
||||
onClick={handleGoBack}
|
||||
focusable={true}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Confirm password</div>
|
||||
</div>
|
||||
<div className="px-3 mb-3 text-sm">
|
||||
Because your notes are encrypted using your password,{' '}
|
||||
<span className="color-danger">Standard Notes does not have a password reset option</span>. If you forget your
|
||||
password, you will permanently lose access to your data.
|
||||
</div>
|
||||
<form onSubmit={handleConfirmFormSubmit} className="px-3 mb-1">
|
||||
<DecoratedPasswordInput
|
||||
className="mb-2"
|
||||
disabled={isRegistering}
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Confirm password"
|
||||
ref={passwordInputRef}
|
||||
value={confirmPassword}
|
||||
/>
|
||||
{error ? <div className="color-danger my-2">{error}</div> : null}
|
||||
<Button
|
||||
className="btn-w-full mt-1 mb-3"
|
||||
label={isRegistering ? 'Creating account...' : 'Create account & sign in'}
|
||||
variant="primary"
|
||||
onClick={handleConfirmFormSubmit}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
<Checkbox
|
||||
name="is-ephemeral"
|
||||
label="Stay signed in"
|
||||
checked={!isEphemeral}
|
||||
onChange={handleEphemeralChange}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
{notesAndTagsCount > 0 ? (
|
||||
<Checkbox
|
||||
name="should-merge-local"
|
||||
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
||||
checked={shouldMergeLocal}
|
||||
onChange={handleShouldMergeChange}
|
||||
disabled={isRegistering}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ConfirmPassword)
|
||||
@@ -0,0 +1,147 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AccountMenuPane } from './AccountMenuPane'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import IconButton from '@/Components/Button/IconButton'
|
||||
import AdvancedOptions from './AdvancedOptions'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
email: string
|
||||
setEmail: React.Dispatch<React.SetStateAction<string>>
|
||||
password: string
|
||||
setPassword: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const CreateAccount: FunctionComponent<Props> = ({
|
||||
viewControllerManager,
|
||||
application,
|
||||
setMenuPane,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
}) => {
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef.current) {
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleEmailChange = useCallback(
|
||||
(text: string) => {
|
||||
setEmail(text)
|
||||
},
|
||||
[setEmail],
|
||||
)
|
||||
|
||||
const handlePasswordChange = useCallback(
|
||||
(text: string) => {
|
||||
setPassword(text)
|
||||
},
|
||||
[setPassword],
|
||||
)
|
||||
|
||||
const handleRegisterFormSubmit = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
setEmail(email)
|
||||
setPassword(password)
|
||||
setMenuPane(AccountMenuPane.ConfirmPassword)
|
||||
},
|
||||
[email, password, setPassword, setMenuPane, setEmail],
|
||||
)
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRegisterFormSubmit(e)
|
||||
}
|
||||
},
|
||||
[handleRegisterFormSubmit],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.GeneralMenu)
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
}, [setEmail, setMenuPane, setPassword])
|
||||
|
||||
const onPrivateWorkspaceChange = useCallback(
|
||||
(isPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
||||
setIsPrivateWorkspace(isPrivateWorkspace)
|
||||
if (isPrivateWorkspace && privateWorkspaceIdentifier) {
|
||||
setEmail(privateWorkspaceIdentifier)
|
||||
}
|
||||
},
|
||||
[setEmail],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral p-0"
|
||||
onClick={handleClose}
|
||||
focusable={true}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Create account</div>
|
||||
</div>
|
||||
<form onSubmit={handleRegisterFormSubmit} className="px-3 mb-1">
|
||||
<DecoratedInput
|
||||
className="mb-2"
|
||||
disabled={isPrivateWorkspace}
|
||||
left={[<Icon type="email" className="color-neutral" />]}
|
||||
onChange={handleEmailChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Email"
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<DecoratedPasswordInput
|
||||
className="mb-2"
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Password"
|
||||
ref={passwordInputRef}
|
||||
value={password}
|
||||
/>
|
||||
<Button className="btn-w-full mt-1" label="Next" variant="primary" onClick={handleRegisterFormSubmit} />
|
||||
</form>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(CreateAccount)
|
||||
@@ -0,0 +1,188 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { SyncQueueStrategy } from '@standardnotes/snjs'
|
||||
import { STRING_GENERIC_SYNC_ERROR } from '@/Constants/Strings'
|
||||
import { useCallback, useMemo, useState, FunctionComponent } from 'react'
|
||||
import { AccountMenuPane } from './AccountMenuPane'
|
||||
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 WorkspaceSwitcherOption from './WorkspaceSwitcher/WorkspaceSwitcherOption'
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { formatLastSyncDate } from '@/Utils/FormatLastSyncDate'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
closeMenu: () => void
|
||||
}
|
||||
|
||||
const iconClassName = 'color-neutral mr-2'
|
||||
|
||||
const GeneralAccountMenu: FunctionComponent<Props> = ({
|
||||
application,
|
||||
viewControllerManager,
|
||||
setMenuPane,
|
||||
closeMenu,
|
||||
mainApplicationGroup,
|
||||
}) => {
|
||||
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false)
|
||||
const [lastSyncDate, setLastSyncDate] = useState(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||
|
||||
const doSynchronization = useCallback(async () => {
|
||||
setIsSyncingInProgress(true)
|
||||
|
||||
application.sync
|
||||
.sync({
|
||||
queueStrategy: SyncQueueStrategy.ForceSpawnNew,
|
||||
checkIntegrity: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && (res as any).error) {
|
||||
throw new Error()
|
||||
} else {
|
||||
setLastSyncDate(formatLastSyncDate(application.sync.getLastSyncDate() as Date))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
application.alertService.alert(STRING_GENERIC_SYNC_ERROR).catch(console.error)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSyncingInProgress(false)
|
||||
})
|
||||
}, [application])
|
||||
|
||||
const user = useMemo(() => application.getUser(), [application])
|
||||
|
||||
const openPreferences = useCallback(() => {
|
||||
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||
viewControllerManager.preferencesController.setCurrentPane('account')
|
||||
viewControllerManager.preferencesController.openPreferences()
|
||||
}, [viewControllerManager])
|
||||
|
||||
const openHelp = useCallback(() => {
|
||||
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||
viewControllerManager.preferencesController.setCurrentPane('help-feedback')
|
||||
viewControllerManager.preferencesController.openPreferences()
|
||||
}, [viewControllerManager])
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
viewControllerManager.accountMenuController.setSigningOut(true)
|
||||
}, [viewControllerManager])
|
||||
|
||||
const activateRegisterPane = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.Register)
|
||||
}, [setMenuPane])
|
||||
|
||||
const activateSignInPane = useCallback(() => {
|
||||
setMenuPane(AccountMenuPane.SignIn)
|
||||
}, [setMenuPane])
|
||||
|
||||
const CREATE_ACCOUNT_INDEX = 1
|
||||
const SWITCHER_INDEX = 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 mt-1 mb-1">
|
||||
<div className="sn-account-menu-headline">Account</div>
|
||||
<div className="flex cursor-pointer" onClick={closeMenu}>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</div>
|
||||
</div>
|
||||
{user ? (
|
||||
<>
|
||||
<div className="px-3 mb-3 color-foreground text-sm">
|
||||
<div>You're signed in as:</div>
|
||||
<div className="my-0.5 font-bold wrap">{user.email}</div>
|
||||
<span className="color-neutral">{application.getHost()}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between px-3 mb-3">
|
||||
{isSyncingInProgress ? (
|
||||
<div className="flex items-center color-info font-semibold">
|
||||
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
|
||||
Syncing...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start">
|
||||
<Icon type="check-circle" className="mr-2 success" />
|
||||
<div>
|
||||
<div className="font-semibold success">Last synced:</div>
|
||||
<div className="color-text">{lastSyncDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex cursor-pointer color-passive-1" onClick={doSynchronization}>
|
||||
<Icon type="sync" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 mb-1">
|
||||
<div className="mb-3 color-foreground">
|
||||
You’re offline. Sign in to sync your notes and preferences across all your devices and enable end-to-end
|
||||
encryption.
|
||||
</div>
|
||||
<div className="flex items-center color-passive-1">
|
||||
<Icon type="cloud-off" className="mr-2" />
|
||||
<span className="font-semibold">Offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Menu
|
||||
isOpen={viewControllerManager.accountMenuController.show}
|
||||
a11yLabel="General account menu"
|
||||
closeMenu={closeMenu}
|
||||
initialFocus={!application.hasAccount() ? CREATE_ACCOUNT_INDEX : SWITCHER_INDEX}
|
||||
>
|
||||
<MenuItemSeparator />
|
||||
<WorkspaceSwitcherOption
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
/>
|
||||
<MenuItemSeparator />
|
||||
{user ? (
|
||||
<MenuItem type={MenuItemType.IconButton} onClick={openPreferences}>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem type={MenuItemType.IconButton} onClick={activateRegisterPane}>
|
||||
<Icon type="user" className={iconClassName} />
|
||||
Create free account
|
||||
</MenuItem>
|
||||
<MenuItem type={MenuItemType.IconButton} onClick={activateSignInPane}>
|
||||
<Icon type="signIn" className={iconClassName} />
|
||||
Sign in
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem className="justify-between" type={MenuItemType.IconButton} onClick={openHelp}>
|
||||
<div className="flex items-center">
|
||||
<Icon type="help" className={iconClassName} />
|
||||
Help & feedback
|
||||
</div>
|
||||
<span className="color-neutral">v{application.version}</span>
|
||||
</MenuItem>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItemSeparator />
|
||||
<MenuItem type={MenuItemType.IconButton} onClick={signOut}>
|
||||
<Icon type="signOut" className={iconClassName} />
|
||||
Sign out workspace
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(GeneralAccountMenu)
|
||||
@@ -0,0 +1,72 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useState } from 'react'
|
||||
import { AccountMenuPane } from './AccountMenuPane'
|
||||
import ConfirmPassword from './ConfirmPassword'
|
||||
import CreateAccount from './CreateAccount'
|
||||
import GeneralAccountMenu from './GeneralAccountMenu'
|
||||
import SignInPane from './SignIn'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
menuPane: AccountMenuPane
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
closeMenu: () => void
|
||||
}
|
||||
|
||||
const MenuPaneSelector: FunctionComponent<Props> = ({
|
||||
application,
|
||||
viewControllerManager,
|
||||
menuPane,
|
||||
setMenuPane,
|
||||
closeMenu,
|
||||
mainApplicationGroup,
|
||||
}) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
switch (menuPane) {
|
||||
case AccountMenuPane.GeneralMenu:
|
||||
return (
|
||||
<GeneralAccountMenu
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
setMenuPane={setMenuPane}
|
||||
closeMenu={closeMenu}
|
||||
/>
|
||||
)
|
||||
case AccountMenuPane.SignIn:
|
||||
return (
|
||||
<SignInPane viewControllerManager={viewControllerManager} application={application} setMenuPane={setMenuPane} />
|
||||
)
|
||||
case AccountMenuPane.Register:
|
||||
return (
|
||||
<CreateAccount
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
setMenuPane={setMenuPane}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
)
|
||||
case AccountMenuPane.ConfirmPassword:
|
||||
return (
|
||||
<ConfirmPassword
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
setMenuPane={setMenuPane}
|
||||
email={email}
|
||||
password={password}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(MenuPaneSelector)
|
||||
214
packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx
Normal file
214
packages/web/src/javascripts/Components/AccountMenu/SignIn.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { isDev } from '@/Utils'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import React, { 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 DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import IconButton from '@/Components/Button/IconButton'
|
||||
import AdvancedOptions from './AdvancedOptions'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
setMenuPane: (pane: AccountMenuPane) => void
|
||||
}
|
||||
|
||||
const SignInPane: FunctionComponent<Props> = ({ application, viewControllerManager, setMenuPane }) => {
|
||||
const { notesAndTagsCount } = viewControllerManager.accountMenuController
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isEphemeral, setIsEphemeral] = useState(false)
|
||||
|
||||
const [isStrictSignin, setIsStrictSignin] = useState(false)
|
||||
const [isSigningIn, setIsSigningIn] = useState(false)
|
||||
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
|
||||
const [isPrivateWorkspace, setIsPrivateWorkspace] = useState(false)
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailInputRef?.current) {
|
||||
emailInputRef.current?.focus()
|
||||
}
|
||||
if (isDev && window.devAccountEmail) {
|
||||
setEmail(window.devAccountEmail)
|
||||
setPassword(window.devAccountPassword as string)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetInvalid = useCallback(() => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
}, [setError, error])
|
||||
|
||||
const handleEmailChange = useCallback((text: string) => {
|
||||
setEmail(text)
|
||||
}, [])
|
||||
|
||||
const handlePasswordChange = useCallback(
|
||||
(text: string) => {
|
||||
if (error.length) {
|
||||
setError('')
|
||||
}
|
||||
setPassword(text)
|
||||
},
|
||||
[setPassword, error],
|
||||
)
|
||||
|
||||
const handleEphemeralChange = useCallback(() => {
|
||||
setIsEphemeral(!isEphemeral)
|
||||
}, [isEphemeral])
|
||||
|
||||
const handleStrictSigninChange = useCallback(() => {
|
||||
setIsStrictSignin(!isStrictSignin)
|
||||
}, [isStrictSignin])
|
||||
|
||||
const handleShouldMergeChange = useCallback(() => {
|
||||
setShouldMergeLocal(!shouldMergeLocal)
|
||||
}, [shouldMergeLocal])
|
||||
|
||||
const signIn = useCallback(() => {
|
||||
setIsSigningIn(true)
|
||||
emailInputRef?.current?.blur()
|
||||
passwordInputRef?.current?.blur()
|
||||
|
||||
application
|
||||
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
|
||||
.then((res) => {
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
viewControllerManager.accountMenuController.closeAccountMenu()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
setError(err.message ?? err.toString())
|
||||
setPassword('')
|
||||
passwordInputRef?.current?.blur()
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSigningIn(false)
|
||||
})
|
||||
}, [viewControllerManager, application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
|
||||
|
||||
const onPrivateWorkspaceChange = useCallback(
|
||||
(newIsPrivateWorkspace: boolean, privateWorkspaceIdentifier?: string) => {
|
||||
setIsPrivateWorkspace(newIsPrivateWorkspace)
|
||||
if (newIsPrivateWorkspace && privateWorkspaceIdentifier) {
|
||||
setEmail(privateWorkspaceIdentifier)
|
||||
}
|
||||
},
|
||||
[setEmail],
|
||||
)
|
||||
|
||||
const handleSignInFormSubmit = useCallback(
|
||||
(e: React.SyntheticEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
emailInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password.length === 0) {
|
||||
passwordInputRef?.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
signIn()
|
||||
},
|
||||
[email, password, signIn],
|
||||
)
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSignInFormSubmit(e)
|
||||
}
|
||||
},
|
||||
[handleSignInFormSubmit],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center px-3 mt-1 mb-3">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
title="Go back"
|
||||
className="flex mr-2 color-neutral p-0"
|
||||
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
|
||||
focusable={true}
|
||||
disabled={isSigningIn}
|
||||
/>
|
||||
<div className="sn-account-menu-headline">Sign in</div>
|
||||
</div>
|
||||
<div className="px-3 mb-1">
|
||||
<DecoratedInput
|
||||
className={`mb-2 ${error ? 'border-danger' : null}`}
|
||||
left={[<Icon type="email" className="color-neutral" />]}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onFocus={resetInvalid}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isSigningIn || isPrivateWorkspace}
|
||||
ref={emailInputRef}
|
||||
/>
|
||||
<DecoratedPasswordInput
|
||||
className={`mb-2 ${error ? 'border-danger' : null}`}
|
||||
disabled={isSigningIn}
|
||||
left={[<Icon type="password" className="color-neutral" />]}
|
||||
onChange={handlePasswordChange}
|
||||
onFocus={resetInvalid}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Password"
|
||||
ref={passwordInputRef}
|
||||
value={password}
|
||||
/>
|
||||
{error ? <div className="color-danger my-2">{error}</div> : null}
|
||||
<Button
|
||||
className="btn-w-full mt-1 mb-3"
|
||||
label={isSigningIn ? 'Signing in...' : 'Sign in'}
|
||||
variant="primary"
|
||||
onClick={handleSignInFormSubmit}
|
||||
disabled={isSigningIn}
|
||||
/>
|
||||
<Checkbox
|
||||
name="is-ephemeral"
|
||||
label="Stay signed in"
|
||||
checked={!isEphemeral}
|
||||
disabled={isSigningIn}
|
||||
onChange={handleEphemeralChange}
|
||||
/>
|
||||
{notesAndTagsCount > 0 ? (
|
||||
<Checkbox
|
||||
name="should-merge-local"
|
||||
label={`Merge local data (${notesAndTagsCount} notes and tags)`}
|
||||
checked={shouldMergeLocal}
|
||||
disabled={isSigningIn}
|
||||
onChange={handleShouldMergeChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<AdvancedOptions
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
disabled={isSigningIn}
|
||||
onPrivateWorkspaceChange={onPrivateWorkspaceChange}
|
||||
onStrictSignInChange={handleStrictSigninChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(SignInPane)
|
||||
45
packages/web/src/javascripts/Components/AccountMenu/User.tsx
Normal file
45
packages/web/src/javascripts/Components/AccountMenu/User.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { User as UserType } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const User = ({ viewControllerManager, application }: Props) => {
|
||||
const { server } = viewControllerManager.accountMenuController
|
||||
const user = application.getUser() as UserType
|
||||
|
||||
return (
|
||||
<div className="sk-panel-section">
|
||||
{viewControllerManager.syncStatusController.errorMessage && (
|
||||
<div className="sk-notification danger">
|
||||
<div className="sk-notification-title">Sync Unreachable</div>
|
||||
<div className="sk-notification-text">
|
||||
Hmm...we can't seem to sync your account. The reason:{' '}
|
||||
{viewControllerManager.syncStatusController.errorMessage}
|
||||
</div>
|
||||
<a
|
||||
className="sk-a info-contrast sk-bold sk-panel-row"
|
||||
href="https://standardnotes.com/help"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Need help?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="sk-panel-row">
|
||||
<div className="sk-panel-column">
|
||||
<div className="sk-h1 sk-bold wrap">{user.email}</div>
|
||||
<div className="sk-subtitle neutral">{server}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sk-panel-row" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(User)
|
||||
@@ -0,0 +1,107 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import MenuItem from '@/Components/Menu/MenuItem'
|
||||
import { MenuItemType } from '@/Components/Menu/MenuItemType'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { ApplicationDescriptor } from '@standardnotes/snjs'
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
FunctionComponent,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
type Props = {
|
||||
descriptor: ApplicationDescriptor
|
||||
onClick: () => void
|
||||
onDelete: () => void
|
||||
renameDescriptor: (label: string) => void
|
||||
hideOptions: boolean
|
||||
}
|
||||
|
||||
const WorkspaceMenuItem: FunctionComponent<Props> = ({
|
||||
descriptor,
|
||||
onClick,
|
||||
onDelete,
|
||||
renameDescriptor,
|
||||
hideOptions,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(descriptor.label)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||
setInputValue(event.target.value)
|
||||
}, [])
|
||||
|
||||
const handleInputKeyDown: KeyboardEventHandler = useCallback((event) => {
|
||||
if (event.key === KeyboardKey.Enter) {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(() => {
|
||||
renameDescriptor(inputValue)
|
||||
setIsRenaming(false)
|
||||
setInputValue('')
|
||||
}, [inputValue, renameDescriptor])
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
type={MenuItemType.RadioButton}
|
||||
className="sn-dropdown-item py-2 focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={onClick}
|
||||
checked={descriptor.primary}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full ml-2">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div>{descriptor.label}</div>
|
||||
)}
|
||||
{descriptor.primary && !hideOptions && (
|
||||
<div>
|
||||
<a
|
||||
role="button"
|
||||
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsRenaming((isRenaming) => !isRenaming)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="sn-icon--mid color-neutral" />
|
||||
</a>
|
||||
<a
|
||||
role="button"
|
||||
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="sn-icon--mid color-danger" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceMenuItem
|
||||
@@ -0,0 +1,95 @@
|
||||
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, 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
|
||||
viewControllerManager: ViewControllerManager
|
||||
isOpen: boolean
|
||||
hideWorkspaceOptions?: boolean
|
||||
}
|
||||
|
||||
const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
|
||||
mainApplicationGroup,
|
||||
viewControllerManager,
|
||||
isOpen,
|
||||
hideWorkspaceOptions = false,
|
||||
}: Props) => {
|
||||
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||
setApplicationDescriptors(applicationDescriptors)
|
||||
|
||||
const removeAppGroupObserver = mainApplicationGroup.addEventObserver((event) => {
|
||||
if (event === ApplicationGroupEvent.DescriptorsDataChanged) {
|
||||
const applicationDescriptors = mainApplicationGroup.getDescriptors()
|
||||
setApplicationDescriptors(applicationDescriptors)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAppGroupObserver()
|
||||
}
|
||||
}, [mainApplicationGroup])
|
||||
|
||||
const signoutAll = useCallback(async () => {
|
||||
const confirmed = await 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 (
|
||||
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
|
||||
{applicationDescriptors.map((descriptor) => (
|
||||
<WorkspaceMenuItem
|
||||
key={descriptor.identifier}
|
||||
descriptor={descriptor}
|
||||
hideOptions={hideWorkspaceOptions}
|
||||
onDelete={destroyWorkspace}
|
||||
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
|
||||
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
|
||||
/>
|
||||
))}
|
||||
<MenuItemSeparator />
|
||||
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
|
||||
}}
|
||||
>
|
||||
<Icon type="user-add" className="color-neutral mr-2" />
|
||||
Add another workspace
|
||||
</MenuItem>
|
||||
|
||||
{!hideWorkspaceOptions && (
|
||||
<MenuItem type={MenuItemType.IconButton} onClick={signoutAll}>
|
||||
<Icon type="signOut" className="color-neutral mr-2" />
|
||||
Sign out all workspaces
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(WorkspaceSwitcherMenu)
|
||||
@@ -0,0 +1,72 @@
|
||||
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, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
|
||||
|
||||
type Props = {
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}, [isOpen, setIsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitem"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="user-switch" className="color-neutral mr-2" />
|
||||
Switch workspace
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
||||
<WorkspaceSwitcherMenu
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(WorkspaceSwitcherOption)
|
||||
@@ -0,0 +1,129 @@
|
||||
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
|
||||
device: WebOrDesktopDevice
|
||||
enableUnfinished: boolean
|
||||
websocketUrl: string
|
||||
onDestroy: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
activeApplication?: WebApplication
|
||||
dealloced?: boolean
|
||||
deallocSource?: DeinitSource
|
||||
deviceDestroyed?: boolean
|
||||
}
|
||||
|
||||
class ApplicationGroupView extends Component<Props, State> {
|
||||
applicationObserverRemover?: () => void
|
||||
private group?: ApplicationGroup
|
||||
private application?: WebApplication
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
|
||||
if (props.device.isDeviceDestroyed()) {
|
||||
this.state = {
|
||||
deviceDestroyed: true,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.group = new ApplicationGroup(props.server, props.device, props.websocketUrl)
|
||||
|
||||
window.mainApplicationGroup = this.group
|
||||
|
||||
this.applicationObserverRemover = this.group.addEventObserver((event, data) => {
|
||||
if (event === ApplicationGroupEvent.PrimaryApplicationSet) {
|
||||
const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.PrimaryApplicationSet]
|
||||
|
||||
this.application = castData.application as WebApplication
|
||||
this.setState({ activeApplication: this.application })
|
||||
} else if (event === ApplicationGroupEvent.DeviceWillRestart) {
|
||||
const castData = data as ApplicationGroupEventData[ApplicationGroupEvent.DeviceWillRestart]
|
||||
|
||||
this.setState({ dealloced: true, deallocSource: castData.source })
|
||||
}
|
||||
})
|
||||
|
||||
this.state = {}
|
||||
|
||||
this.group.initialize().catch(console.error)
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.application = undefined
|
||||
|
||||
this.applicationObserverRemover?.()
|
||||
;(this.applicationObserverRemover as unknown) = undefined
|
||||
|
||||
this.group?.deinit()
|
||||
;(this.group as unknown) = undefined
|
||||
|
||||
this.setState({ dealloced: true, activeApplication: undefined })
|
||||
|
||||
const onDestroy = this.props.onDestroy
|
||||
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
override render() {
|
||||
const renderDialog = (message: string) => {
|
||||
return (
|
||||
<DialogOverlay className={'sn-component challenge-modal-overlay'}>
|
||||
<DialogContent
|
||||
aria-label="Switching workspace"
|
||||
className={
|
||||
'challenge-modal flex flex-col items-center bg-default p-8 rounded relative shadow-overlay-light border-1 border-solid border-main'
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
if (this.state.deviceDestroyed) {
|
||||
const message = `Secure memory has destroyed this application instance. ${
|
||||
isDesktopApplication()
|
||||
? 'Restart the app to continue.'
|
||||
: 'Close this browser tab and open a new one to continue.'
|
||||
}`
|
||||
|
||||
return renderDialog(message)
|
||||
}
|
||||
|
||||
if (this.state.dealloced) {
|
||||
const message = this.state.deallocSource === DeinitSource.Lock ? 'Locking workspace...' : 'Switching workspace...'
|
||||
return renderDialog(message)
|
||||
}
|
||||
|
||||
if (!this.group || !this.state.activeApplication || this.state.activeApplication.dealloced) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={this.state.activeApplication.identifier} key={this.state.activeApplication.ephemeralIdentifier}>
|
||||
<DeallocateHandler application={this.state.activeApplication}>
|
||||
<ApplicationView
|
||||
key={this.state.activeApplication.ephemeralIdentifier}
|
||||
mainApplicationGroup={this.group}
|
||||
application={this.state.activeApplication}
|
||||
/>
|
||||
</DeallocateHandler>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ApplicationGroupView
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { getPlatformString, getWindowUrlParams } from '@/Utils'
|
||||
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 '@/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 FilePreviewModalWrapper from '@/Components/FilePreview/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
|
||||
}
|
||||
|
||||
const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
|
||||
const platformString = getPlatformString()
|
||||
const [appClass, setAppClass] = useState('')
|
||||
const [launched, setLaunched] = useState(false)
|
||||
const [needsUnlock, setNeedsUnlock] = useState(true)
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
|
||||
const viewControllerManager = application.getViewControllerManager()
|
||||
|
||||
useEffect(() => {
|
||||
const desktopService = application.getDesktopService()
|
||||
|
||||
if (desktopService) {
|
||||
application.componentManager.setDesktopManager(desktopService)
|
||||
}
|
||||
|
||||
application
|
||||
.prepareForLaunch({
|
||||
receiveChallenge: async (challenge) => {
|
||||
const challengesCopy = challenges.slice()
|
||||
challengesCopy.push(challenge)
|
||||
setChallenges(challengesCopy)
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
void application.launch()
|
||||
})
|
||||
.catch(console.error)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [application])
|
||||
|
||||
const removeChallenge = useCallback(
|
||||
(challenge: Challenge) => {
|
||||
const challengesCopy = challenges.slice()
|
||||
removeFromArray(challengesCopy, challenge)
|
||||
setChallenges(challengesCopy)
|
||||
},
|
||||
[challenges],
|
||||
)
|
||||
|
||||
const onAppStart = useCallback(() => {
|
||||
setNeedsUnlock(application.hasPasscode())
|
||||
}, [application])
|
||||
|
||||
const handleDemoSignInFromParams = useCallback(() => {
|
||||
const token = getWindowUrlParams().get('demo-token')
|
||||
if (!token || application.hasAccount()) {
|
||||
return
|
||||
}
|
||||
|
||||
void application.sessions.populateSessionFromDemoShareToken(token)
|
||||
}, [application])
|
||||
|
||||
const onAppLaunch = useCallback(() => {
|
||||
setLaunched(true)
|
||||
setNeedsUnlock(false)
|
||||
handleDemoSignInFromParams()
|
||||
}, [handleDemoSignInFromParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (application.isStarted()) {
|
||||
onAppStart()
|
||||
}
|
||||
|
||||
if (application.isLaunched()) {
|
||||
onAppLaunch()
|
||||
}
|
||||
|
||||
const removeAppObserver = application.addEventObserver(async (eventName) => {
|
||||
if (eventName === ApplicationEvent.Started) {
|
||||
onAppStart()
|
||||
} else if (eventName === ApplicationEvent.Launched) {
|
||||
onAppLaunch()
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseReadError) {
|
||||
alertDialog({
|
||||
text: 'Unable to load local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
alertDialog({
|
||||
text: 'Unable to write to local database. Please restart the app and try again.',
|
||||
}).catch(console.error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeAppObserver()
|
||||
}
|
||||
}, [application, onAppLaunch, onAppStart])
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addWebEventObserver(async (eventName, data) => {
|
||||
if (eventName === WebAppEvent.PanelResized) {
|
||||
const { panel, collapsed } = data as PanelResizedData
|
||||
let appClass = ''
|
||||
if (panel === PANEL_NAME_NOTES && collapsed) {
|
||||
appClass += 'collapsed-notes'
|
||||
}
|
||||
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
|
||||
appClass += ' collapsed-navigation'
|
||||
}
|
||||
setAppClass(appClass)
|
||||
} else if (eventName === WebAppEvent.WindowDidFocus) {
|
||||
if (!(await application.isLocked())) {
|
||||
application.sync.sync().catch(console.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const renderAppContents = useMemo(() => {
|
||||
return !needsUnlock && launched
|
||||
}, [needsUnlock, launched])
|
||||
|
||||
const renderChallenges = useCallback(() => {
|
||||
return (
|
||||
<>
|
||||
{challenges.map((challenge) => {
|
||||
return (
|
||||
<div className="sk-modal" key={`${challenge.id}${application.ephemeralIdentifier}`}>
|
||||
<ChallengeModal
|
||||
key={`${challenge.id}${application.ephemeralIdentifier}`}
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
challenge={challenge}
|
||||
onDismiss={removeChallenge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application])
|
||||
|
||||
if (!renderAppContents) {
|
||||
return renderChallenges()
|
||||
}
|
||||
|
||||
return (
|
||||
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
|
||||
<div className={platformString + ' main-ui-view sn-component'}>
|
||||
<div id="app" className={appClass + ' app app-column-container'}>
|
||||
<Navigation application={application} />
|
||||
<ContentListView application={application} viewControllerManager={viewControllerManager} />
|
||||
<NoteGroupView application={application} />
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Footer application={application} applicationGroup={mainApplicationGroup} />
|
||||
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
|
||||
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
|
||||
<RevisionHistoryModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
</>
|
||||
|
||||
{renderChallenges()}
|
||||
|
||||
<>
|
||||
<NotesContextMenu
|
||||
application={application}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
noteTagsController={viewControllerManager.noteTagsController}
|
||||
/>
|
||||
<TagsContextMenuWrapper viewControllerManager={viewControllerManager} />
|
||||
<FileContextMenuWrapper
|
||||
filesController={viewControllerManager.filesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
/>
|
||||
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<ConfirmSignoutContainer
|
||||
applicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
application={application}
|
||||
/>
|
||||
<ToastContainer />
|
||||
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
|
||||
<PermissionsModalWrapper application={application} />
|
||||
</>
|
||||
</div>
|
||||
</PremiumModalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApplicationView
|
||||
@@ -0,0 +1,314 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
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, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { FileItem, SNNote } from '@standardnotes/snjs'
|
||||
import { addToast, ToastType } from '@standardnotes/stylekit'
|
||||
import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||
import AttachedFilesPopover from './AttachedFilesPopover'
|
||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||
import { PopoverTabs } from './PopoverTabs'
|
||||
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
|
||||
import { NotesController } from '@/Controllers/NotesController'
|
||||
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
featuresController: FeaturesController
|
||||
filePreviewModalController: FilePreviewModalController
|
||||
filesController: FilesController
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
selectionController: SelectedItemsController
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const AttachedFilesButton: FunctionComponent<Props> = ({
|
||||
application,
|
||||
featuresController,
|
||||
filesController,
|
||||
filePreviewModalController,
|
||||
navigationController,
|
||||
notesController,
|
||||
selectionController,
|
||||
onClickPreprocessing,
|
||||
}: Props) => {
|
||||
const { allFiles, attachedFiles } = filesController
|
||||
const attachedFilesCount = attachedFiles.length
|
||||
|
||||
const premiumModal = usePremiumModal()
|
||||
const note: SNNote | undefined = notesController.firstSelectedNote
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (filePreviewModalController.isOpen) {
|
||||
keepMenuOpen(true)
|
||||
} else {
|
||||
keepMenuOpen(false)
|
||||
}
|
||||
}, [filePreviewModalController.isOpen, keepMenuOpen])
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(
|
||||
navigationController.isInFilesView ? PopoverTabs.AllFiles : PopoverTabs.AttachedFiles,
|
||||
)
|
||||
|
||||
const isAttachedTabDisabled = navigationController.isInFilesView || selectionController.selectedItemsCount > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (isAttachedTabDisabled && currentTab === PopoverTabs.AttachedFiles) {
|
||||
setCurrentTab(PopoverTabs.AllFiles)
|
||||
}
|
||||
}, [currentTab, isAttachedTabDisabled])
|
||||
|
||||
const toggleAttachedFilesMenu = useCallback(async () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
|
||||
const newOpenState = !open
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setOpen(newOpenState)
|
||||
}
|
||||
}, [onClickPreprocessing, open])
|
||||
|
||||
const prospectivelyShowFilesPremiumModal = useCallback(() => {
|
||||
if (!featuresController.hasFiles) {
|
||||
premiumModal.activate('Files')
|
||||
}
|
||||
}, [featuresController.hasFiles, premiumModal])
|
||||
|
||||
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
|
||||
prospectivelyShowFilesPremiumModal()
|
||||
|
||||
await toggleAttachedFilesMenu()
|
||||
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
|
||||
|
||||
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 [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (isHandlingFileDrag(event, application)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDragIn = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
switch ((event.target as HTMLElement).id) {
|
||||
case PopoverTabs.AllFiles:
|
||||
setCurrentTab(PopoverTabs.AllFiles)
|
||||
break
|
||||
case PopoverTabs.AttachedFiles:
|
||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||
break
|
||||
}
|
||||
|
||||
dragCounter.current = dragCounter.current + 1
|
||||
|
||||
if (event.dataTransfer?.items.length) {
|
||||
setIsDraggingFiles(true)
|
||||
if (!open) {
|
||||
toggleAttachedFilesMenu().catch(console.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, toggleAttachedFilesMenu, application],
|
||||
)
|
||||
|
||||
const handleDragOut = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current - 1
|
||||
|
||||
if (dragCounter.current > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
|
||||
if (!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
|
||||
}
|
||||
|
||||
const uploadedFiles = await filesController.uploadNewFile(fileOrHandle)
|
||||
|
||||
if (!uploadedFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
attachFileToNote(file).catch(console.error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
event.dataTransfer.clearData()
|
||||
dragCounter.current = 0
|
||||
}
|
||||
},
|
||||
[
|
||||
filesController,
|
||||
featuresController.hasFiles,
|
||||
attachFileToNote,
|
||||
currentTab,
|
||||
application,
|
||||
prospectivelyShowFilesPremiumModal,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragenter', handleDragIn)
|
||||
window.addEventListener('dragleave', handleDragOut)
|
||||
window.addEventListener('dragover', handleDrag)
|
||||
window.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragIn)
|
||||
window.removeEventListener('dragleave', handleDragOut)
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
ref={buttonRef}
|
||||
className={`sn-icon-button border-contrast ${attachedFilesCount > 0 ? 'py-1 px-3' : ''}`}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<VisuallyHidden>Attached files</VisuallyHidden>
|
||||
<Icon type="attachment-file" className="block" />
|
||||
{attachedFilesCount > 0 && <span className="ml-2">{attachedFilesCount}</span>}
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
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 && (
|
||||
<AttachedFilesPopover
|
||||
application={application}
|
||||
filesController={filesController}
|
||||
attachedFiles={attachedFiles}
|
||||
allFiles={allFiles}
|
||||
closeOnBlur={closeOnBlur}
|
||||
currentTab={currentTab}
|
||||
isDraggingFiles={isDraggingFiles}
|
||||
setCurrentTab={setCurrentTab}
|
||||
attachedTabDisabled={isAttachedTabDisabled}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AttachedFilesButton)
|
||||
@@ -0,0 +1,185 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { FilesIllustration } from '@standardnotes/icons'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
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 { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
import { PopoverTabs } from './PopoverTabs'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
filesController: FilesController
|
||||
allFiles: FileItem[]
|
||||
attachedFiles: FileItem[]
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
currentTab: PopoverTabs
|
||||
isDraggingFiles: boolean
|
||||
setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
|
||||
attachedTabDisabled: boolean
|
||||
}
|
||||
|
||||
const AttachedFilesPopover: FunctionComponent<Props> = ({
|
||||
application,
|
||||
filesController,
|
||||
allFiles,
|
||||
attachedFiles,
|
||||
closeOnBlur,
|
||||
currentTab,
|
||||
isDraggingFiles,
|
||||
setCurrentTab,
|
||||
attachedTabDisabled,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
|
||||
|
||||
const filteredList =
|
||||
searchQuery.length > 0
|
||||
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
|
||||
: filesList
|
||||
|
||||
const handleAttachFilesClick = async () => {
|
||||
const uploadedFiles = await filesController.uploadNewFile()
|
||||
if (!uploadedFiles) {
|
||||
return
|
||||
}
|
||||
if (currentTab === PopoverTabs.AttachedFiles) {
|
||||
uploadedFiles.forEach((file) => {
|
||||
filesController
|
||||
.handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: { file },
|
||||
})
|
||||
.catch(console.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const previewHandler = (file: FileItem) => {
|
||||
filesController
|
||||
.handleFileAction({
|
||||
type: PopoverFileItemActionType.PreviewFile,
|
||||
payload: { file, otherFiles: currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles },
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
style={{
|
||||
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
|
||||
}}
|
||||
>
|
||||
<div className="flex border-0 border-b-1 border-solid border-main">
|
||||
<button
|
||||
id={PopoverTabs.AttachedFiles}
|
||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||
currentTab === PopoverTabs.AttachedFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
||||
} ${attachedTabDisabled ? 'color-neutral cursor-not-allowed' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AttachedFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
disabled={attachedTabDisabled}
|
||||
>
|
||||
Attached
|
||||
</button>
|
||||
<button
|
||||
id={PopoverTabs.AllFiles}
|
||||
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
|
||||
currentTab === PopoverTabs.AllFiles ? 'color-info font-medium shadow-bottom' : 'color-text'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentTab(PopoverTabs.AllFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
All files
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 max-h-110 overflow-y-auto">
|
||||
{filteredList.length > 0 || searchQuery.length > 0 ? (
|
||||
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="color-text w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onInput={(e) => {
|
||||
setSearchQuery((e.target as HTMLInputElement).value)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<button
|
||||
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
searchInputRef.current?.focus()
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="clear-circle-filled" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{filteredList.length > 0 ? (
|
||||
filteredList.map((file: FileItem) => {
|
||||
return (
|
||||
<PopoverFileItem
|
||||
key={file.uuid}
|
||||
file={file}
|
||||
isAttachedToNote={attachedFiles.includes(file)}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
getIconType={application.iconsController.getIconForFileType}
|
||||
closeOnBlur={closeOnBlur}
|
||||
previewHandler={previewHandler}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full py-8">
|
||||
<div className="w-18 h-18 mb-2">
|
||||
<FilesIllustration />
|
||||
</div>
|
||||
<div className="text-sm font-medium mb-3">
|
||||
{searchQuery.length > 0
|
||||
? 'No result found'
|
||||
: currentTab === PopoverTabs.AttachedFiles
|
||||
? 'No files attached to this note'
|
||||
: 'No files found in this account'}
|
||||
</div>
|
||||
<Button variant="normal" onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||
</Button>
|
||||
<div className="text-xs color-passive-0 mt-3">Or drop your files here</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredList.length > 0 && (
|
||||
<button
|
||||
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
|
||||
onClick={handleAttachFilesClick}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<Icon type="add" className="mr-2 color-neutral" />
|
||||
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(AttachedFilesPopover)
|
||||
@@ -0,0 +1,126 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import {
|
||||
FormEventHandler,
|
||||
FunctionComponent,
|
||||
KeyboardEventHandler,
|
||||
useCallback,
|
||||
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'
|
||||
|
||||
const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
|
||||
file,
|
||||
isAttachedToNote,
|
||||
handleFileAction,
|
||||
getIconType,
|
||||
closeOnBlur,
|
||||
previewHandler,
|
||||
}) => {
|
||||
const [fileName, setFileName] = useState(file.name)
|
||||
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const fileNameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenamingFile) {
|
||||
fileNameInputRef.current?.focus()
|
||||
}
|
||||
}, [isRenamingFile])
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (file: FileItem, name: string) => {
|
||||
if (name.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file,
|
||||
name,
|
||||
},
|
||||
})
|
||||
setIsRenamingFile(false)
|
||||
},
|
||||
[handleFileAction],
|
||||
)
|
||||
|
||||
const handleFileNameInput: FormEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||
setFileName((event.target as HTMLInputElement).value)
|
||||
}, [])
|
||||
|
||||
const handleFileNameInputKeyDown: KeyboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (fileName.length > 0 && event.key === KeyboardKey.Enter) {
|
||||
itemRef.current?.focus()
|
||||
}
|
||||
},
|
||||
[fileName.length],
|
||||
)
|
||||
|
||||
const handleFileNameInputBlur = useCallback(() => {
|
||||
renameFile(file, fileName).catch(console.error)
|
||||
}, [file, fileName, renameFile])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRenamingFile) {
|
||||
return
|
||||
}
|
||||
|
||||
previewHandler(file)
|
||||
}, [file, isRenamingFile, previewHandler])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="flex items-center justify-between p-3 focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
<div onClick={handleClick} className="flex items-center cursor-pointer">
|
||||
{getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
|
||||
<div className="flex flex-col mx-4">
|
||||
{isRenamingFile ? (
|
||||
<input
|
||||
type="text"
|
||||
className="text-input px-1.5 py-1 mb-1 border-1 border-solid border-main bg-transparent color-foreground"
|
||||
value={fileName}
|
||||
ref={fileNameInputRef}
|
||||
onInput={handleFileNameInput}
|
||||
onKeyDown={handleFileNameInputKeyDown}
|
||||
onBlur={handleFileNameInputBlur}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm mb-1 break-word">
|
||||
<span className="vertical-middle">{file.name}</span>
|
||||
{file.protected && (
|
||||
<Icon type="lock-filled" className="sn-icon--small ml-2 color-neutral vertical-middle" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs color-passive-0">
|
||||
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PopoverFileSubmenu
|
||||
file={file}
|
||||
isAttachedToNote={isAttachedToNote}
|
||||
handleFileAction={handleFileAction}
|
||||
setIsRenamingFile={setIsRenamingFile}
|
||||
closeOnBlur={closeOnBlur}
|
||||
previewHandler={previewHandler}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PopoverFileItem
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
|
||||
export enum PopoverFileItemActionType {
|
||||
AttachFileToNote,
|
||||
DetachFileToNote,
|
||||
DeleteFile,
|
||||
DownloadFile,
|
||||
RenameFile,
|
||||
ToggleFileProtection,
|
||||
PreviewFile,
|
||||
}
|
||||
|
||||
export type PopoverFileItemAction =
|
||||
| {
|
||||
type: Exclude<
|
||||
PopoverFileItemActionType,
|
||||
| PopoverFileItemActionType.RenameFile
|
||||
| PopoverFileItemActionType.ToggleFileProtection
|
||||
| PopoverFileItemActionType.PreviewFile
|
||||
>
|
||||
payload: {
|
||||
file: FileItem
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.ToggleFileProtection
|
||||
payload: {
|
||||
file: FileItem
|
||||
}
|
||||
callback: (isProtected: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.RenameFile
|
||||
payload: {
|
||||
file: FileItem
|
||||
name: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.PreviewFile
|
||||
payload: {
|
||||
file: FileItem
|
||||
otherFiles: FileItem[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IconType, FileItem } from '@standardnotes/snjs'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { PopoverFileItemAction } from './PopoverFileItemAction'
|
||||
|
||||
type CommonProps = {
|
||||
file: FileItem
|
||||
isAttachedToNote: boolean
|
||||
handleFileAction: (action: PopoverFileItemAction) => Promise<{
|
||||
didHandleAction: boolean
|
||||
}>
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
previewHandler: (file: FileItem) => void
|
||||
}
|
||||
|
||||
export type PopoverFileItemProps = CommonProps & {
|
||||
getIconType(type: string): IconType
|
||||
}
|
||||
|
||||
export type PopoverFileSubmenuProps = CommonProps & {
|
||||
setIsRenamingFile: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { PopoverFileSubmenuProps } from './PopoverFileItemProps'
|
||||
import { PopoverFileItemActionType } from './PopoverFileItemAction'
|
||||
|
||||
const PopoverFileSubmenu: FunctionComponent<PopoverFileSubmenuProps> = ({
|
||||
file,
|
||||
isAttachedToNote,
|
||||
handleFileAction,
|
||||
setIsRenamingFile,
|
||||
previewHandler,
|
||||
}) => {
|
||||
const menuContainerRef = useRef<HTMLDivElement>(null)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [isFileProtected, setIsFileProtected] = useState(file.protected)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: 'auto',
|
||||
})
|
||||
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
if (!isMenuOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsMenuOpen(!isMenuOpen)
|
||||
}, [isMenuOpen])
|
||||
|
||||
const recalculateMenuStyle = useCallback(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
setTimeout(() => {
|
||||
recalculateMenuStyle()
|
||||
})
|
||||
}
|
||||
}, [isMenuOpen, recalculateMenuStyle])
|
||||
|
||||
return (
|
||||
<div ref={menuContainerRef}>
|
||||
<Disclosure open={isMenuOpen} onChange={toggleMenu}>
|
||||
<DisclosureButton
|
||||
ref={menuButtonRef}
|
||||
onBlur={closeOnBlur}
|
||||
className="w-7 h-7 p-1 rounded-full border-0 bg-transparent hover:bg-contrast cursor-pointer"
|
||||
>
|
||||
<Icon type="more" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={menuRef}
|
||||
style={{
|
||||
...menuStyle,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-60 py-1 fixed overflow-y-auto"
|
||||
>
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
previewHandler(file)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="file" className="mr-2 color-neutral" />
|
||||
Preview file
|
||||
</button>
|
||||
{isAttachedToNote ? (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DetachFileToNote,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link-off" className="mr-2 color-neutral" />
|
||||
Detach from note
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="link" className="mr-2 color-neutral" />
|
||||
Attach to note
|
||||
</button>
|
||||
)}
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.ToggleFileProtection,
|
||||
payload: { file },
|
||||
callback: (isProtected: boolean) => {
|
||||
setIsFileProtected(isProtected)
|
||||
},
|
||||
}).catch(console.error)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className="mr-2 color-neutral" />
|
||||
Password protection
|
||||
</span>
|
||||
<Switch
|
||||
className="px-0 pointer-events-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
checked={isFileProtected}
|
||||
/>
|
||||
</button>
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DownloadFile,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="download" className="mr-2 color-neutral" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
setIsRenamingFile(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="mr-2 color-neutral" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DeleteFile,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="mr-2 color-danger" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PopoverFileSubmenu
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles = 'attached-files-tab',
|
||||
AllFiles = 'all-files-tab',
|
||||
}
|
||||
@@ -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 <IconComponent className={className} />
|
||||
}
|
||||
25
packages/web/src/javascripts/Components/Bubble/Bubble.tsx
Normal file
25
packages/web/src/javascripts/Components/Bubble/Bubble.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
const styles = {
|
||||
base: 'px-2 py-1.5 text-center rounded-full cursor-pointer transition border-1 border-solid active:border-info active:bg-info active:color-neutral-contrast',
|
||||
unselected: 'color-neutral border-secondary',
|
||||
selected: 'border-info bg-info color-neutral-contrast',
|
||||
}
|
||||
|
||||
const Bubble: FunctionComponent<Props> = ({ label, selected, onSelect }) => (
|
||||
<span
|
||||
role="tab"
|
||||
className={`bubble ${styles.base} ${selected ? styles.selected : styles.unselected}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
export default Bubble
|
||||
68
packages/web/src/javascripts/Components/Button/Button.tsx
Normal file
68
packages/web/src/javascripts/Components/Button/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Ref, forwardRef, ReactNode, ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
const baseClass = 'rounded px-4 py-1.75 font-bold text-sm fit-content'
|
||||
|
||||
type ButtonVariant = 'normal' | 'primary'
|
||||
|
||||
const getClassName = (variant: ButtonVariant, danger: boolean, disabled: boolean) => {
|
||||
const borders = variant === 'normal' ? 'border-solid border-main border-1' : 'no-border'
|
||||
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
|
||||
let colors = variant === 'normal' ? 'bg-default color-text' : 'bg-info color-info-contrast'
|
||||
|
||||
let focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
|
||||
: 'hover:brightness-130 focus:outline-none focus:brightness-130'
|
||||
|
||||
if (danger) {
|
||||
colors = variant === 'normal' ? 'bg-default color-danger' : 'bg-danger color-info-contrast'
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
colors = variant === 'normal' ? 'bg-default color-passive-2' : 'bg-passive-2 color-info-contrast'
|
||||
focusHoverStates =
|
||||
variant === 'normal'
|
||||
? 'focus:bg-default focus:outline-none hover:bg-default'
|
||||
: 'focus:brightness-default focus:outline-none hover:brightness-default'
|
||||
}
|
||||
|
||||
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`
|
||||
}
|
||||
|
||||
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
variant?: ButtonVariant
|
||||
dangerStyle?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
const Button = forwardRef(
|
||||
(
|
||||
{
|
||||
variant = 'normal',
|
||||
label,
|
||||
className = '',
|
||||
dangerStyle: danger = false,
|
||||
disabled = false,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${getClassName(variant, danger, disabled)} ${className}`}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{label ?? children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default Button
|
||||
@@ -0,0 +1,43 @@
|
||||
import { FunctionComponent, MouseEventHandler } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
onClick: () => void
|
||||
className?: string
|
||||
icon: IconType
|
||||
iconClassName?: string
|
||||
title: string
|
||||
focusable: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const IconButton: FunctionComponent<Props> = ({
|
||||
onClick,
|
||||
className = '',
|
||||
icon,
|
||||
title,
|
||||
focusable,
|
||||
iconClassName = '',
|
||||
disabled = false,
|
||||
}) => {
|
||||
const click: MouseEventHandler = (e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
const focusableClass = focusable ? '' : 'focus:shadow-none'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
className={`no-border cursor-pointer bg-transparent flex flex-row items-center ${focusableClass} ${className}`}
|
||||
onClick={click}
|
||||
disabled={disabled}
|
||||
aria-label={title}
|
||||
>
|
||||
<Icon type={icon} className={iconClassName} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconButton
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FunctionComponent, MouseEventHandler } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
type ButtonType = 'normal' | 'primary'
|
||||
|
||||
type Props = {
|
||||
onClick: () => void
|
||||
type: ButtonType
|
||||
className?: string
|
||||
icon: IconType
|
||||
}
|
||||
|
||||
const RoundIconButton: FunctionComponent<Props> = ({ onClick, type, className, icon: iconType }) => {
|
||||
const click: MouseEventHandler = (e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
const classes = type === 'primary' ? 'info ' : ''
|
||||
return (
|
||||
<button className={`sn-icon-button ${classes} ${className ?? ''}`} onClick={click}>
|
||||
<Icon type={iconType} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoundIconButton
|
||||
@@ -0,0 +1,268 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import {
|
||||
ButtonType,
|
||||
Challenge,
|
||||
ChallengePrompt,
|
||||
ChallengeReason,
|
||||
ChallengeValue,
|
||||
removeFromArray,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ProtectedIllustration } from '@standardnotes/icons'
|
||||
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
|
||||
viewControllerManager: ViewControllerManager
|
||||
mainApplicationGroup: ApplicationGroup
|
||||
challenge: Challenge
|
||||
onDismiss?: (challenge: Challenge) => void
|
||||
}
|
||||
|
||||
const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]): ChallengeModalValues | undefined => {
|
||||
let hasInvalidValues = false
|
||||
const validatedValues = { ...values }
|
||||
for (const prompt of prompts) {
|
||||
const value = validatedValues[prompt.id]
|
||||
if (typeof value.value === 'string' && value.value.length === 0) {
|
||||
validatedValues[prompt.id].invalid = true
|
||||
hasInvalidValues = true
|
||||
}
|
||||
}
|
||||
if (!hasInvalidValues) {
|
||||
return validatedValues
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ChallengeModal: FunctionComponent<Props> = ({
|
||||
application,
|
||||
viewControllerManager,
|
||||
mainApplicationGroup,
|
||||
challenge,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const [values, setValues] = useState<ChallengeModalValues>(() => {
|
||||
const values = {} as ChallengeModalValues
|
||||
for (const prompt of challenge.prompts) {
|
||||
values[prompt.id] = {
|
||||
prompt,
|
||||
value: prompt.initialValue ?? '',
|
||||
invalid: false,
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
|
||||
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
|
||||
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
|
||||
challenge.reason,
|
||||
)
|
||||
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const validatedValues = validateValues(values, challenge.prompts)
|
||||
if (!validatedValues) {
|
||||
return
|
||||
}
|
||||
if (isSubmitting || isProcessing) {
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
setIsProcessing(true)
|
||||
|
||||
const valuesToProcess: ChallengeValue[] = []
|
||||
for (const inputValue of Object.values(validatedValues)) {
|
||||
const rawValue = inputValue.value
|
||||
const value = { prompt: inputValue.prompt, value: rawValue }
|
||||
valuesToProcess.push(value)
|
||||
}
|
||||
|
||||
const processingPrompts = valuesToProcess.map((v) => v.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
setProcessingPrompts(processingPrompts)
|
||||
/**
|
||||
* Unfortunately neccessary to wait 50ms so that the above setState call completely
|
||||
* updates the UI to change processing state, before we enter into UI blocking operation
|
||||
* (crypto key generation)
|
||||
*/
|
||||
setTimeout(() => {
|
||||
if (valuesToProcess.length > 0) {
|
||||
application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error)
|
||||
} else {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}, 50)
|
||||
}, [application, challenge, isProcessing, isSubmitting, values])
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string | number, prompt: ChallengePrompt) => {
|
||||
const newValues = { ...values }
|
||||
newValues[prompt.id].invalid = false
|
||||
newValues[prompt.id].value = value
|
||||
setValues(newValues)
|
||||
},
|
||||
[values],
|
||||
)
|
||||
|
||||
const cancelChallenge = useCallback(() => {
|
||||
if (challenge.cancelable) {
|
||||
application.cancelChallenge(challenge)
|
||||
onDismiss?.(challenge)
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
useEffect(() => {
|
||||
const removeChallengeObserver = application.addChallengeObserver(challenge, {
|
||||
onValidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = false
|
||||
return newValues
|
||||
})
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
},
|
||||
onInvalidValue: (value) => {
|
||||
setValues((values) => {
|
||||
const newValues = { ...values }
|
||||
newValues[value.prompt.id].invalid = true
|
||||
return newValues
|
||||
})
|
||||
/** If custom validation, treat all values together and not individually */
|
||||
if (!value.prompt.validates) {
|
||||
setProcessingPrompts([])
|
||||
setIsProcessing(false)
|
||||
} else {
|
||||
setProcessingPrompts((currentlyProcessingPrompts) => {
|
||||
const processingPrompts = currentlyProcessingPrompts.slice()
|
||||
removeFromArray(processingPrompts, value.prompt)
|
||||
setIsProcessing(processingPrompts.length > 0)
|
||||
return processingPrompts
|
||||
})
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
onDismiss?.(challenge)
|
||||
},
|
||||
onCancel: () => {
|
||||
onDismiss?.(challenge)
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeChallengeObserver()
|
||||
}
|
||||
}, [application, challenge, onDismiss])
|
||||
|
||||
if (!challenge.prompts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogOverlay
|
||||
className={`sn-component ${
|
||||
challenge.reason === ChallengeReason.ApplicationUnlock ? 'challenge-modal-overlay' : ''
|
||||
}`}
|
||||
onDismiss={cancelChallenge}
|
||||
dangerouslyBypassFocusLock={bypassModalFocusLock}
|
||||
key={challenge.id}
|
||||
>
|
||||
<DialogContent
|
||||
aria-label="Challenge modal"
|
||||
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
|
||||
challenge.reason !== ChallengeReason.ApplicationUnlock
|
||||
? 'shadow-overlay-light border-1 border-solid border-main'
|
||||
: 'focus:shadow-none'
|
||||
}`}
|
||||
>
|
||||
{challenge.cancelable && (
|
||||
<button
|
||||
onClick={cancelChallenge}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
)}
|
||||
<ProtectedIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-lg text-center max-w-76 mb-3">{challenge.heading}</div>
|
||||
|
||||
{challenge.subheading && (
|
||||
<div className="text-center text-sm max-w-76 mb-4 break-word">{challenge.subheading}</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="flex flex-col items-center min-w-76"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
{challenge.prompts.map((prompt, index) => (
|
||||
<ChallengeModalPrompt
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
values={values}
|
||||
index={index}
|
||||
onValueChange={onValueChange}
|
||||
isInvalid={values[prompt.id].invalid}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
<Button variant="primary" disabled={isProcessing} className="min-w-76 mt-1 mb-3.5" onClick={submit}>
|
||||
{isProcessing ? 'Generating Keys...' : 'Submit'}
|
||||
</Button>
|
||||
{shouldShowForgotPasscode && (
|
||||
<Button
|
||||
className="flex items-center justify-center min-w-76"
|
||||
onClick={() => {
|
||||
setBypassModalFocusLock(true)
|
||||
application.alertService
|
||||
.confirm(
|
||||
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
|
||||
'Forgot passcode?',
|
||||
'Delete local data',
|
||||
ButtonType.Danger,
|
||||
)
|
||||
.then((shouldDeleteLocalData) => {
|
||||
if (shouldDeleteLocalData) {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setBypassModalFocusLock(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="help" className="mr-2 color-neutral" />
|
||||
Forgot passcode?
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowWorkspaceSwitcher && (
|
||||
<LockscreenWorkspaceSwitcher
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChallengeModal
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||
import { InputValue } from './InputValue'
|
||||
|
||||
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
|
||||
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
|
||||
values: ChallengeModalValues
|
||||
index: number
|
||||
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
|
||||
isInvalid: boolean
|
||||
}
|
||||
|
||||
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (index === 0) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [index])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInvalid) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isInvalid])
|
||||
|
||||
return (
|
||||
<div key={prompt.id} className="w-full mb-3">
|
||||
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
|
||||
<div className="min-w-76">
|
||||
<div className="text-sm font-medium mb-2">Allow protected access for</div>
|
||||
<div className="flex items-center justify-between bg-passive-4 rounded p-1">
|
||||
{ProtectionSessionDurations.map((option) => {
|
||||
const selected = option.valueInSeconds === values[prompt.id].value
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className={`cursor-pointer px-2 py-1.5 rounded ${
|
||||
selected ? 'bg-default color-foreground font-semibold' : 'color-passive-0 hover:bg-passive-3'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`session-duration-${prompt.id}`}
|
||||
className={'appearance-none m-0 focus:shadow-none focus:outline-none'}
|
||||
style={{
|
||||
marginRight: 0,
|
||||
}}
|
||||
checked={selected}
|
||||
onChange={(event) => {
|
||||
event.preventDefault()
|
||||
onValueChange(option.valueInSeconds, prompt)
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : prompt.secureTextEntry ? (
|
||||
<DecoratedPasswordInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
) : (
|
||||
<DecoratedInput
|
||||
ref={inputRef}
|
||||
placeholder={prompt.placeholder}
|
||||
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
|
||||
onChange={(value) => onValueChange(value, prompt)}
|
||||
/>
|
||||
)}
|
||||
{isInvalid && <div className="text-sm color-danger mt-2">Invalid authentication, please try again.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChallengeModalPrompt
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ChallengePrompt } from '@standardnotes/snjs'
|
||||
|
||||
export type InputValue = {
|
||||
prompt: ChallengePrompt
|
||||
value: string | number | boolean
|
||||
invalid: boolean
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
|
||||
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
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
|
||||
|
||||
useCloseOnClickOutside(containerRef, () => setIsOpen(false))
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
const menuPosition = calculateSubmenuStyle(buttonRef.current)
|
||||
if (menuPosition) {
|
||||
setMenuStyle(menuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen)
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timeToWaitBeforeCheckingMenuCollision = 0
|
||||
setTimeout(() => {
|
||||
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
|
||||
|
||||
if (newMenuPosition) {
|
||||
setMenuStyle(newMenuPosition)
|
||||
}
|
||||
}, timeToWaitBeforeCheckingMenuCollision)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Button ref={buttonRef} onClick={toggleMenu} className="flex items-center justify-center min-w-76 mt-2">
|
||||
<Icon type="user-switch" className="color-neutral mr-2" />
|
||||
Switch workspace
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div ref={menuRef} className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto" style={menuStyle}>
|
||||
<WorkspaceSwitcherMenu
|
||||
mainApplicationGroup={mainApplicationGroup}
|
||||
viewControllerManager={viewControllerManager}
|
||||
isOpen={isOpen}
|
||||
hideWorkspaceOptions={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LockscreenWorkspaceSwitcher
|
||||
@@ -0,0 +1,112 @@
|
||||
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, useRef, useState } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import ChangeEditorMenu from './ChangeEditorMenu'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const ChangeEditorButton: FunctionComponent<Props> = ({
|
||||
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<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
|
||||
|
||||
const toggleChangeEditorMenu = async () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const { clientHeight } = document.documentElement
|
||||
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
|
||||
const footerHeightInPx = footerElementRect?.height
|
||||
|
||||
if (footerHeightInPx) {
|
||||
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
|
||||
const newOpenState = !isOpen
|
||||
if (newOpenState && onClickPreprocessing) {
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
|
||||
setIsOpen(newOpenState)
|
||||
setTimeout(() => {
|
||||
setIsVisible(newOpenState)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button border-contrast"
|
||||
>
|
||||
<VisuallyHidden>Change note type</VisuallyHidden>
|
||||
<Icon type="dashboard" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
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 && (
|
||||
<ChangeEditorMenu
|
||||
closeOnBlur={closeOnBlur}
|
||||
application={application}
|
||||
isVisible={isVisible}
|
||||
note={note}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ChangeEditorButton)
|
||||
@@ -0,0 +1,220 @@
|
||||
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 '@/Constants/Strings'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import {
|
||||
ComponentArea,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs'
|
||||
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/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 | undefined
|
||||
}
|
||||
|
||||
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
||||
|
||||
const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
application,
|
||||
closeOnBlur,
|
||||
closeMenu,
|
||||
isVisible,
|
||||
note,
|
||||
}) => {
|
||||
const [editors] = useState<SNComponent[]>(() =>
|
||||
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
|
||||
return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
|
||||
}),
|
||||
)
|
||||
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
|
||||
const [currentEditor, setCurrentEditor] = useState<SNComponent>()
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(createEditorMenuGroups(application, editors))
|
||||
}, [application, editors])
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
}
|
||||
}, [application, note])
|
||||
|
||||
const premiumModal = usePremiumModal()
|
||||
|
||||
const isSelectedEditor = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
if (currentEditor) {
|
||||
if (item?.component?.identifier === currentEditor.identifier) {
|
||||
return true
|
||||
}
|
||||
} else if (item.name === PLAIN_EDITOR_NAME) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[currentEditor],
|
||||
)
|
||||
|
||||
const selectComponent = 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
|
||||
},
|
||||
})
|
||||
}
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = application.componentManager.editorForNote(note)
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(transactionForDisassociateComponentWithCurrentNote(currentEditor, note))
|
||||
}
|
||||
const prefersPlain = note.prefersPlainEditor
|
||||
if (prefersPlain) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator
|
||||
noteMutator.prefersPlainEditor = false
|
||||
},
|
||||
})
|
||||
}
|
||||
transactions.push(transactionForAssociateComponentWithCurrentNote(component, note))
|
||||
}
|
||||
|
||||
await application.mutator.runTransactionalMutations(transactions)
|
||||
/** Dirtying can happen above */
|
||||
application.sync.sync().catch(console.error)
|
||||
|
||||
setCurrentEditor(application.componentManager.editorForNote(note))
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const selectEditor = useCallback(
|
||||
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 && note) {
|
||||
selectComponent(itemToBeSelected.component ?? null, note).catch(console.error)
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
},
|
||||
[application.componentManager, closeMenu, currentEditor, note, premiumModal, selectComponent],
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu className="pt-0.5 pb-1" a11yLabel="Change note type menu" isOpen={isVisible}>
|
||||
{groups
|
||||
.filter((group) => group.items && group.items.length)
|
||||
.map((group, index) => {
|
||||
const groupId = getGroupId(group)
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
<div className={`py-1 border-0 border-t-1px border-solid border-main ${index === 0 ? 'border-t-0' : ''}`}>
|
||||
{group.items.map((item) => {
|
||||
const onClickEditorItem = () => {
|
||||
selectEditor(item).catch(console.error)
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={onClickEditorItem}
|
||||
className={
|
||||
'sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none flex-row-reverse'
|
||||
}
|
||||
onBlur={closeOnBlur}
|
||||
checked={isSelectedEditor(item)}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
|
||||
{item.name}
|
||||
</div>
|
||||
{!item.isEntitled && <Icon type="premium-feature" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangeEditorMenu
|
||||
@@ -0,0 +1,127 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import {
|
||||
ContentType,
|
||||
FeatureStatus,
|
||||
SNComponent,
|
||||
ComponentArea,
|
||||
FeatureDescription,
|
||||
GetFeatures,
|
||||
NoteType,
|
||||
} from '@standardnotes/snjs'
|
||||
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
|
||||
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
|
||||
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others'
|
||||
|
||||
const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => {
|
||||
if (featureDescription.note_type) {
|
||||
return featureDescription.note_type
|
||||
} else if (featureDescription.file_type) {
|
||||
switch (featureDescription.file_type) {
|
||||
case 'txt':
|
||||
return 'plain'
|
||||
case 'html':
|
||||
return NoteType.RichText
|
||||
case 'md':
|
||||
return NoteType.Markdown
|
||||
default:
|
||||
return 'others'
|
||||
}
|
||||
}
|
||||
return 'others'
|
||||
}
|
||||
|
||||
export const createEditorMenuGroups = (application: WebApplication, editors: SNComponent[]) => {
|
||||
const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
|
||||
plain: [
|
||||
{
|
||||
name: PLAIN_EDITOR_NAME,
|
||||
isEntitled: true,
|
||||
},
|
||||
],
|
||||
'rich-text': [],
|
||||
markdown: [],
|
||||
task: [],
|
||||
code: [],
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
}
|
||||
|
||||
GetFeatures()
|
||||
.filter((feature) => feature.content_type === ContentType.Component && feature.area === ComponentArea.Editor)
|
||||
.forEach((editorFeature) => {
|
||||
const notInstalled = !editors.find((editor) => editor.identifier === editorFeature.identifier)
|
||||
const isExperimental = application.features.isExperimentalFeature(editorFeature.identifier)
|
||||
if (notInstalled && !isExperimental) {
|
||||
editorItems[getEditorGroup(editorFeature)].push({
|
||||
name: editorFeature.name as string,
|
||||
isEntitled: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
editors.forEach((editor) => {
|
||||
const editorItem: EditorMenuItem = {
|
||||
name: editor.displayName,
|
||||
component: editor,
|
||||
isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled,
|
||||
}
|
||||
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem)
|
||||
})
|
||||
|
||||
const editorMenuGroups: EditorMenuGroup[] = [
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Plain text',
|
||||
items: editorItems.plain,
|
||||
},
|
||||
{
|
||||
icon: 'rich-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Rich text',
|
||||
items: editorItems['rich-text'],
|
||||
},
|
||||
{
|
||||
icon: 'markdown',
|
||||
iconClassName: 'color-accessory-tint-2',
|
||||
title: 'Markdown text',
|
||||
items: editorItems.markdown,
|
||||
},
|
||||
{
|
||||
icon: 'tasks',
|
||||
iconClassName: 'color-accessory-tint-3',
|
||||
title: 'Todo',
|
||||
items: editorItems.task,
|
||||
},
|
||||
{
|
||||
icon: 'code',
|
||||
iconClassName: 'color-accessory-tint-4',
|
||||
title: 'Code',
|
||||
items: editorItems.code,
|
||||
},
|
||||
{
|
||||
icon: 'spreadsheets',
|
||||
iconClassName: 'color-accessory-tint-5',
|
||||
title: 'Spreadsheet',
|
||||
items: editorItems.spreadsheet,
|
||||
},
|
||||
{
|
||||
icon: 'authenticator',
|
||||
iconClassName: 'color-accessory-tint-6',
|
||||
title: 'Authentication',
|
||||
items: editorItems.authentication,
|
||||
},
|
||||
{
|
||||
icon: 'editor',
|
||||
iconClassName: 'color-neutral',
|
||||
title: 'Others',
|
||||
items: editorItems.others,
|
||||
},
|
||||
]
|
||||
|
||||
return editorMenuGroups
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeEventHandler, FunctionComponent } from 'react'
|
||||
|
||||
type CheckboxProps = {
|
||||
name: string
|
||||
checked: boolean
|
||||
onChange: ChangeEventHandler<HTMLInputElement>
|
||||
disabled?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
const Checkbox: FunctionComponent<CheckboxProps> = ({ name, checked, onChange, disabled, label }) => {
|
||||
return (
|
||||
<label htmlFor={name} className="flex items-center fit-content mb-2">
|
||||
<input
|
||||
className="mr-2"
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={name}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default Checkbox
|
||||
@@ -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<IProps> = ({ application, onLoad, componentViewer, requestReload }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const [hasIssueLoading, setHasIssueLoading] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(componentViewer.getFeatureStatus())
|
||||
const [isComponentValid, setIsComponentValid] = useState(true)
|
||||
const [error, setError] = useState<ComponentViewerError | undefined>(undefined)
|
||||
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(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 && (
|
||||
<IssueOnLoading
|
||||
componentName={component.displayName}
|
||||
reloadIframe={() => {
|
||||
reloadValidityStatus(), requestReload?.(componentViewer, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{featureStatus !== FeatureStatus.Entitled && (
|
||||
<IsExpired
|
||||
expiredDate={dateToLocalizedString(component.valid_until)}
|
||||
featureStatus={featureStatus}
|
||||
componentName={component.displayName}
|
||||
manageSubscription={manageSubscription}
|
||||
/>
|
||||
)}
|
||||
{deprecationMessage && !isDeprecationMessageDismissed && (
|
||||
<IsDeprecated deprecationMessage={deprecationMessage} dismissDeprecationMessage={dismissDeprecationMessage} />
|
||||
)}
|
||||
{error === ComponentViewerError.OfflineRestricted && <OfflineRestricted />}
|
||||
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
frameBorder={0}
|
||||
src={componentViewer.url || ''}
|
||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
|
||||
>
|
||||
Loading
|
||||
</iframe>
|
||||
)}
|
||||
{isLoading && <div className={'loading-overlay'} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ComponentView)
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
type Props = {
|
||||
deprecationMessage: string | undefined
|
||||
dismissDeprecationMessage: () => void
|
||||
}
|
||||
|
||||
const IsDeprecated: FunctionComponent<Props> = ({ deprecationMessage, dismissDeprecationMessage }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-label warning'}>{deprecationMessage || 'This extension is deprecated.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={dismissDeprecationMessage}>
|
||||
<button className={'sn-button small info'}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IsDeprecated
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FeatureStatus } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
type Props = {
|
||||
expiredDate: string
|
||||
componentName: string
|
||||
featureStatus: FeatureStatus
|
||||
manageSubscription: () => void
|
||||
}
|
||||
|
||||
const statusString = (featureStatus: FeatureStatus, expiredDate: string, componentName: string) => {
|
||||
switch (featureStatus) {
|
||||
case FeatureStatus.InCurrentPlanButExpired:
|
||||
return `Your subscription expired on ${expiredDate}`
|
||||
case FeatureStatus.NoUserSubscription:
|
||||
return 'You do not have an active subscription'
|
||||
case FeatureStatus.NotInCurrentPlan:
|
||||
return `Please upgrade your plan to access ${componentName}`
|
||||
default:
|
||||
return `${componentName} is valid and you should not be seeing this message`
|
||||
}
|
||||
}
|
||||
|
||||
const IsExpired: FunctionComponent<Props> = ({ expiredDate, featureStatus, componentName, manageSubscription }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-app-bar-item-column'}>
|
||||
<div className={'sk-circle danger small'} />
|
||||
</div>
|
||||
<div className={'sk-app-bar-item-column'}>
|
||||
<div>
|
||||
<strong>{statusString(featureStatus, expiredDate, componentName)}</strong>
|
||||
<div className={'sk-p'}>{componentName} is in a read-only state.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={() => manageSubscription()}>
|
||||
<button className={'sn-button small success'}>Manage Subscription</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IsExpired
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
type Props = {
|
||||
componentName: string
|
||||
reloadIframe: () => void
|
||||
}
|
||||
|
||||
const IssueOnLoading: FunctionComponent<Props> = ({ componentName, reloadIframe }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
|
||||
<div className={'left'}>
|
||||
<div className={'sk-app-bar-item'}>
|
||||
<div className={'sk-label.warning'}>There was an issue loading {componentName}.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'right'}>
|
||||
<div className={'sk-app-bar-item'} onClick={reloadIframe}>
|
||||
<button className={'sn-button small info'}>Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IssueOnLoading
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
const OfflineRestricted: FunctionComponent = () => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-panel static'}>
|
||||
<div className={'sk-panel-content'}>
|
||||
<div className={'sk-panel-section stretch'}>
|
||||
<div className={'sk-panel-column'} />
|
||||
<div className={'sk-h1 sk-bold'}>You have restricted this component to not use a hosted version.</div>
|
||||
<div className={'sk-subtitle'}>Locally-installed components are not available in the web application.</div>
|
||||
<div className={'sk-panel-row'} />
|
||||
<div className={'sk-panel-row'}>
|
||||
<div className={'sk-panel-column'}>
|
||||
<div className={'sk-p'}>To continue, choose from the following options:</div>
|
||||
<ul>
|
||||
<li className={'sk-p'}>
|
||||
Enable the Hosted option for this component by opening the Preferences {'>'} General {'>'} Advanced
|
||||
Settings menu and toggling 'Use hosted when local is unavailable' under this component's options.
|
||||
Then press Reload.
|
||||
</li>
|
||||
<li className={'sk-p'}>Use the desktop application.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OfflineRestricted
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
type Props = {
|
||||
componentName: string
|
||||
}
|
||||
|
||||
const UrlMissing: FunctionComponent<Props> = ({ componentName }) => {
|
||||
return (
|
||||
<div className={'sn-component'}>
|
||||
<div className={'sk-panel static'}>
|
||||
<div className={'sk-panel-content'}>
|
||||
<div className={'sk-panel-section stretch'}>
|
||||
<div className={'sk-panel-section-title'}>This extension is missing its URL property.</div>
|
||||
<p>In order to access your note immediately, please switch from {componentName} to the Plain Editor.</p>
|
||||
<br />
|
||||
<p>Please contact help@standardnotes.com to remedy this issue.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UrlMissing
|
||||
@@ -0,0 +1,121 @@
|
||||
import { FunctionComponent, useEffect, useRef, useState } from 'react'
|
||||
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
|
||||
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 '@/Application/ApplicationGroup'
|
||||
import { isDesktopApplication } from '@/Utils'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
applicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
const ConfirmSignoutModal: FunctionComponent<Props> = ({ application, viewControllerManager, applicationGroup }) => {
|
||||
const [deleteLocalBackups, setDeleteLocalBackups] = useState(false)
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
function closeDialog() {
|
||||
viewControllerManager.accountMenuController.setSigningOut(false)
|
||||
}
|
||||
|
||||
const [localBackupsCount, setLocalBackupsCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
application.desktopDevice?.localBackupsCount().then(setLocalBackupsCount).catch(console.error)
|
||||
}, [viewControllerManager.accountMenuController.signingOut, application.desktopDevice])
|
||||
|
||||
const workspaces = applicationGroup.getDescriptors()
|
||||
const showWorkspaceWarning = workspaces.length > 1 && isDesktopApplication()
|
||||
|
||||
return (
|
||||
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef}>
|
||||
<div className="sk-modal-content">
|
||||
<div className="sn-component">
|
||||
<div className="sk-panel">
|
||||
<div className="sk-panel-content">
|
||||
<div className="sk-panel-section">
|
||||
<AlertDialogLabel className="sk-h3 sk-panel-section-title">Sign out workspace?</AlertDialogLabel>
|
||||
<AlertDialogDescription className="sk-panel-row">
|
||||
<div>
|
||||
<p className="color-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
|
||||
{showWorkspaceWarning && (
|
||||
<>
|
||||
<br />
|
||||
<p className="color-foreground">
|
||||
<strong>Note: </strong>
|
||||
Because you have other workspaces signed in, this sign out may leave logs and other metadata
|
||||
of your session on this device. For a more robust sign out that performs a hard clear of all
|
||||
app-related data, use the <i>Sign out all workspaces</i> option under <i>Switch workspace</i>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
|
||||
{localBackupsCount > 0 && (
|
||||
<div className="flex">
|
||||
<div className="sk-panel-row"></div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteLocalBackups}
|
||||
onChange={(event) => {
|
||||
setDeleteLocalBackups((event.target as HTMLInputElement).checked)
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
Delete {localBackupsCount} local backup file
|
||||
{localBackupsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="capitalize sk-a ml-1.5 p-0 rounded cursor-pointer"
|
||||
onClick={() => {
|
||||
application.desktopDevice?.viewlocalBackups()
|
||||
}}
|
||||
>
|
||||
View backup files
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex my-1 mt-4">
|
||||
<button className="sn-button small neutral" ref={cancelRef} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="sn-button small danger ml-2"
|
||||
onClick={() => {
|
||||
if (deleteLocalBackups) {
|
||||
application.signOutAndDeleteLocalBackups().catch(console.error)
|
||||
} else {
|
||||
application.user.signOut().catch(console.error)
|
||||
}
|
||||
closeDialog()
|
||||
}}
|
||||
>
|
||||
{application.hasAccount() ? 'Sign Out' : 'Delete Workspace'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
ConfirmSignoutModal.displayName = 'ConfirmSignoutModal'
|
||||
|
||||
const ConfirmSignoutContainer = (props: Props) => {
|
||||
if (!props.viewControllerManager.accountMenuController.signingOut) {
|
||||
return null
|
||||
}
|
||||
return <ConfirmSignoutModal {...props} />
|
||||
}
|
||||
|
||||
export default observer(ConfirmSignoutContainer)
|
||||
@@ -0,0 +1,84 @@
|
||||
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<UuidString, ListableContentItem>
|
||||
paginate: () => void
|
||||
}
|
||||
|
||||
const ContentList: FunctionComponent<Props> = ({
|
||||
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 (
|
||||
<div
|
||||
className="infinite-scroll focus:shadow-none focus:outline-none"
|
||||
id="notes-scrollable"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<ContentListItem
|
||||
key={item.uuid}
|
||||
application={application}
|
||||
item={item}
|
||||
selected={!!selectedItems[item.uuid]}
|
||||
hideDate={hideDate}
|
||||
hidePreview={hideNotePreview}
|
||||
hideTags={hideTags}
|
||||
hideIcon={hideEditorIcon}
|
||||
sortBy={sortBy}
|
||||
filesController={viewControllerManager.filesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ContentList)
|
||||
@@ -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<AbstractListItemProps> = (props) => {
|
||||
const getTags = () => {
|
||||
if (props.hideTags) {
|
||||
return []
|
||||
}
|
||||
|
||||
const selectedTag = props.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 <NoteListItem tags={getTags()} {...props} />
|
||||
case ContentType.File:
|
||||
return <FileListItem tags={getTags()} {...props} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default ContentListItem
|
||||
@@ -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<Props> = ({
|
||||
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 (
|
||||
<Menu
|
||||
className={
|
||||
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
|
||||
border-1 border-solid border-main text-sm z-index-dropdown-menu \
|
||||
flex flex-col py-2 top-full left-2 absolute'
|
||||
}
|
||||
a11yLabel="Notes list options menu"
|
||||
closeMenu={closeDisplayOptionsMenu}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">Sort by</div>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByDateModified}
|
||||
checked={sortBy === CollectionSort.UpdatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between ml-2">
|
||||
<span>Date modified</span>
|
||||
{sortBy === CollectionSort.UpdatedAt ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByCreationDate}
|
||||
checked={sortBy === CollectionSort.CreatedAt}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between ml-2">
|
||||
<span>Creation date</span>
|
||||
{sortBy === CollectionSort.CreatedAt ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className="py-2"
|
||||
type={MenuItemType.RadioButton}
|
||||
onClick={toggleSortByTitle}
|
||||
checked={sortBy === CollectionSort.Title}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-grow items-center justify-between ml-2">
|
||||
<span>Title</span>
|
||||
{sortBy === CollectionSort.Title ? (
|
||||
sortReverse ? (
|
||||
<Icon type="arrows-sort-up" className="color-neutral" />
|
||||
) : (
|
||||
<Icon type="arrows-sort-down" className="color-neutral" />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItemSeparator />
|
||||
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">View</div>
|
||||
{viewControllerManager.navigationController.selectedUuid !== SystemViewId.Files && (
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePreview}
|
||||
onChange={toggleHidePreview}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<div className="flex flex-col max-w-3/4">Show note preview</div>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideDate}
|
||||
onChange={toggleHideDate}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideTags}
|
||||
onChange={toggleHideTags}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show tags
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideEditorIcon}
|
||||
onChange={toggleEditorIcon}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show icon
|
||||
</MenuItem>
|
||||
<div className="h-1px my-2 bg-border"></div>
|
||||
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">Other</div>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hidePinned}
|
||||
onChange={toggleHidePinned}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show pinned
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={!hideProtected}
|
||||
onChange={toggleHideProtected}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show protected
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showArchived}
|
||||
onChange={toggleShowArchived}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show archived
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
type={MenuItemType.SwitchButton}
|
||||
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
|
||||
checked={showTrashed}
|
||||
onChange={toggleShowTrashed}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
Show trashed
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ContentListOptionsMenu)
|
||||
@@ -0,0 +1,279 @@
|
||||
import { KeyboardKey, KeyboardModifier } from '@/Services/IOService'
|
||||
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 {
|
||||
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 ContentListOptionsMenu from './ContentListOptionsMenu'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const ContentListView: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||
const itemsViewPanelRef = useRef<HTMLDivElement>(null)
|
||||
const displayOptionsMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
completedFullSync,
|
||||
noteFilterText,
|
||||
optionsSubtitle,
|
||||
panelTitle,
|
||||
renderedItems,
|
||||
setNoteFilterText,
|
||||
searchBarElement,
|
||||
selectNextItem,
|
||||
selectPreviousItem,
|
||||
onFilterEnter,
|
||||
clearFilterText,
|
||||
paginate,
|
||||
panelWidth,
|
||||
createNewNote,
|
||||
} = viewControllerManager.itemListController
|
||||
|
||||
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
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
const newNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault()
|
||||
addNewItem()
|
||||
},
|
||||
})
|
||||
|
||||
const nextNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Down,
|
||||
elements: [document.body, ...(searchBarElement ? [searchBarElement] : [])],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement === document.activeElement) {
|
||||
searchBarElement?.blur()
|
||||
}
|
||||
selectNextItem()
|
||||
},
|
||||
})
|
||||
|
||||
const previousNoteKeyObserver = application.io.addKeyObserver({
|
||||
key: KeyboardKey.Up,
|
||||
element: document.body,
|
||||
onKeyDown: () => {
|
||||
selectPreviousItem()
|
||||
},
|
||||
})
|
||||
|
||||
const searchKeyObserver = application.io.addKeyObserver({
|
||||
key: 'f',
|
||||
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
|
||||
onKeyDown: () => {
|
||||
if (searchBarElement) {
|
||||
searchBarElement.focus()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return () => {
|
||||
newNoteKeyObserver()
|
||||
nextNoteKeyObserver()
|
||||
previousNoteKeyObserver()
|
||||
searchKeyObserver()
|
||||
}
|
||||
}, [addNewItem, application.io, createNewNote, searchBarElement, selectNextItem, selectPreviousItem])
|
||||
|
||||
const onNoteFilterTextChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
setNoteFilterText(e.target.value)
|
||||
},
|
||||
[setNoteFilterText],
|
||||
)
|
||||
|
||||
const onSearchFocused = useCallback(() => setFocusedSearch(true), [])
|
||||
const onSearchBlurred = useCallback(() => setFocusedSearch(false), [])
|
||||
|
||||
const onNoteFilterKeyUp: KeyboardEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (e.key === KeyboardKey.Enter) {
|
||||
onFilterEnter()
|
||||
}
|
||||
},
|
||||
[onFilterEnter],
|
||||
)
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
|
||||
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
|
||||
},
|
||||
[viewControllerManager, application],
|
||||
)
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
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 (
|
||||
<div
|
||||
id="items-column"
|
||||
className="sn-component section app-column app-column-second"
|
||||
aria-label={'Notes & Files'}
|
||||
ref={itemsViewPanelRef}
|
||||
>
|
||||
<div className="content">
|
||||
<div id="items-title-bar" className="section-title-bar">
|
||||
<div id="items-title-bar-container">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h2 font-semibold title">{panelTitle}</div>
|
||||
<button
|
||||
className="flex items-center px-5 py-1 bg-contrast hover:brightness-130 color-text border-0 cursor-pointer"
|
||||
title={addButtonLabel}
|
||||
aria-label={addButtonLabel}
|
||||
onClick={addNewItem}
|
||||
>
|
||||
<Icon type="add" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="filter-section" role="search">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="search-bar"
|
||||
className="filter-bar"
|
||||
placeholder="Search"
|
||||
title="Searches notes and files in the currently selected tag"
|
||||
value={noteFilterText}
|
||||
onChange={onNoteFilterTextChange}
|
||||
onKeyUp={onNoteFilterKeyUp}
|
||||
onFocus={onSearchFocused}
|
||||
onBlur={onSearchBlurred}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{noteFilterText && (
|
||||
<button onClick={clearFilterText} id="search-clear-button">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(focusedSearch || noteFilterText) && (
|
||||
<div className="animate-fade-from-top">
|
||||
<SearchOptions application={application} viewControllerManager={viewControllerManager} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NoAccountWarningWrapper viewControllerManager={viewControllerManager} />
|
||||
</div>
|
||||
<div id="items-menu-bar" className="sn-component" ref={displayOptionsMenuRef}>
|
||||
<div className="sk-app-bar no-edges">
|
||||
<div className="left">
|
||||
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
|
||||
<DisclosureButton
|
||||
className={`sk-app-bar-item bg-contrast color-text border-0 focus:shadow-none ${
|
||||
showDisplayOptionsMenu ? 'selected' : ''
|
||||
}`}
|
||||
onBlur={closeDisplayOptMenuOnBlur}
|
||||
>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-label">Options</div>
|
||||
</div>
|
||||
<div className="sk-app-bar-item-column">
|
||||
<div className="sk-sublabel">{optionsSubtitle}</div>
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel onBlur={closeDisplayOptMenuOnBlur}>
|
||||
{showDisplayOptionsMenu && (
|
||||
<ContentListOptionsMenu
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||
isOpen={showDisplayOptionsMenu}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">No items.</p> : null}
|
||||
{!completedFullSync && !renderedItems.length ? <p className="empty-items-list faded">Loading...</p> : null}
|
||||
{renderedItems.length ? (
|
||||
<ContentList
|
||||
items={renderedItems}
|
||||
selectedItems={selectedItems}
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
paginate={paginate}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{itemsViewPanelRef.current && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
hoverable={true}
|
||||
defaultWidth={300}
|
||||
panel={itemsViewPanelRef.current}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ContentListView)
|
||||
@@ -0,0 +1,86 @@
|
||||
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<DisplayableListItemProps> = ({
|
||||
application,
|
||||
filesController,
|
||||
selectionController,
|
||||
hideDate,
|
||||
hideIcon,
|
||||
hideTags,
|
||||
item,
|
||||
selected,
|
||||
sortBy,
|
||||
tags,
|
||||
}) => {
|
||||
const openFileContextMenu = useCallback(
|
||||
(posX: number, posY: number) => {
|
||||
filesController.setFileContextMenuLocation({
|
||||
x: posX,
|
||||
y: posY,
|
||||
})
|
||||
filesController.setShowFileContextMenu(true)
|
||||
},
|
||||
[filesController],
|
||||
)
|
||||
|
||||
const openContextMenu = useCallback(
|
||||
async (posX: number, posY: number) => {
|
||||
const { didSelect } = await selectionController.selectItem(item.uuid)
|
||||
if (didSelect) {
|
||||
openFileContextMenu(posX, posY)
|
||||
}
|
||||
},
|
||||
[selectionController, item.uuid, openFileContextMenu],
|
||||
)
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
void selectionController.selectItem(item.uuid, true)
|
||||
}, [item.uuid, selectionController])
|
||||
|
||||
const IconComponent = () =>
|
||||
getFileIconComponent(
|
||||
application.iconsController.getIconForFileType((item as FileItem).mimeType),
|
||||
'w-5 h-5 flex-shrink-0',
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`content-list-item flex items-stretch w-full cursor-pointer ${
|
||||
selected && 'selected border-0 border-l-2px border-solid border-info'
|
||||
}`}
|
||||
id={item.uuid}
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
void openContextMenu(event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
{!hideIcon ? (
|
||||
<div className="flex flex-col items-center justify-between p-4.5 pr-3 mr-0">
|
||||
<IconComponent />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pr-4" />
|
||||
)}
|
||||
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
|
||||
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
|
||||
<div className="break-word mr-2">{item.title}</div>
|
||||
</div>
|
||||
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
||||
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||
<ListItemConflictIndicator item={item} />
|
||||
</div>
|
||||
<ListItemFlagIcons item={item} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FileListItem)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import { ListableContentItem } from './Types/ListableContentItem'
|
||||
|
||||
type Props = {
|
||||
item: {
|
||||
conflictOf?: ListableContentItem['conflictOf']
|
||||
}
|
||||
}
|
||||
|
||||
const ListItemConflictIndicator: FunctionComponent<Props> = ({ item }) => {
|
||||
return item.conflictOf ? (
|
||||
<div className="flex flex-wrap items-center mt-0.5">
|
||||
<div className={'py-1 px-1.5 rounded mr-1 mt-2 bg-danger color-danger-contrast'}>
|
||||
<div className="text-xs font-bold text-center">Conflicted Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default ListItemConflictIndicator
|
||||
@@ -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<Props> = ({ item, hasFiles = false }) => {
|
||||
return (
|
||||
<div className="flex items-start p-4 pl-0 border-0 border-b-1 border-solid border-main">
|
||||
{item.locked && (
|
||||
<span className="flex items-center" title="Editing Disabled">
|
||||
<Icon ariaLabel="Editing Disabled" type="pencil-off" className="sn-icon--small color-info" />
|
||||
</span>
|
||||
)}
|
||||
{item.trashed && (
|
||||
<span className="flex items-center ml-1.5" title="Trashed">
|
||||
<Icon ariaLabel="Trashed" type="trash-filled" className="sn-icon--small color-danger" />
|
||||
</span>
|
||||
)}
|
||||
{item.archived && (
|
||||
<span className="flex items-center ml-1.5" title="Archived">
|
||||
<Icon ariaLabel="Archived" type="archive" className="sn-icon--mid color-accessory-tint-3" />
|
||||
</span>
|
||||
)}
|
||||
{item.pinned && (
|
||||
<span className="flex items-center ml-1.5" title="Pinned">
|
||||
<Icon ariaLabel="Pinned" type="pin-filled" className="sn-icon--small color-info" />
|
||||
</span>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<span className="flex items-center ml-1.5" title="Files">
|
||||
<Icon ariaLabel="Files" type="attachment-file" className="sn-icon--small color-info" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItemFlagIcons
|
||||
@@ -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<Props> = ({ item, hideDate, sortBy }) => {
|
||||
const showModifiedDate = sortBy === CollectionSort.UpdatedAt
|
||||
|
||||
if (hideDate && !item.protected) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs leading-1.4 mt-1 faded">
|
||||
{item.protected && <span>Protected {hideDate ? '' : ' • '}</span>}
|
||||
{!hideDate && showModifiedDate && <span>Modified {item.updatedAtString || 'Now'}</span>}
|
||||
{!hideDate && !showModifiedDate && <span>{item.createdAtString || 'Now'}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItemMetadata
|
||||
@@ -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<Props> = ({ hideTags, tags }) => {
|
||||
if (hideTags || !tags.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap mt-1.5 text-xs gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
className="inline-flex items-center py-1 px-1.5 bg-passive-4-opacity-variant color-foreground rounded-0.5"
|
||||
key={tag.uuid}
|
||||
>
|
||||
<Icon type="hashtag" className="sn-icon--small color-passive-1 mr-1" />
|
||||
<span>{tag.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItemTags
|
||||
@@ -0,0 +1,98 @@
|
||||
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<DisplayableListItemProps> = ({
|
||||
application,
|
||||
notesController,
|
||||
selectionController,
|
||||
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) => {
|
||||
notesController.setContextMenuClickLocation({
|
||||
x: posX,
|
||||
y: posY,
|
||||
})
|
||||
notesController.reloadContextMenuLayout()
|
||||
notesController.setContextMenuOpen(true)
|
||||
}
|
||||
|
||||
const openContextMenu = async (posX: number, posY: number) => {
|
||||
const { didSelect } = await selectionController.selectItem(item.uuid, true)
|
||||
if (didSelect) {
|
||||
openNoteContextMenu(posX, posY)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`content-list-item flex items-stretch w-full cursor-pointer ${
|
||||
selected && 'selected border-0 border-l-2px border-solid border-info'
|
||||
}`}
|
||||
id={item.uuid}
|
||||
onClick={() => {
|
||||
void selectionController.selectItem(item.uuid, true)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
void openContextMenu(event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
{!hideIcon ? (
|
||||
<div className="flex flex-col items-center justify-between p-4 pr-3 mr-0">
|
||||
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`color-accessory-tint-${tint}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pr-4" />
|
||||
)}
|
||||
<div className="flex-grow min-w-0 py-4 px-0 border-0 border-b-1 border-solid border-main">
|
||||
<div className="flex items-start justify-between font-semibold text-base leading-1.3 overflow-hidden">
|
||||
<div className="break-word mr-2">{item.title}</div>
|
||||
</div>
|
||||
{!hidePreview && !item.hidePreview && !item.protected && (
|
||||
<div className="overflow-hidden overflow-ellipsis text-sm">
|
||||
{item.preview_html && (
|
||||
<div
|
||||
className="my-1"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtmlString(item.preview_html),
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
{!item.preview_html && item.preview_plain && (
|
||||
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.preview_plain}</div>
|
||||
)}
|
||||
{!item.preview_html && !item.preview_plain && item.text && (
|
||||
<div className="leading-1.3 overflow-hidden line-clamp-1 mt-1">{item.text}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
|
||||
<ListItemTags hideTags={hideTags} tags={tags} />
|
||||
<ListItemConflictIndicator item={item} />
|
||||
</div>
|
||||
<ListItemFlagIcons item={item} hasFiles={hasFiles} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(NoteListItem)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { NotesController } from '@/Controllers/NotesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
import { SortableItem } from '@standardnotes/snjs'
|
||||
import { ListableContentItem } from './ListableContentItem'
|
||||
|
||||
export type AbstractListItemProps = {
|
||||
application: WebApplication
|
||||
filesController: FilesController
|
||||
selectionController: SelectedItemsController
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
hideDate: boolean
|
||||
hideIcon: boolean
|
||||
hideTags: boolean
|
||||
hidePreview: boolean
|
||||
item: ListableContentItem
|
||||
selected: boolean
|
||||
sortBy: keyof SortableItem | undefined
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SNTag } from '@standardnotes/snjs'
|
||||
import { AbstractListItemProps } from './AbstractListItemProps'
|
||||
|
||||
export type DisplayableListItemProps = AbstractListItemProps & {
|
||||
tags: {
|
||||
uuid: SNTag['uuid']
|
||||
title: SNTag['title']
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ContentType, DecryptedItem, ItemContent } from '@standardnotes/snjs'
|
||||
|
||||
export type ListableContentItem = DecryptedItem<ItemContent> & {
|
||||
title: string
|
||||
protected: boolean
|
||||
uuid: string
|
||||
content_type: ContentType
|
||||
updatedAtString?: string
|
||||
createdAtString?: string
|
||||
hidePreview?: boolean
|
||||
preview_html?: string
|
||||
preview_plain?: string
|
||||
text?: string
|
||||
}
|
||||
@@ -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<Props> = ({ application, children }) => {
|
||||
if (application.dealloced) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default observer(DeallocateHandler)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ListboxArrow, ListboxButton, ListboxInput, ListboxList, ListboxOption, ListboxPopover } from '@reach/listbox'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { FunctionComponent } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { DropdownItem } from './DropdownItem'
|
||||
|
||||
type DropdownProps = {
|
||||
id: string
|
||||
label: string
|
||||
items: DropdownItem[]
|
||||
value: string
|
||||
onChange: (value: string, item: DropdownItem) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type ListboxButtonProps = DropdownItem & {
|
||||
isExpanded: boolean
|
||||
}
|
||||
|
||||
const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
|
||||
label,
|
||||
isExpanded,
|
||||
icon,
|
||||
iconClassName = '',
|
||||
}) => (
|
||||
<>
|
||||
<div className="sn-dropdown-button-label">
|
||||
{icon ? (
|
||||
<div className="flex mr-2">
|
||||
<Icon type={icon} className={`sn-icon--small ${iconClassName}`} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="dropdown-selected-label">{label}</div>
|
||||
</div>
|
||||
<ListboxArrow className={`sn-dropdown-arrow ${isExpanded ? 'sn-dropdown-arrow-flipped' : ''}`}>
|
||||
<Icon type="menu-arrow-down" className="sn-icon--small color-passive-1" />
|
||||
</ListboxArrow>
|
||||
</>
|
||||
)
|
||||
|
||||
const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
|
||||
const labelId = `${id}-label`
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const selectedItem = items.find((item) => item.value === value) as DropdownItem
|
||||
|
||||
onChange(value, selectedItem)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
|
||||
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
|
||||
<ListboxButton
|
||||
className="sn-dropdown-button"
|
||||
children={({ value, label, isExpanded }) => {
|
||||
const current = items.find((item) => item.value === value)
|
||||
const icon = current ? current?.icon : null
|
||||
const iconClassName = current ? current?.iconClassName : null
|
||||
return CustomDropdownButton({
|
||||
value: value ? value : label.toLowerCase(),
|
||||
label,
|
||||
isExpanded,
|
||||
...(icon ? { icon } : null),
|
||||
...(iconClassName ? { iconClassName } : null),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ListboxPopover className="sn-dropdown sn-dropdown-popover">
|
||||
<div className="sn-component">
|
||||
<ListboxList>
|
||||
{items.map((item) => (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className="sn-dropdown-item"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex mr-3">
|
||||
<Icon type={item.icon} className={`sn-icon--small ${item.iconClassName ?? ''}`} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-input">{item.label}</div>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
</div>
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dropdown
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
export type DropdownItem = {
|
||||
icon?: IconType
|
||||
iconClassName?: string
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FileMenuOptions from './FileMenuOptions'
|
||||
|
||||
type Props = {
|
||||
filesController: FilesController
|
||||
selectionController: SelectedItemsController
|
||||
}
|
||||
|
||||
const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, selectionController }) => {
|
||||
const { showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = filesController
|
||||
|
||||
const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'hidden',
|
||||
})
|
||||
const [contextMenuMaxHeight, setContextMenuMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => setShowFileContextMenu(open))
|
||||
useCloseOnClickOutside(contextMenuRef, () => filesController.setShowFileContextMenu(false))
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="sn-dropdown min-w-60 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
|
||||
style={{
|
||||
...contextMenuStyle,
|
||||
maxHeight: contextMenuMaxHeight,
|
||||
}}
|
||||
>
|
||||
<FileMenuOptions
|
||||
filesController={filesController}
|
||||
selectionController={selectionController}
|
||||
closeOnBlur={closeOnBlur}
|
||||
closeMenu={() => setShowFileContextMenu(false)}
|
||||
shouldShowRenameOption={false}
|
||||
shouldShowAttachOption={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
FileContextMenu.displayName = 'FileContextMenu'
|
||||
|
||||
const FileContextMenuWrapper: FunctionComponent<Props> = ({ filesController, selectionController }) => {
|
||||
const { showFileContextMenu } = filesController
|
||||
const { selectedFiles } = selectionController
|
||||
|
||||
const selectedFile = selectedFiles[0]
|
||||
|
||||
if (!showFileContextMenu || !selectedFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <FileContextMenu filesController={filesController} selectionController={selectionController} />
|
||||
}
|
||||
|
||||
export default observer(FileContextMenuWrapper)
|
||||
@@ -0,0 +1,148 @@
|
||||
import { FunctionComponent, useCallback, useMemo } from 'react'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
|
||||
type Props = {
|
||||
closeMenu: () => void
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
|
||||
filesController: FilesController
|
||||
selectionController: SelectedItemsController
|
||||
isFileAttachedToNote?: boolean
|
||||
renameToggleCallback?: (isRenamingFile: boolean) => void
|
||||
shouldShowRenameOption: boolean
|
||||
shouldShowAttachOption: boolean
|
||||
}
|
||||
|
||||
const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
closeMenu,
|
||||
closeOnBlur,
|
||||
filesController,
|
||||
selectionController,
|
||||
isFileAttachedToNote,
|
||||
renameToggleCallback,
|
||||
shouldShowRenameOption,
|
||||
shouldShowAttachOption,
|
||||
}) => {
|
||||
const { selectedFiles } = selectionController
|
||||
const { handleFileAction } = filesController
|
||||
|
||||
const hasProtectedFiles = useMemo(() => selectedFiles.some((file) => file.protected), [selectedFiles])
|
||||
|
||||
const onPreview = useCallback(() => {
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.PreviewFile,
|
||||
payload: {
|
||||
file: selectedFiles[0],
|
||||
otherFiles: selectedFiles.length > 1 ? selectedFiles : filesController.allFiles,
|
||||
},
|
||||
})
|
||||
closeMenu()
|
||||
}, [closeMenu, filesController.allFiles, handleFileAction, selectedFiles])
|
||||
|
||||
const onDetach = useCallback(() => {
|
||||
const file = selectedFiles[0]
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.DetachFileToNote,
|
||||
payload: { file },
|
||||
})
|
||||
closeMenu()
|
||||
}, [closeMenu, handleFileAction, selectedFiles])
|
||||
|
||||
const onAttach = useCallback(() => {
|
||||
const file = selectedFiles[0]
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
payload: { file },
|
||||
})
|
||||
closeMenu()
|
||||
}, [closeMenu, handleFileAction, selectedFiles])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={onPreview}>
|
||||
<Icon type="file" className="mr-2 color-neutral" />
|
||||
Preview file
|
||||
</button>
|
||||
{selectedFiles.length === 1 && (
|
||||
<>
|
||||
{isFileAttachedToNote ? (
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={onDetach}>
|
||||
<Icon type="link-off" className="mr-2 color-neutral" />
|
||||
Detach from note
|
||||
</button>
|
||||
) : shouldShowAttachOption ? (
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item focus:bg-info-backdrop" onClick={onAttach}>
|
||||
<Icon type="link" className="mr-2 color-neutral" />
|
||||
Attach to note
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
void filesController.setProtectionForFiles(!hasProtectedFiles, selectionController.selectedFiles)
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className="mr-2 color-neutral" />
|
||||
Password protection
|
||||
</span>
|
||||
<Switch
|
||||
className="px-0 pointer-events-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
checked={hasProtectedFiles}
|
||||
/>
|
||||
</button>
|
||||
<div className="min-h-1px my-1 bg-border"></div>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
void filesController.downloadFiles(selectionController.selectedFiles)
|
||||
}}
|
||||
>
|
||||
<Icon type="download" className="mr-2 color-neutral" />
|
||||
Download
|
||||
</button>
|
||||
{shouldShowRenameOption && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
renameToggleCallback?.(true)
|
||||
}}
|
||||
>
|
||||
<Icon type="pencil" className="mr-2 color-neutral" />
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop"
|
||||
onClick={() => {
|
||||
void filesController.deleteFilesPermanently(selectionController.selectedFiles)
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="mr-2 color-danger" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
{selectedFiles.length === 1 && (
|
||||
<div className="px-3 pt-1.5 pb-0.5 text-xs color-neutral font-medium">
|
||||
<div>
|
||||
<span className="font-semibold">File ID:</span> {selectedFiles[0].uuid}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FileMenuOptions)
|
||||
@@ -0,0 +1,93 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import VisuallyHidden from '@reach/visually-hidden'
|
||||
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import FileMenuOptions from './FileMenuOptions'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
|
||||
type Props = {
|
||||
filesController: FilesController
|
||||
selectionController: SelectedItemsController
|
||||
}
|
||||
|
||||
const FilesOptionsPanel = ({ filesController, selectionController }: Props) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
|
||||
|
||||
const onDisclosureChange = 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 - 2)
|
||||
}
|
||||
setPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
})
|
||||
setOpen((open) => !open)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Disclosure open={open} onChange={onDisclosureChange}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className="sn-icon-button border-contrast"
|
||||
>
|
||||
<VisuallyHidden>Actions</VisuallyHidden>
|
||||
<Icon type="more" className="block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
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 py-2 overflow-y-auto fixed"
|
||||
onBlur={closeOnBlur}
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
>
|
||||
{open && (
|
||||
<FileMenuOptions
|
||||
filesController={filesController}
|
||||
selectionController={selectionController}
|
||||
closeOnBlur={closeOnBlur}
|
||||
closeMenu={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
shouldShowAttachOption={false}
|
||||
shouldShowRenameOption={false}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FilesOptionsPanel)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { MutableRefObject } from 'react'
|
||||
|
||||
export const createObjectURLWithRef = (
|
||||
type: FileItem['mimeType'],
|
||||
bytes: Uint8Array,
|
||||
ref: MutableRefObject<string | undefined>,
|
||||
) => {
|
||||
const objectURL = URL.createObjectURL(
|
||||
new Blob([bytes], {
|
||||
type,
|
||||
}),
|
||||
)
|
||||
|
||||
ref.current = objectURL
|
||||
|
||||
return objectURL
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { concatenateUint8Arrays } from '@/Utils'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import FilePreviewError from './FilePreviewError'
|
||||
import { isFileTypePreviewable } from './isFilePreviewable'
|
||||
import PreviewComponent from './PreviewComponent'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
file: FileItem
|
||||
}
|
||||
|
||||
const FilePreview = ({ file, application }: Props) => {
|
||||
const isFilePreviewable = useMemo(() => {
|
||||
return isFileTypePreviewable(file.mimeType)
|
||||
}, [file.mimeType])
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(true)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadedBytes, setDownloadedBytes] = useState<Uint8Array>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilePreviewable) {
|
||||
setIsDownloading(false)
|
||||
setDownloadProgress(0)
|
||||
setDownloadedBytes(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadFileForPreview = async () => {
|
||||
if (downloadedBytes) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
const chunks: Uint8Array[] = []
|
||||
setDownloadProgress(0)
|
||||
await application.files.downloadFile(file, async (decryptedChunk, progress) => {
|
||||
chunks.push(decryptedChunk)
|
||||
if (progress) {
|
||||
setDownloadProgress(Math.round(progress.percentComplete))
|
||||
}
|
||||
})
|
||||
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
|
||||
setDownloadedBytes(finalDecryptedBytes)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void downloadFileForPreview()
|
||||
}, [application.files, downloadedBytes, file, isFilePreviewable])
|
||||
|
||||
return isDownloading ? (
|
||||
<div className="flex flex-col justify-center items-center flex-grow">
|
||||
<div className="flex items-center">
|
||||
<div className="sk-spinner w-5 h-5 spinner-info mr-3"></div>
|
||||
<div className="text-base font-semibold">{downloadProgress}%</div>
|
||||
</div>
|
||||
<span className="mt-3">Loading file...</span>
|
||||
</div>
|
||||
) : downloadedBytes ? (
|
||||
<PreviewComponent file={file} bytes={downloadedBytes} />
|
||||
) : (
|
||||
<FilePreviewError
|
||||
file={file}
|
||||
filesController={application.getViewControllerManager().filesController}
|
||||
tryAgainCallback={() => {
|
||||
setDownloadedBytes(undefined)
|
||||
}}
|
||||
isFilePreviewable={isFilePreviewable}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreview
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { NoPreviewIllustration } from '@standardnotes/icons'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import Button from '../Button/Button'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
filesController: FilesController
|
||||
isFilePreviewable: boolean
|
||||
tryAgainCallback: () => void
|
||||
}
|
||||
|
||||
const FilePreviewError = ({ file, filesController, isFilePreviewable, tryAgainCallback }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow">
|
||||
<NoPreviewIllustration className="w-30 h-30 mb-4" />
|
||||
<div className="font-bold text-base mb-2">This file can't be previewed.</div>
|
||||
{isFilePreviewable ? (
|
||||
<>
|
||||
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||
There was an error loading the file. Try again, or download the file and open it using another application.
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mr-3"
|
||||
onClick={() => {
|
||||
tryAgainCallback()
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
variant="normal"
|
||||
onClick={() => {
|
||||
filesController.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-center color-passive-0 mb-4 max-w-35ch">
|
||||
To view this file, download it and open it using another application.
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
filesController.downloadFile(file).catch(console.error)
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewError
|
||||
@@ -0,0 +1,39 @@
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
}
|
||||
|
||||
const FilePreviewInfoPanel: FunctionComponent<Props> = ({ file }) => {
|
||||
return (
|
||||
<div className="flex flex-col min-w-70 p-4 border-0 border-l-1px border-solid border-main">
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon type="info" className="mr-2" />
|
||||
<div className="font-semibold">File information</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Type:</span> {file.mimeType}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Decrypted Size:</span> {formatSizeToReadableString(file.decryptedSize)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Encrypted Size:</span> {formatSizeToReadableString(file.encryptedSize)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Created:</span> {file.created_at.toLocaleString()}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold">Last Modified:</span> {file.userModifiedDate.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">File ID:</span> {file.uuid}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewInfoPanel
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import FilePreview from './FilePreview'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
}
|
||||
|
||||
const FilePreviewModal: FunctionComponent<Props> = observer(({ application, viewControllerManager }) => {
|
||||
const { currentFile, setCurrentFile, otherFiles, dismiss } = viewControllerManager.filePreviewModalController
|
||||
|
||||
if (!currentFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [showFileInfoPanel, setShowFileInfoPanel] = useState(false)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const keyDownHandler: KeyboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
const hasNotPressedLeftOrRightKeys = event.key !== KeyboardKey.Left && event.key !== KeyboardKey.Right
|
||||
|
||||
if (hasNotPressedLeftOrRightKeys) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const currentFileIndex = otherFiles.findIndex((fileFromArray) => fileFromArray.uuid === currentFile.uuid)
|
||||
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Left: {
|
||||
const previousFileIndex = currentFileIndex - 1 >= 0 ? currentFileIndex - 1 : otherFiles.length - 1
|
||||
const previousFile = otherFiles[previousFileIndex]
|
||||
if (previousFile) {
|
||||
setCurrentFile(previousFile)
|
||||
}
|
||||
break
|
||||
}
|
||||
case KeyboardKey.Right: {
|
||||
const nextFileIndex = currentFileIndex + 1 < otherFiles.length ? currentFileIndex + 1 : 0
|
||||
const nextFile = otherFiles[nextFileIndex]
|
||||
if (nextFile) {
|
||||
setCurrentFile(nextFile)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[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 (
|
||||
<DialogOverlay
|
||||
className="sn-component"
|
||||
aria-label="File preview modal"
|
||||
onDismiss={dismiss}
|
||||
initialFocusRef={closeButtonRef}
|
||||
dangerouslyBypassScrollLock
|
||||
>
|
||||
<DialogContent
|
||||
aria-label="File preview modal"
|
||||
className="flex flex-col rounded shadow-overlay"
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: '90%',
|
||||
minHeight: '90%',
|
||||
background: 'var(--modal-background-color)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex flex-shrink-0 justify-between items-center min-h-6 px-4 py-3 border-0 border-b-1 border-solid border-main focus:shadow-none"
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
onKeyDown={keyDownHandler}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-6 h-6">{IconComponent}</div>
|
||||
<span className="ml-3 font-medium">{currentFile.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex p-1.5 mr-4 bg-transparent hover:bg-contrast border-solid border-main border-1 cursor-pointer rounded"
|
||||
onClick={() => setShowFileInfoPanel((show) => !show)}
|
||||
>
|
||||
<Icon type="info" className="color-neutral" />
|
||||
</button>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={dismiss}
|
||||
aria-label="Close modal"
|
||||
className="flex p-1 bg-transparent hover:bg-contrast border-0 cursor-pointer rounded"
|
||||
>
|
||||
<Icon type="close" className="color-neutral" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow min-h-0">
|
||||
<div className="flex flex-grow items-center justify-center relative max-w-full">
|
||||
<FilePreview file={currentFile} application={application} key={currentFile.uuid} />
|
||||
</div>
|
||||
{showFileInfoPanel && <FilePreviewInfoPanel file={currentFile} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
)
|
||||
})
|
||||
|
||||
FilePreviewModal.displayName = 'FilePreviewModal'
|
||||
|
||||
const FilePreviewModalWrapper: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
|
||||
return viewControllerManager.filePreviewModalController.isOpen ? (
|
||||
<FilePreviewModal application={application} viewControllerManager={viewControllerManager} />
|
||||
) : null
|
||||
}
|
||||
|
||||
export default observer(FilePreviewModalWrapper)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useRef, useState } from 'react'
|
||||
import IconButton from '../Button/IconButton'
|
||||
|
||||
type Props = {
|
||||
objectUrl: string
|
||||
}
|
||||
|
||||
const ImagePreview: FunctionComponent<Props> = ({ objectUrl }) => {
|
||||
const initialImgHeightRef = useRef<number>()
|
||||
|
||||
const [imageZoomPercent, setImageZoomPercent] = useState(100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full min-h-0">
|
||||
<div className="flex items-center justify-center w-full h-full relative overflow-auto">
|
||||
<img
|
||||
src={objectUrl}
|
||||
style={{
|
||||
height: `${imageZoomPercent}%`,
|
||||
...(imageZoomPercent <= 100
|
||||
? {
|
||||
minWidth: '100%',
|
||||
objectFit: 'contain',
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
margin: 'auto',
|
||||
}),
|
||||
}}
|
||||
ref={(imgElement) => {
|
||||
if (!initialImgHeightRef.current) {
|
||||
initialImgHeightRef.current = imgElement?.height
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center absolute left-1/2 -translate-x-1/2 bottom-6 py-1 px-3 bg-default border-1 border-solid border-main rounded">
|
||||
<span className="mr-1.5">Zoom:</span>
|
||||
<IconButton
|
||||
className="hover:bg-contrast p-1 rounded"
|
||||
icon={'subtract' as IconType}
|
||||
title="Zoom Out"
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => {
|
||||
const newPercent = percent - 10
|
||||
if (newPercent >= 10) {
|
||||
return newPercent
|
||||
} else {
|
||||
return percent
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2">{imageZoomPercent}%</span>
|
||||
<IconButton
|
||||
className="hover:bg-contrast p-1 rounded"
|
||||
icon="add"
|
||||
title="Zoom In"
|
||||
focusable={true}
|
||||
onClick={() => {
|
||||
setImageZoomPercent((percent) => percent + 10)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreview
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useEffect, useMemo, useRef } from 'react'
|
||||
import { createObjectURLWithRef } from './CreateObjectURLWithRef'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import { PreviewableTextFileTypes } from './isFilePreviewable'
|
||||
import TextPreview from './TextPreview'
|
||||
|
||||
type Props = {
|
||||
file: FileItem
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
const PreviewComponent: FunctionComponent<Props> = ({ file, bytes }) => {
|
||||
const objectUrlRef = useRef<string>()
|
||||
|
||||
const objectUrl = useMemo(() => {
|
||||
return createObjectURLWithRef(file.mimeType, bytes, objectUrlRef)
|
||||
}, [bytes, file.mimeType])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = objectUrlRef.current
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrlRef.current = ''
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <ImagePreview objectUrl={objectUrl} />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return <video className="w-full h-full" src={objectUrl} controls autoPlay />
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('audio/')) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<audio src={objectUrl} controls />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (PreviewableTextFileTypes.includes(file.mimeType)) {
|
||||
return <TextPreview bytes={bytes} />
|
||||
}
|
||||
|
||||
return <object className="w-full h-full" data={objectUrl} />
|
||||
}
|
||||
|
||||
export default PreviewComponent
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type Props = {
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
const TextPreview = ({ bytes }: Props) => {
|
||||
const text = useMemo(() => {
|
||||
const textDecoder = new TextDecoder()
|
||||
return textDecoder.decode(bytes)
|
||||
}, [bytes])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
autoComplete="off"
|
||||
className="w-full h-full flex-grow font-editor focus:shadow-none focus:outline-none"
|
||||
dir="auto"
|
||||
id={ElementIds.FileTextPreview}
|
||||
defaultValue={text}
|
||||
readOnly={true}
|
||||
></textarea>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextPreview
|
||||
@@ -0,0 +1,15 @@
|
||||
export const PreviewableTextFileTypes = ['text/plain', 'application/json']
|
||||
|
||||
export const isFileTypePreviewable = (fileType: string) => {
|
||||
const isImage = fileType.startsWith('image/')
|
||||
const isVideo = fileType.startsWith('video/')
|
||||
const isAudio = fileType.startsWith('audio/')
|
||||
const isPdf = fileType === 'application/pdf'
|
||||
const isText = PreviewableTextFileTypes.includes(fileType)
|
||||
|
||||
if (isImage || isVideo || isAudio || isText || isPdf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
|
||||
import FileViewWithoutProtection from './FileViewWithoutProtection'
|
||||
import { FileViewProps } from './FileViewProps'
|
||||
|
||||
const FileView = ({ application, viewControllerManager, file }: FileViewProps) => {
|
||||
const [shouldShowProtectedOverlay, setShouldShowProtectedOverlay] = useState(
|
||||
file.protected && !application.hasProtectionSources(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setShouldShowProtectedOverlay(viewControllerManager.filesController.showProtectedOverlay)
|
||||
}, [viewControllerManager.filesController.showProtectedOverlay])
|
||||
|
||||
const dismissProtectedWarning = useCallback(() => {
|
||||
void viewControllerManager.filesController.toggleFileProtection(file)
|
||||
}, [file, viewControllerManager.filesController])
|
||||
|
||||
return shouldShowProtectedOverlay ? (
|
||||
<div aria-label="Note" className="section editor sn-component">
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<ProtectedItemOverlay
|
||||
viewControllerManager={viewControllerManager}
|
||||
hasProtectionSources={application.hasProtectionSources()}
|
||||
onViewItem={dismissProtectedWarning}
|
||||
itemType={'note'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FileViewWithoutProtection application={application} viewControllerManager={viewControllerManager} file={file} />
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FileView)
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { FileItem } from '@standardnotes/snjs/dist/@types'
|
||||
|
||||
export type FileViewProps = {
|
||||
application: WebApplication
|
||||
viewControllerManager: ViewControllerManager
|
||||
file: FileItem
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ChangeEventHandler, FormEventHandler, useCallback, useEffect, useState } from 'react'
|
||||
import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
|
||||
import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
|
||||
import FilePreview from '@/Components/FilePreview/FilePreview'
|
||||
import { FileViewProps } from './FileViewProps'
|
||||
|
||||
const FileViewWithoutProtection = ({ application, viewControllerManager, file }: FileViewProps) => {
|
||||
const [name, setName] = useState(file.name)
|
||||
|
||||
useEffect(() => {
|
||||
setName(file.name)
|
||||
}, [file.name])
|
||||
|
||||
const onTitleChange: ChangeEventHandler<HTMLInputElement> = useCallback(async (event) => {
|
||||
setName(event.target.value)
|
||||
}, [])
|
||||
|
||||
const onFormSubmit: FormEventHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
await application.items.renameFile(file, name)
|
||||
|
||||
void application.sync.sync()
|
||||
},
|
||||
[application.items, application.sync, file, name],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sn-component section editor" aria-label="File">
|
||||
<div className="flex flex-col">
|
||||
<div className="content-title-bar section-title-bar w-full" id="file-title-bar">
|
||||
<div className="flex items-center justify-between h-8">
|
||||
<div className="flex-grow">
|
||||
<form onSubmit={onFormSubmit} className="title overflow-auto">
|
||||
<input
|
||||
className="input"
|
||||
id={ElementIds.FileTitleEditor}
|
||||
onChange={onTitleChange}
|
||||
onFocus={(event) => {
|
||||
event.target.select()
|
||||
}}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3">
|
||||
<AttachedFilesButton
|
||||
application={application}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
filePreviewModalController={viewControllerManager.filePreviewModalController}
|
||||
filesController={viewControllerManager.filesController}
|
||||
navigationController={viewControllerManager.navigationController}
|
||||
notesController={viewControllerManager.notesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
/>
|
||||
</div>
|
||||
<FileOptionsPanel
|
||||
filesController={viewControllerManager.filesController}
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FilePreview file={file} application={application} key={file.uuid} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(FileViewWithoutProtection)
|
||||
456
packages/web/src/javascripts/Components/Footer/Footer.tsx
Normal file
456
packages/web/src/javascripts/Components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { WebAppEvent } from '@/Application/WebAppEvent'
|
||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { destroyAllObjectProperties, preventRefreshing } from '@/Utils'
|
||||
import { ApplicationEvent, ApplicationDescriptor } from '@standardnotes/snjs'
|
||||
import {
|
||||
STRING_NEW_UPDATE_READY,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_UPGRADE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
} from '@/Constants/Strings'
|
||||
import { alertDialog, confirmDialog } from '@/Services/AlertService'
|
||||
import AccountMenu from '@/Components/AccountMenu/AccountMenu'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu'
|
||||
import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu'
|
||||
import { Fragment } from 'react'
|
||||
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
|
||||
import { EditorEventSource } from '@/Types/EditorEventSource'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
applicationGroup: ApplicationGroup
|
||||
}
|
||||
|
||||
type State = {
|
||||
outOfSync: boolean
|
||||
dataUpgradeAvailable: boolean
|
||||
hasPasscode: boolean
|
||||
descriptors: ApplicationDescriptor[]
|
||||
showBetaWarning: boolean
|
||||
showSyncResolution: boolean
|
||||
newUpdateAvailable: boolean
|
||||
showAccountMenu: boolean
|
||||
showQuickSettingsMenu: boolean
|
||||
offline: boolean
|
||||
hasError: boolean
|
||||
arbitraryStatusMessage?: string
|
||||
}
|
||||
|
||||
class Footer extends PureComponent<Props, State> {
|
||||
public user?: unknown
|
||||
private didCheckForOffline = false
|
||||
private completedInitialSync = false
|
||||
private showingDownloadStatus = false
|
||||
private webEventListenerDestroyer: () => void
|
||||
private removeStatusObserver!: () => void
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
offline: true,
|
||||
outOfSync: false,
|
||||
dataUpgradeAvailable: false,
|
||||
hasPasscode: false,
|
||||
descriptors: props.applicationGroup.getDescriptors(),
|
||||
showBetaWarning: false,
|
||||
showSyncResolution: false,
|
||||
newUpdateAvailable: false,
|
||||
showAccountMenu: false,
|
||||
showQuickSettingsMenu: false,
|
||||
}
|
||||
|
||||
this.webEventListenerDestroyer = props.application.addWebEventObserver((event, data) => {
|
||||
const statusService = this.application.status
|
||||
|
||||
switch (event) {
|
||||
case WebAppEvent.NewUpdateAvailable:
|
||||
this.onNewUpdateAvailable()
|
||||
break
|
||||
case WebAppEvent.EditorFocused:
|
||||
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
|
||||
this.closeAccountMenu()
|
||||
}
|
||||
break
|
||||
case WebAppEvent.BeganBackupDownload:
|
||||
statusService.setMessage('Saving local backup…')
|
||||
break
|
||||
case WebAppEvent.EndedBackupDownload: {
|
||||
const successMessage = 'Successfully saved backup.'
|
||||
const errorMessage = 'Unable to save local backup.'
|
||||
statusService.setMessage((data as any).success ? successMessage : errorMessage)
|
||||
|
||||
const twoSeconds = 2000
|
||||
setTimeout(() => {
|
||||
if (statusService.message === successMessage || statusService.message === errorMessage) {
|
||||
statusService.setMessage('')
|
||||
}
|
||||
}, twoSeconds)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.removeStatusObserver()
|
||||
;(this.removeStatusObserver as unknown) = undefined
|
||||
|
||||
this.webEventListenerDestroyer()
|
||||
;(this.webEventListenerDestroyer as unknown) = undefined
|
||||
|
||||
super.deinit()
|
||||
|
||||
destroyAllObjectProperties(this)
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
|
||||
this.removeStatusObserver = this.application.status.addEventObserver((_event, message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
})
|
||||
})
|
||||
|
||||
this.autorun(() => {
|
||||
const showBetaWarning = this.viewControllerManager.showBetaWarning
|
||||
this.setState({
|
||||
showBetaWarning: showBetaWarning,
|
||||
showAccountMenu: this.viewControllerManager.accountMenuController.show,
|
||||
showQuickSettingsMenu: this.viewControllerManager.quickSettingsMenuController.open,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
reloadUpgradeStatus() {
|
||||
this.application
|
||||
.checkForSecurityUpdate()
|
||||
.then((available) => {
|
||||
this.setState({
|
||||
dataUpgradeAvailable: available,
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
override async onAppLaunch() {
|
||||
super.onAppLaunch().catch(console.error)
|
||||
this.reloadPasscodeStatus().catch(console.error)
|
||||
this.reloadUser()
|
||||
this.reloadUpgradeStatus()
|
||||
this.updateOfflineStatus()
|
||||
this.findErrors()
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
this.user = this.application.getUser()
|
||||
}
|
||||
|
||||
async reloadPasscodeStatus() {
|
||||
const hasPasscode = this.application.hasPasscode()
|
||||
this.setState({
|
||||
hasPasscode: hasPasscode,
|
||||
})
|
||||
}
|
||||
|
||||
override async onAppKeyChange() {
|
||||
super.onAppKeyChange().catch(console.error)
|
||||
this.reloadPasscodeStatus().catch(console.error)
|
||||
}
|
||||
|
||||
override onAppEvent(eventName: ApplicationEvent) {
|
||||
switch (eventName) {
|
||||
case ApplicationEvent.KeyStatusChanged:
|
||||
this.reloadUpgradeStatus()
|
||||
break
|
||||
case ApplicationEvent.EnteredOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: true,
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.ExitedOutOfSync:
|
||||
this.setState({
|
||||
outOfSync: false,
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.status.setMessage('')
|
||||
this.completedInitialSync = true
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
this.didCheckForOffline = true
|
||||
if (this.state.offline && this.application.items.getNoteCount() === 0) {
|
||||
this.viewControllerManager.accountMenuController.setShow(true)
|
||||
}
|
||||
}
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.SyncStatusChanged:
|
||||
this.updateSyncStatus()
|
||||
break
|
||||
case ApplicationEvent.FailedSync:
|
||||
this.updateSyncStatus()
|
||||
this.findErrors()
|
||||
this.updateOfflineStatus()
|
||||
break
|
||||
case ApplicationEvent.LocalDataIncrementalLoad:
|
||||
case ApplicationEvent.LocalDataLoaded:
|
||||
this.updateLocalDataStatus()
|
||||
break
|
||||
case ApplicationEvent.SignedIn:
|
||||
case ApplicationEvent.SignedOut:
|
||||
this.reloadUser()
|
||||
break
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.status.setMessage('Syncing…')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.status
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
if (syncStatus.hasError()) {
|
||||
statusManager.setMessage('Unable to Sync')
|
||||
} else if (stats.downloadCount > 20) {
|
||||
const text = `Downloading ${stats.downloadCount} items. Keep app open.`
|
||||
statusManager.setMessage(text)
|
||||
this.showingDownloadStatus = true
|
||||
} else if (this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false
|
||||
statusManager.setMessage('Download Complete.')
|
||||
setTimeout(() => {
|
||||
statusManager.setMessage('')
|
||||
}, 2000)
|
||||
} else if (stats.uploadTotalCount > 20) {
|
||||
const completionPercentage =
|
||||
stats.uploadCompletionCount === 0 ? 0 : stats.uploadCompletionCount / stats.uploadTotalCount
|
||||
|
||||
const stringPercentage = completionPercentage.toLocaleString(undefined, {
|
||||
style: 'percent',
|
||||
})
|
||||
|
||||
statusManager.setMessage(`Syncing ${stats.uploadTotalCount} items (${stringPercentage} complete)`)
|
||||
} else {
|
||||
statusManager.setMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.status
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
const encryption = this.application.isEncryptionAvailable()
|
||||
if (stats.localDataDone) {
|
||||
statusManager.setMessage('')
|
||||
return
|
||||
}
|
||||
const notesString = `${stats.localDataCurrent}/${stats.localDataTotal} items...`
|
||||
const loadingStatus = encryption ? `Decrypting ${notesString}` : `Loading ${notesString}`
|
||||
statusManager.setMessage(loadingStatus)
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.setState({
|
||||
offline: this.application.noAccount(),
|
||||
})
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.setState({
|
||||
hasError: this.application.sync.getSyncStatus().hasError(),
|
||||
})
|
||||
}
|
||||
|
||||
securityUpdateClickHandler = async () => {
|
||||
if (
|
||||
await confirmDialog({
|
||||
title: STRING_UPGRADE_ACCOUNT_CONFIRM_TITLE,
|
||||
text: STRING_UPGRADE_ACCOUNT_CONFIRM_TEXT,
|
||||
confirmButtonText: STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
|
||||
})
|
||||
) {
|
||||
preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_UPGRADE, async () => {
|
||||
await this.application.upgradeProtocolVersion()
|
||||
}).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
accountMenuClickHandler = () => {
|
||||
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
|
||||
this.viewControllerManager.accountMenuController.toggleShow()
|
||||
}
|
||||
|
||||
quickSettingsClickHandler = () => {
|
||||
this.viewControllerManager.accountMenuController.closeAccountMenu()
|
||||
this.viewControllerManager.quickSettingsMenuController.toggle()
|
||||
}
|
||||
|
||||
syncResolutionClickHandler = () => {
|
||||
this.setState({
|
||||
showSyncResolution: !this.state.showSyncResolution,
|
||||
})
|
||||
}
|
||||
|
||||
closeAccountMenu = () => {
|
||||
this.viewControllerManager.accountMenuController.setShow(false)
|
||||
this.viewControllerManager.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
|
||||
}
|
||||
|
||||
lockClickHandler = () => {
|
||||
this.application.lock().catch(console.error)
|
||||
}
|
||||
|
||||
onNewUpdateAvailable = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: true,
|
||||
})
|
||||
}
|
||||
|
||||
newUpdateClickHandler = () => {
|
||||
this.setState({
|
||||
newUpdateAvailable: false,
|
||||
})
|
||||
this.application.alertService.alert(STRING_NEW_UPDATE_READY).catch(console.error)
|
||||
}
|
||||
|
||||
betaMessageClickHandler = () => {
|
||||
alertDialog({
|
||||
title: 'You are using a beta version of the app',
|
||||
text: 'If you wish to go back to a stable version, make sure to sign out ' + 'of this beta app first.',
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu = () => {
|
||||
this.viewControllerManager.accountMenuController.closeAccountMenu()
|
||||
}
|
||||
|
||||
clickOutsideQuickSettingsMenu = () => {
|
||||
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
<div id="footer-bar" className="sk-app-bar no-edges no-bottom-edge">
|
||||
<div className="left">
|
||||
<div className="sk-app-bar-item ml-0">
|
||||
<div
|
||||
onClick={this.accountMenuClickHandler}
|
||||
className={
|
||||
(this.state.showAccountMenu ? 'bg-border' : '') +
|
||||
' w-8 h-full flex items-center justify-center cursor-pointer rounded-full'
|
||||
}
|
||||
>
|
||||
<div className={this.state.hasError ? 'danger' : (this.user ? 'info' : 'neutral') + ' w-5 h-5'}>
|
||||
<Icon type="account-circle" className="hover:color-info w-5 h-5 max-h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showAccountMenu && (
|
||||
<AccountMenu
|
||||
onClickOutside={this.clickOutsideAccountMenu}
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
application={this.application}
|
||||
mainApplicationGroup={this.props.applicationGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sk-app-bar-item ml-0-important">
|
||||
<div
|
||||
onClick={this.quickSettingsClickHandler}
|
||||
className="w-8 h-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon
|
||||
type="tune"
|
||||
className={(this.state.showQuickSettingsMenu ? 'color-info' : '') + ' rounded hover:color-info'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{this.state.showQuickSettingsMenu && (
|
||||
<QuickSettingsMenu
|
||||
onClickOutside={this.clickOutsideQuickSettingsMenu}
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
application={this.application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{this.state.showBetaWarning && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div className="sk-app-bar-item">
|
||||
<a onClick={this.betaMessageClickHandler} className="no-decoration sk-label title">
|
||||
You are using a beta version of the app
|
||||
</a>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="center">
|
||||
{this.state.arbitraryStatusMessage && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-app-bar-item-column">
|
||||
<span className="neutral sk-label">{this.state.arbitraryStatusMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="right">
|
||||
{this.state.dataUpgradeAvailable && (
|
||||
<div onClick={this.securityUpdateClickHandler} className="sk-app-bar-item">
|
||||
<span className="success sk-label">Encryption upgrade available.</span>
|
||||
</div>
|
||||
)}
|
||||
{this.state.newUpdateAvailable && (
|
||||
<div onClick={this.newUpdateClickHandler} className="sk-app-bar-item">
|
||||
<span className="info sk-label">New update available.</span>
|
||||
</div>
|
||||
)}
|
||||
{(this.state.outOfSync || this.state.showSyncResolution) && (
|
||||
<div className="sk-app-bar-item">
|
||||
{this.state.outOfSync && (
|
||||
<div onClick={this.syncResolutionClickHandler} className="sk-label warning">
|
||||
Potentially Out of Sync
|
||||
</div>
|
||||
)}
|
||||
{this.state.showSyncResolution && (
|
||||
<SyncResolutionMenu close={this.syncResolutionClickHandler} application={this.application} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{this.state.offline && (
|
||||
<div className="sk-app-bar-item">
|
||||
<div className="sk-label">Offline</div>
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<Fragment>
|
||||
<div className="sk-app-bar-item border" />
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="sk-app-bar-item pl-1 hover:color-info"
|
||||
>
|
||||
<Icon type="lock-filled" />
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Footer
|
||||
204
packages/web/src/javascripts/Components/Icon/Icon.tsx
Normal file
204
packages/web/src/javascripts/Components/Icon/Icon.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
|
||||
import {
|
||||
AccessibilityIcon,
|
||||
AccountCircleIcon,
|
||||
AddIcon,
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowsSortDownIcon,
|
||||
ArrowsSortUpIcon,
|
||||
AttachmentFileIcon,
|
||||
AuthenticatorIcon,
|
||||
CheckBoldIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClearCircleFilledIcon,
|
||||
CloseIcon,
|
||||
CloudOffIcon,
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
DashboardIcon,
|
||||
DownloadIcon,
|
||||
EditorIcon,
|
||||
EmailIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileDocIcon,
|
||||
FileIcon,
|
||||
FileImageIcon,
|
||||
FileMovIcon,
|
||||
FileMusicIcon,
|
||||
FileOtherIcon,
|
||||
FilePdfIcon,
|
||||
FilePptIcon,
|
||||
FileXlsIcon,
|
||||
FileZipIcon,
|
||||
FolderIcon,
|
||||
HashtagIcon,
|
||||
HashtagOffIcon,
|
||||
HelpIcon,
|
||||
HistoryIcon,
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
LinkIcon,
|
||||
LinkOffIcon,
|
||||
ListBulleted,
|
||||
ListedIcon,
|
||||
LockFilledIcon,
|
||||
LockIcon,
|
||||
MarkdownIcon,
|
||||
MenuArrowDownAlt,
|
||||
MenuArrowDownIcon,
|
||||
MenuArrowRightIcon,
|
||||
MenuCloseIcon,
|
||||
MoreIcon,
|
||||
NotesIcon,
|
||||
PasswordIcon,
|
||||
PencilFilledIcon,
|
||||
PencilIcon,
|
||||
PencilOffIcon,
|
||||
PinFilledIcon,
|
||||
PinIcon,
|
||||
PlainTextIcon,
|
||||
PremiumFeatureIcon,
|
||||
RestoreIcon,
|
||||
RichTextIcon,
|
||||
SecurityIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
SignInIcon,
|
||||
SignOutIcon,
|
||||
SpreadsheetsIcon,
|
||||
StarIcon,
|
||||
SyncIcon,
|
||||
TasksIcon,
|
||||
ThemesIcon,
|
||||
TrashFilledIcon,
|
||||
TrashIcon,
|
||||
TrashSweepIcon,
|
||||
TuneIcon,
|
||||
UnarchiveIcon,
|
||||
UnpinIcon,
|
||||
UserAddIcon,
|
||||
UserIcon,
|
||||
UserSwitch,
|
||||
WarningIcon,
|
||||
WindowIcon,
|
||||
SubtractIcon,
|
||||
} from '@standardnotes/icons'
|
||||
|
||||
export const ICONS = {
|
||||
'account-circle': AccountCircleIcon,
|
||||
'arrow-left': ArrowLeftIcon,
|
||||
'arrows-sort-down': ArrowsSortDownIcon,
|
||||
'arrows-sort-up': ArrowsSortUpIcon,
|
||||
'attachment-file': AttachmentFileIcon,
|
||||
'check-bold': CheckBoldIcon,
|
||||
'check-circle': CheckCircleIcon,
|
||||
'chevron-down': ChevronDownIcon,
|
||||
'chevron-right': ChevronRightIcon,
|
||||
'clear-circle-filled': ClearCircleFilledIcon,
|
||||
'cloud-off': CloudOffIcon,
|
||||
'eye-off': EyeOffIcon,
|
||||
'file-doc': FileDocIcon,
|
||||
'file-image': FileImageIcon,
|
||||
'file-mov': FileMovIcon,
|
||||
'file-music': FileMusicIcon,
|
||||
'file-other': FileOtherIcon,
|
||||
'file-pdf': FilePdfIcon,
|
||||
'file-ppt': FilePptIcon,
|
||||
'file-xls': FileXlsIcon,
|
||||
'file-zip': FileZipIcon,
|
||||
'hashtag-off': HashtagOffIcon,
|
||||
'link-off': LinkOffIcon,
|
||||
'list-bulleted': ListBulleted,
|
||||
'lock-filled': LockFilledIcon,
|
||||
'menu-arrow-down-alt': MenuArrowDownAlt,
|
||||
'menu-arrow-down': MenuArrowDownIcon,
|
||||
'menu-arrow-right': MenuArrowRightIcon,
|
||||
'menu-close': MenuCloseIcon,
|
||||
'pencil-filled': PencilFilledIcon,
|
||||
'pencil-off': PencilOffIcon,
|
||||
'pin-filled': PinFilledIcon,
|
||||
'plain-text': PlainTextIcon,
|
||||
'premium-feature': PremiumFeatureIcon,
|
||||
'rich-text': RichTextIcon,
|
||||
'trash-filled': TrashFilledIcon,
|
||||
'trash-sweep': TrashSweepIcon,
|
||||
'user-add': UserAddIcon,
|
||||
'user-switch': UserSwitch,
|
||||
accessibility: AccessibilityIcon,
|
||||
add: AddIcon,
|
||||
archive: ArchiveIcon,
|
||||
authenticator: AuthenticatorIcon,
|
||||
check: CheckIcon,
|
||||
close: CloseIcon,
|
||||
code: CodeIcon,
|
||||
copy: CopyIcon,
|
||||
dashboard: DashboardIcon,
|
||||
download: DownloadIcon,
|
||||
editor: EditorIcon,
|
||||
email: EmailIcon,
|
||||
eye: EyeIcon,
|
||||
file: FileIcon,
|
||||
folder: FolderIcon,
|
||||
hashtag: HashtagIcon,
|
||||
help: HelpIcon,
|
||||
history: HistoryIcon,
|
||||
info: InfoIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
link: LinkIcon,
|
||||
listed: ListedIcon,
|
||||
lock: LockIcon,
|
||||
markdown: MarkdownIcon,
|
||||
more: MoreIcon,
|
||||
notes: NotesIcon,
|
||||
password: PasswordIcon,
|
||||
pencil: PencilIcon,
|
||||
pin: PinIcon,
|
||||
restore: RestoreIcon,
|
||||
security: SecurityIcon,
|
||||
server: ServerIcon,
|
||||
settings: SettingsIcon,
|
||||
signIn: SignInIcon,
|
||||
signOut: SignOutIcon,
|
||||
spreadsheets: SpreadsheetsIcon,
|
||||
star: StarIcon,
|
||||
sync: SyncIcon,
|
||||
tasks: TasksIcon,
|
||||
themes: ThemesIcon,
|
||||
trash: TrashIcon,
|
||||
tune: TuneIcon,
|
||||
unarchive: UnarchiveIcon,
|
||||
unpin: UnpinIcon,
|
||||
user: UserIcon,
|
||||
warning: WarningIcon,
|
||||
window: WindowIcon,
|
||||
subtract: SubtractIcon,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: IconType
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel }) => {
|
||||
const IconComponent = ICONS[type as keyof typeof ICONS]
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<IconComponent
|
||||
className={`sn-icon ${className}`}
|
||||
role="img"
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : { 'aria-hidden': true })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
@@ -0,0 +1,78 @@
|
||||
import { forwardRef, Fragment, Ref } from 'react'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean) => {
|
||||
return {
|
||||
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
|
||||
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
|
||||
}`,
|
||||
input: `w-full border-0 focus:shadow-none bg-transparent color-text ${
|
||||
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
|
||||
} ${hasRightDecorations ? 'pr-2' : ''}`,
|
||||
disabled: 'bg-passive-5 cursor-not-allowed',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that can be decorated on the left and right side
|
||||
*/
|
||||
const DecoratedInput = forwardRef(
|
||||
(
|
||||
{
|
||||
type = 'text',
|
||||
className = '',
|
||||
disabled = false,
|
||||
left,
|
||||
right,
|
||||
value,
|
||||
placeholder = '',
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
autocomplete = false,
|
||||
}: DecoratedInputProps,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const hasLeftDecorations = Boolean(left?.length)
|
||||
const hasRightDecorations = Boolean(right?.length)
|
||||
const classNames = getClassNames(hasLeftDecorations, hasRightDecorations)
|
||||
|
||||
return (
|
||||
<div className={`${classNames.container} ${disabled ? classNames.disabled : ''} ${className}`}>
|
||||
{left && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{left.map((leftChild, index) => (
|
||||
<Fragment key={index}>{leftChild}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type={type}
|
||||
className={`${classNames.input} ${disabled ? classNames.disabled : ''}`}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange && onChange((e.target as HTMLInputElement).value)}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
data-lpignore={type !== 'password' ? true : false}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
{right && (
|
||||
<div className="flex items-center px-2 py-1.5">
|
||||
{right.map((rightChild, index) => (
|
||||
<div className={index > 0 ? 'ml-3' : ''} key={index}>
|
||||
{rightChild}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default DecoratedInput
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FocusEventHandler, KeyboardEventHandler, ReactNode } from 'react'
|
||||
|
||||
export type DecoratedInputProps = {
|
||||
type?: 'text' | 'email' | 'password'
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
left?: ReactNode[]
|
||||
right?: ReactNode[]
|
||||
value?: string
|
||||
placeholder?: string
|
||||
onChange?: (text: string) => void
|
||||
onFocus?: FocusEventHandler
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
autocomplete?: boolean
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Dispatch, FunctionComponent, Ref, SetStateAction, forwardRef, useState } from 'react'
|
||||
import DecoratedInput from './DecoratedInput'
|
||||
import IconButton from '@/Components/Button/IconButton'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const Toggle: FunctionComponent<{
|
||||
isToggled: boolean
|
||||
setIsToggled: Dispatch<SetStateAction<boolean>>
|
||||
}> = ({ isToggled, setIsToggled }) => (
|
||||
<IconButton
|
||||
className="w-5 h-5 p-0 justify-center sk-circle hover:bg-passive-4 color-neutral"
|
||||
icon={isToggled ? 'eye-off' : 'eye'}
|
||||
iconClassName="sn-icon--small"
|
||||
title="Show/hide password"
|
||||
onClick={() => setIsToggled((isToggled) => !isToggled)}
|
||||
focusable={true}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* Password input that has a toggle to show/hide password and can be decorated on the left and right side
|
||||
*/
|
||||
const DecoratedPasswordInput = forwardRef((props: DecoratedInputProps, ref: Ref<HTMLInputElement>) => {
|
||||
const [isToggled, setIsToggled] = useState(false)
|
||||
|
||||
const rightSideDecorations = props.right ? [...props.right] : []
|
||||
|
||||
return (
|
||||
<DecoratedInput
|
||||
{...props}
|
||||
ref={ref}
|
||||
type={isToggled ? 'text' : 'password'}
|
||||
right={[...rightSideDecorations, <Toggle isToggled={isToggled} setIsToggled={setIsToggled} />]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default DecoratedPasswordInput
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChangeEventHandler, Ref, forwardRef, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
type: 'text' | 'email' | 'password'
|
||||
label: string
|
||||
value: string
|
||||
onChange: ChangeEventHandler<HTMLInputElement>
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
inputClassName?: string
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
const FloatingLabelInput = forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
disabled,
|
||||
value,
|
||||
isInvalid,
|
||||
onChange,
|
||||
className = '',
|
||||
labelClassName = '',
|
||||
inputClassName = '',
|
||||
}: Props,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
const BASE_CLASSNAME = 'relative bg-default'
|
||||
|
||||
const LABEL_CLASSNAME = `hidden absolute ${!focused ? 'color-neutral' : 'color-info'} ${
|
||||
focused || value ? 'flex top-0 left-2 pt-1.5 px-1' : ''
|
||||
} ${isInvalid ? 'color-danger' : ''} ${labelClassName}`
|
||||
|
||||
const INPUT_CLASSNAME = `w-full h-full ${
|
||||
focused || value ? 'pt-6 pb-2' : 'py-2.5'
|
||||
} px-3 text-input border-1 border-solid border-main rounded placeholder-medium text-input focus:ring-info ${
|
||||
isInvalid ? 'border-danger placeholder-dark-red' : ''
|
||||
} ${inputClassName}`
|
||||
|
||||
const handleFocus = () => setFocused(true)
|
||||
|
||||
const handleBlur = () => setFocused(false)
|
||||
|
||||
return (
|
||||
<div className={`${BASE_CLASSNAME} ${className}`}>
|
||||
<label htmlFor={id} className={LABEL_CLASSNAME}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={INPUT_CLASSNAME}
|
||||
placeholder={!focused ? label : ''}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default FloatingLabelInput
|
||||
16
packages/web/src/javascripts/Components/Input/Input.tsx
Normal file
16
packages/web/src/javascripts/Components/Input/Input.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Input: FunctionComponent<Props> = ({ className = '', disabled = false, text }) => {
|
||||
const base = 'rounded py-1.5 px-3 text-input my-1 h-8 bg-contrast'
|
||||
const stateClasses = disabled ? 'no-border' : 'border-solid border-1 border-main'
|
||||
const classes = `${base} ${stateClasses} ${className}`
|
||||
return <input type="text" className={classes} disabled={disabled} value={text} />
|
||||
}
|
||||
|
||||
export default Input
|
||||
67
packages/web/src/javascripts/Components/Menu/Menu.tsx
Normal file
67
packages/web/src/javascripts/Components/Menu/Menu.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
FunctionComponent,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { KeyboardKey } from '@/Services/IOService'
|
||||
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
||||
|
||||
type MenuProps = {
|
||||
className?: string
|
||||
style?: CSSProperties | undefined
|
||||
a11yLabel: string
|
||||
children: ReactNode
|
||||
closeMenu?: () => void
|
||||
isOpen: boolean
|
||||
initialFocus?: number
|
||||
}
|
||||
|
||||
const Menu: FunctionComponent<MenuProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
a11yLabel,
|
||||
closeMenu,
|
||||
isOpen,
|
||||
initialFocus,
|
||||
}: MenuProps) => {
|
||||
const menuElementRef = useRef<HTMLMenuElement>(null)
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
|
||||
(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
closeMenu?.()
|
||||
return
|
||||
}
|
||||
},
|
||||
[closeMenu],
|
||||
)
|
||||
|
||||
useListKeyboardNavigation(menuElementRef, initialFocus)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
menuElementRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<menu
|
||||
className={`m-0 pl-0 list-style-none focus:shadow-none ${className}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={menuElementRef}
|
||||
style={style}
|
||||
aria-label={a11yLabel}
|
||||
>
|
||||
{children}
|
||||
</menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default Menu
|
||||
77
packages/web/src/javascripts/Components/Menu/MenuItem.tsx
Normal file
77
packages/web/src/javascripts/Components/Menu/MenuItem.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { forwardRef, MouseEventHandler, ReactNode, Ref } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { SwitchProps } from '@/Components/Switch/SwitchProps'
|
||||
import { IconType } from '@standardnotes/snjs'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { MenuItemType } from './MenuItemType'
|
||||
|
||||
type MenuItemProps = {
|
||||
type: MenuItemType
|
||||
children: ReactNode
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||
onChange?: SwitchProps['onChange']
|
||||
onBlur?: (event: { relatedTarget: EventTarget | null }) => void
|
||||
className?: string
|
||||
checked?: boolean
|
||||
icon?: IconType
|
||||
iconClassName?: string
|
||||
tabIndex?: number
|
||||
}
|
||||
|
||||
const MenuItem = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
onClick,
|
||||
onChange,
|
||||
onBlur,
|
||||
className = '',
|
||||
type,
|
||||
checked,
|
||||
icon,
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
}: MenuItemProps,
|
||||
ref: Ref<HTMLButtonElement>,
|
||||
) => {
|
||||
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
|
||||
<li className="list-style-none" role="none">
|
||||
<button
|
||||
ref={ref}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop focus:shadow-none justify-between"
|
||||
onClick={() => {
|
||||
onChange(!checked)
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
<span className="flex flex-grow items-center">{children}</span>
|
||||
<Switch className="px-0" checked={checked} />
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
<li className="list-style-none" role="none">
|
||||
<button
|
||||
ref={ref}
|
||||
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
|
||||
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
|
||||
onClick={onClick}
|
||||
onBlur={onBlur}
|
||||
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}
|
||||
>
|
||||
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
|
||||
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
|
||||
<div className={`pseudo-radio-btn ${checked ? 'pseudo-radio-btn--checked' : ''} flex-shrink-0`}></div>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MenuItem
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
|
||||
const MenuItemSeparator: FunctionComponent = () => (
|
||||
<li className="list-style-none" role="none">
|
||||
<div role="separator" className="h-1px my-2 bg-border" />
|
||||
</li>
|
||||
)
|
||||
|
||||
export default MenuItemSeparator
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MenuItemType {
|
||||
IconButton,
|
||||
RadioButton,
|
||||
SwitchButton,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IlNotesIcon } from '@standardnotes/icons'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import Button from '../Button/Button'
|
||||
import { useCallback } from 'react'
|
||||
import FileOptionsPanel from '../FileContextMenu/FileOptionsPanel'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { NotesController } from '@/Controllers/NotesController'
|
||||
import AttachedFilesButton from '../AttachedFilesPopover/AttachedFilesButton'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
featuresController: FeaturesController
|
||||
filePreviewModalController: FilePreviewModalController
|
||||
filesController: FilesController
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
selectionController: SelectedItemsController
|
||||
}
|
||||
|
||||
const MultipleSelectedFiles = ({
|
||||
application,
|
||||
filesController,
|
||||
featuresController,
|
||||
filePreviewModalController,
|
||||
navigationController,
|
||||
notesController,
|
||||
selectionController,
|
||||
}: Props) => {
|
||||
const count = selectionController.selectedFilesCount
|
||||
|
||||
const cancelMultipleSelection = useCallback(() => {
|
||||
selectionController.cancelMultipleSelection()
|
||||
}, [selectionController])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected files</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<AttachedFilesButton
|
||||
application={application}
|
||||
featuresController={featuresController}
|
||||
filePreviewModalController={filePreviewModalController}
|
||||
filesController={filesController}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
selectionController={selectionController}
|
||||
/>
|
||||
</div>
|
||||
<FileOptionsPanel filesController={filesController} selectionController={selectionController} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<IlNotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected files</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected files.</p>
|
||||
<Button className="mt-2.5" onClick={cancelMultipleSelection}>
|
||||
Cancel multiple selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(MultipleSelectedFiles)
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IlNotesIcon } from '@standardnotes/icons'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
|
||||
import Button from '../Button/Button'
|
||||
import { useCallback } from 'react'
|
||||
import AttachedFilesButton from '../AttachedFilesPopover/AttachedFilesButton'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||
import { NotesController } from '@/Controllers/NotesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
import { NoteTagsController } from '@/Controllers/NoteTagsController'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
featuresController: FeaturesController
|
||||
filePreviewModalController: FilePreviewModalController
|
||||
filesController: FilesController
|
||||
navigationController: NavigationController
|
||||
notesController: NotesController
|
||||
noteTagsController: NoteTagsController
|
||||
selectionController: SelectedItemsController
|
||||
}
|
||||
|
||||
const MultipleSelectedNotes = ({
|
||||
application,
|
||||
featuresController,
|
||||
filePreviewModalController,
|
||||
filesController,
|
||||
navigationController,
|
||||
notesController,
|
||||
noteTagsController,
|
||||
selectionController,
|
||||
}: Props) => {
|
||||
const count = notesController.selectedNotesCount
|
||||
|
||||
const cancelMultipleSelection = useCallback(() => {
|
||||
selectionController.cancelMultipleSelection()
|
||||
}, [selectionController])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="sk-h1 font-bold m-0">{count} selected notes</h1>
|
||||
<div className="flex">
|
||||
<div className="mr-3">
|
||||
<AttachedFilesButton
|
||||
application={application}
|
||||
featuresController={featuresController}
|
||||
filePreviewModalController={filePreviewModalController}
|
||||
filesController={filesController}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
selectionController={selectionController}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-3">
|
||||
<PinNoteButton notesController={notesController} />
|
||||
</div>
|
||||
<NotesOptionsPanel
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
notesController={notesController}
|
||||
noteTagsController={noteTagsController}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<IlNotesIcon className="block" />
|
||||
<h2 className="text-lg m-0 text-center mt-4">{count} selected notes</h2>
|
||||
<p className="text-sm mt-2 text-center max-w-60">Actions will be performed on all selected notes.</p>
|
||||
<Button className="mt-2.5" onClick={cancelMultipleSelection}>
|
||||
Cancel multiple selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(MultipleSelectedNotes)
|
||||
@@ -0,0 +1,83 @@
|
||||
import SmartViewsSection from '@/Components/Tags/SmartViewsSection'
|
||||
import TagsSection from '@/Components/Tags/TagsSection'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PANEL_NAME_NAVIGATION } from '@/Constants/Constants'
|
||||
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import PanelResizer, { PanelSide, ResizeFinishCallback, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
const Navigation: FunctionComponent<Props> = ({ application }) => {
|
||||
const viewControllerManager = useMemo(() => application.getViewControllerManager(), [application])
|
||||
const [ref, setRef] = useState<HTMLDivElement | null>()
|
||||
const [panelWidth, setPanelWidth] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addEventObserver(async () => {
|
||||
const width = application.getPreference(PrefKey.TagsPanelWidth)
|
||||
if (width) {
|
||||
setPanelWidth(width)
|
||||
}
|
||||
}, ApplicationEvent.PreferencesChanged)
|
||||
|
||||
return () => {
|
||||
removeObserver()
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
|
||||
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
|
||||
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
|
||||
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||
application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, isCollapsed)
|
||||
},
|
||||
[application, viewControllerManager],
|
||||
)
|
||||
|
||||
const panelWidthEventCallback = useCallback(() => {
|
||||
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
|
||||
}, [viewControllerManager])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="navigation"
|
||||
className="sn-component section app-column app-column-first"
|
||||
data-aria-label="Navigation"
|
||||
ref={setRef}
|
||||
>
|
||||
<div id="navigation-content" className="content">
|
||||
<div className="section-title-bar">
|
||||
<div className="section-title-bar-header">
|
||||
<div className="sk-h3 title">
|
||||
<span className="sk-bold">Views</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrollable">
|
||||
<SmartViewsSection viewControllerManager={viewControllerManager} />
|
||||
<TagsSection viewControllerManager={viewControllerManager} />
|
||||
</div>
|
||||
</div>
|
||||
{ref && (
|
||||
<PanelResizer
|
||||
collapsable={true}
|
||||
defaultWidth={150}
|
||||
panel={ref}
|
||||
hoverable={true}
|
||||
side={PanelSide.Right}
|
||||
type={PanelResizeType.WidthOnly}
|
||||
resizeFinishCallback={panelResizeFinishCallback}
|
||||
widthEventCallback={panelWidthEventCallback}
|
||||
width={panelWidth}
|
||||
left={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Navigation)
|
||||
@@ -0,0 +1,49 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { ViewControllerManager } from '@/Services/ViewControllerManager'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { MouseEventHandler, useCallback } from 'react'
|
||||
|
||||
type Props = { viewControllerManager: ViewControllerManager }
|
||||
|
||||
const NoAccountWarning = observer(({ viewControllerManager }: Props) => {
|
||||
const showAccountMenu: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
event.stopPropagation()
|
||||
viewControllerManager.accountMenuController.setShow(true)
|
||||
},
|
||||
[viewControllerManager],
|
||||
)
|
||||
|
||||
const hideWarning = useCallback(() => {
|
||||
viewControllerManager.noAccountWarningController.hide()
|
||||
}, [viewControllerManager])
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-4 rounded-md shadow-sm grid grid-template-cols-1fr">
|
||||
<h1 className="sk-h3 m-0 font-semibold">Data not backed up</h1>
|
||||
<p className="m-0 mt-1 col-start-1 col-end-3">Sign in or register to back up your notes.</p>
|
||||
<button className="sn-button small info mt-3 col-start-1 col-end-3 justify-self-start" onClick={showAccountMenu}>
|
||||
Open Account menu
|
||||
</button>
|
||||
<button
|
||||
onClick={hideWarning}
|
||||
title="Ignore warning"
|
||||
aria-label="Ignore warning"
|
||||
style={{ height: '20px' }}
|
||||
className="border-0 m-0 p-0 bg-transparent cursor-pointer rounded-md col-start-2 row-start-1 color-neutral hover:color-info"
|
||||
>
|
||||
<Icon type="close" className="block" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
NoAccountWarning.displayName = 'NoAccountWarning'
|
||||
|
||||
const NoAccountWarningWrapper = ({ viewControllerManager }: Props) => {
|
||||
const canShow = viewControllerManager.noAccountWarningController.show
|
||||
|
||||
return canShow ? <NoAccountWarning viewControllerManager={viewControllerManager} /> : null
|
||||
}
|
||||
|
||||
export default observer(NoAccountWarningWrapper)
|
||||
@@ -0,0 +1,130 @@
|
||||
import { FileItem, NoteViewController } from '@standardnotes/snjs'
|
||||
import { PureComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
|
||||
import NoteView from '@/Components/NoteView/NoteView'
|
||||
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import FileView from '@/Components/FileView/FileView'
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean
|
||||
showMultipleSelectedFiles: boolean
|
||||
controllers: NoteViewController[]
|
||||
selectedFile: FileItem | undefined
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
class NoteGroupView extends PureComponent<Props, State> {
|
||||
private removeChangeObserver!: () => void
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props, props.application)
|
||||
this.state = {
|
||||
showMultipleSelectedNotes: false,
|
||||
showMultipleSelectedFiles: false,
|
||||
controllers: [],
|
||||
selectedFile: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
|
||||
const controllerGroup = this.application.noteControllerGroup
|
||||
this.removeChangeObserver = this.application.noteControllerGroup.addActiveControllerChangeObserver(() => {
|
||||
const controllers = controllerGroup.noteControllers
|
||||
this.setState({
|
||||
controllers: controllers,
|
||||
})
|
||||
})
|
||||
|
||||
this.autorun(() => {
|
||||
if (!this.viewControllerManager) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.viewControllerManager && this.viewControllerManager.notesController) {
|
||||
this.setState({
|
||||
showMultipleSelectedNotes: this.viewControllerManager.notesController.selectedNotesCount > 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.viewControllerManager.selectionController) {
|
||||
this.setState({
|
||||
showMultipleSelectedFiles: this.viewControllerManager.selectionController.selectedFilesCount > 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.autorun(() => {
|
||||
if (this.viewControllerManager && this.viewControllerManager.selectionController) {
|
||||
this.setState({
|
||||
selectedFile: this.viewControllerManager.selectionController.selectedFiles[0],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override deinit() {
|
||||
this.removeChangeObserver?.()
|
||||
;(this.removeChangeObserver as unknown) = undefined
|
||||
|
||||
super.deinit()
|
||||
}
|
||||
|
||||
override render() {
|
||||
const shouldNotShowMultipleSelectedItems =
|
||||
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
|
||||
|
||||
return (
|
||||
<div id={ElementIds.EditorColumn} className="h-full app-column app-column-third">
|
||||
{this.state.showMultipleSelectedNotes && (
|
||||
<MultipleSelectedNotes
|
||||
application={this.application}
|
||||
filesController={this.viewControllerManager.filesController}
|
||||
selectionController={this.viewControllerManager.selectionController}
|
||||
featuresController={this.viewControllerManager.featuresController}
|
||||
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
|
||||
navigationController={this.viewControllerManager.navigationController}
|
||||
notesController={this.viewControllerManager.notesController}
|
||||
noteTagsController={this.viewControllerManager.noteTagsController}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.showMultipleSelectedFiles && (
|
||||
<MultipleSelectedFiles
|
||||
application={this.application}
|
||||
filesController={this.viewControllerManager.filesController}
|
||||
selectionController={this.viewControllerManager.selectionController}
|
||||
featuresController={this.viewControllerManager.featuresController}
|
||||
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
|
||||
navigationController={this.viewControllerManager.navigationController}
|
||||
notesController={this.viewControllerManager.notesController}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldNotShowMultipleSelectedItems && this.state.controllers.length > 0 && (
|
||||
<>
|
||||
{this.state.controllers.map((controller) => {
|
||||
return <NoteView key={controller.note.uuid} application={this.application} controller={controller} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldNotShowMultipleSelectedItems && this.state.controllers.length < 1 && this.state.selectedFile && (
|
||||
<FileView
|
||||
application={this.application}
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
file={this.state.selectedFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteGroupView
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user