Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: immediately insert file node and show progress inside super note when uploading a new file #2876

Merged
merged 6 commits into from
May 6, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
file upload progress in super embed
amanharwara committed May 5, 2024
commit 43494a2a50f52bcfdcd1910b7162f02586cd1ca0
1 change: 1 addition & 0 deletions packages/files/src/Domain/Service/FilesClientInterface.ts
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ export interface FilesClientInterface {
finishUpload(
operation: EncryptAndUploadFileOperation,
fileMetadata: FileMetadata,
uuid: string,
): Promise<FileItem | ClientDisplayableError>

downloadFile(
27 changes: 20 additions & 7 deletions packages/services/src/Domain/Files/FileService.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,12 @@ import {
isEncryptedPayload,
VaultListingInterface,
SharedVaultListingInterface,
DecryptedPayload,
FillItemContent,
PayloadVaultOverrides,
PayloadTimestampDefaults,
CreateItemFromPayload,
DecryptedItemInterface,
} from '@standardnotes/models'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { LoggerInterface, spaceSeparatedStrings, UuidGenerator } from '@standardnotes/utils'
@@ -246,6 +252,7 @@ export class FileService extends AbstractService implements FilesClientInterface
public async finishUpload(
operation: EncryptAndUploadFileOperation,
fileMetadata: FileMetadata,
uuid: string,
): Promise<FileItem | ClientDisplayableError> {
const uploadSessionClosed = await this.api.closeUploadSession(
operation.getValetToken(),
@@ -268,16 +275,22 @@ export class FileService extends AbstractService implements FilesClientInterface
remoteIdentifier: result.remoteIdentifier,
}

const file = await this.mutator.createItem<FileItem>(
ContentType.TYPES.File,
FillItemContentSpecialized(fileContent),
true,
operation.vault,
)
const filePayload = new DecryptedPayload<FileContent>({
uuid,
content_type: ContentType.TYPES.File,
content: FillItemContent<FileContent>(FillItemContentSpecialized(fileContent)),
dirty: true,
...PayloadVaultOverrides(operation.vault),
...PayloadTimestampDefaults(),
})

const fileItem = CreateItemFromPayload(filePayload) as DecryptedItemInterface<FileContent>

const insertedItem = await this.mutator.insertItem<FileItem>(fileItem)

await this.sync.sync()

return file
return insertedItem
}

private async decryptCachedEntry(file: FileItem, entry: EncryptedBytes): Promise<DecryptedBytes> {
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createCommand, LexicalCommand } from 'lexical'

export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand('INSERT_FILE_COMMAND')
export const UPLOAD_AND_INSERT_FILE_COMMAND: LexicalCommand<File> = createCommand('UPLOAD_AND_INSERT_FILE_COMMAND')
export const INSERT_BUBBLE_COMMAND: LexicalCommand<string> = createCommand('INSERT_BUBBLE_COMMAND')
export const INSERT_DATETIME_COMMAND: LexicalCommand<'date' | 'time' | 'datetime'> =
createCommand('INSERT_DATETIME_COMMAND')
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INSERT_FILE_COMMAND } from '../Commands'
import { INSERT_FILE_COMMAND, UPLOAD_AND_INSERT_FILE_COMMAND } from '../Commands'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'

import { useEffect, useState } from 'react'
@@ -19,45 +19,24 @@ import { FilesControllerEvent } from '@/Controllers/FilesController'
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
import { useApplication } from '@/Components/ApplicationProvider'
import { SNNote } from '@standardnotes/snjs'
import Spinner from '../../../Spinner/Spinner'
import Modal from '../../Lexical/UI/Modal'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../../Utils'

export const OPEN_FILE_UPLOAD_MODAL_COMMAND = createCommand('OPEN_FILE_UPLOAD_MODAL_COMMAND')

function UploadFileDialog({ currentNote, onClose }: { currentNote: SNNote; onClose: () => void }) {
const application = useApplication()
function UploadFileDialog({ onClose }: { onClose: () => void }) {
const [editor] = useLexicalComposerContext()
const filesController = useFilesController()
const linkingController = useLinkingController()

const [file, setFile] = useState<File>()
const [isUploadingFile, setIsUploadingFile] = useState(false)

const onClick = () => {
if (!file) {
return
}

setIsUploadingFile(true)
filesController
.uploadNewFile(file)
.then((uploadedFile) => {
if (!uploadedFile) {
return
}
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
})
.catch(console.error)
.finally(() => {
setIsUploadingFile(false)
onClose()
})
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, file)
onClose()
}

return (
@@ -72,13 +51,9 @@ function UploadFileDialog({ currentNote, onClose }: { currentNote: SNNote; onClo
}}
/>
<div className="mt-1.5 flex justify-end">
{isUploadingFile ? (
<Spinner className="h-4 w-4" />
) : (
<Button onClick={onClick} disabled={!file} small={isMobileScreen()}>
Upload
</Button>
)}
<Button onClick={onClick} disabled={!file} small={isMobileScreen()}>
Upload
</Button>
</div>
</>
)
@@ -99,17 +74,21 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS

const uploadFilesList = (files: FileList) => {
Array.from(files).forEach(async (file) => {
try {
const uploadedFile = await filesController.uploadNewFile(file)
if (uploadedFile) {
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
}
} catch (error) {
console.error(error)
editor.dispatchCommand(UPLOAD_AND_INSERT_FILE_COMMAND, file)
})
}

const insertFileNode = (uuid: string, onInsert?: (node: FileNode) => void) => {
editor.update(() => {
const fileNode = $createFileNode(uuid)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
const newLineNode = $createParagraphNode()
fileNode.getParentOrThrow().insertAfter(newLineNode)
if (onInsert) {
onInsert(fileNode)
}
})
}
@@ -118,14 +97,34 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
editor.registerCommand<string>(
INSERT_FILE_COMMAND,
(payload) => {
const fileNode = $createFileNode(payload)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
const newLineNode = $createParagraphNode()
fileNode.getParentOrThrow().insertAfter(newLineNode)

insertFileNode(payload)
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
UPLOAD_AND_INSERT_FILE_COMMAND,
(file) => {
const note = currentNote
let fileNode: FileNode | undefined
filesController
.uploadNewFile(file, {
showToast: false,
onUploadStart(fileUuid) {
insertFileNode(fileUuid, (node) => (fileNode = node))
},
})
.then((uploadedFile) => {
if (uploadedFile) {
void linkingController.linkItems(note, uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = note.protected
})
} else {
fileNode?.remove()
}
})
.catch(console.error)
return true
},
COMMAND_PRIORITY_EDITOR,
@@ -159,17 +158,20 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
if (!parent) {
return
}
// if ($isRootOrShadowRoot(parent)) {
// return
// }
if (parent.getChildrenSize() === 1) {
parent.insertBefore(node)
parent.remove()
}
}),
)
}, [application, currentNote.protected, editor, filesController, linkingController])
}, [application, currentNote, editor, filesController, linkingController])

useEffect(() => {
const disposer = filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadedToNote) {
if (event === FilesControllerEvent.FileUploadedToNote && data[FilesControllerEvent.FileUploadedToNote]) {
const fileUuid = data[FilesControllerEvent.FileUploadedToNote].uuid
editor.dispatchCommand(INSERT_FILE_COMMAND, fileUuid)
}
@@ -181,7 +183,7 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
if (showFileUploadModal) {
return (
<Modal onClose={() => setShowFileUploadModal(false)} title="Upload File">
<UploadFileDialog currentNote={currentNote} onClose={() => setShowFileUploadModal(false)} />
<UploadFileDialog onClose={() => setShowFileUploadModal(false)} />
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -6,6 +6,9 @@ import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileItem } from '@standardnotes/snjs'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { observer } from 'mobx-react-lite'
import Spinner from '@/Components/Spinner/Spinner'
import { FilesControllerEvent } from '@/Controllers/FilesController'

export type FileComponentProps = Readonly<{
className: Readonly<{
@@ -19,10 +22,11 @@ export type FileComponentProps = Readonly<{
setZoomLevel: (zoomLevel: number) => void
}>

export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
const [file, setFile] = useState(() => application.items.findItem<FileItem>(fileUuid))
const uploadProgress = application.filesController.uploadProgressMap.get(fileUuid)

const [canLoad, setCanLoad] = useState(false)

@@ -90,6 +94,41 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel,
)
}, [editor, isSelected, nodeKey, setSelected])

useEffect(() => {
return application.filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadFinished && data[FilesControllerEvent.FileUploadFinished]) {
const { uploadedFile } = data[FilesControllerEvent.FileUploadFinished]
if (uploadedFile.uuid === fileUuid) {
setFile(uploadedFile)
}
}
})
}, [application.filesController, fileUuid])

if (uploadProgress && (uploadProgress.progress < 100 || !file)) {
const progress = uploadProgress.progress
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center" ref={blockWrapperRef}>
<div className="flex items-center gap-2">
<Spinner className="h-4 w-4" />
Uploading file "{uploadProgress.file.name}"... ({progress}%)
</div>
<div className="w-full max-w-[50%] overflow-hidden rounded bg-contrast">
<div
className="h-2 rounded rounded-tl-none bg-info transition-[width] duration-100"
role="progressbar"
style={{
width: `${progress}%`,
}}
aria-valuenow={progress}
/>
</div>
</div>
</BlockWithAlignableContents>
)
}

if (!file) {
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
@@ -114,3 +153,5 @@ export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel,
</BlockWithAlignableContents>
)
}

export default observer(FileComponent)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { $createFileNode, convertToFileElement } from './FileUtils'
import { FileComponent } from './FileComponent'
import FileComponent from './FileComponent'
import { SerializedFileNode } from './SerializedFileNode'
import { ItemNodeInterface } from '../../ItemNodeInterface'

53 changes: 49 additions & 4 deletions packages/web/src/javascripts/Controllers/FilesController.ts
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@ import {
ProtectionsClientInterface,
SNNote,
SyncServiceInterface,
UuidGenerator,
VaultServiceInterface,
} from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType, updateToast } from '@standardnotes/toast'
@@ -53,13 +54,17 @@ const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionT
type FileContextMenuLocation = { x: number; y: number }

export type FilesControllerEventData = {
[FilesControllerEvent.FileUploadedToNote]: {
[FilesControllerEvent.FileUploadedToNote]?: {
uuid: string
}
[FilesControllerEvent.FileUploadFinished]?: {
uploadedFile: FileItem
}
}

export enum FilesControllerEvent {
FileUploadedToNote,
FileUploadedToNote = 'FileUploadedToNote',
FileUploadFinished = 'FileUploadFinished',
}

export class FilesController extends AbstractViewController<FilesControllerEvent, FilesControllerEventData> {
@@ -73,6 +78,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
reader = this.shouldUseStreamingAPI ? StreamingFileReader : ClassicFileReader
maxFileSize = this.reader.maximumFileSize()

uploadProgressMap: Map<
string,
{
file: File
progress: number
}
> = new Map()

override deinit(): void {
super.deinit()
;(this.notesController as unknown) = undefined
@@ -111,6 +124,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
setShowFileContextMenu: action,
setShowProtectedOverlay: action,
setFileContextMenuLocation: action,

uploadProgressMap: observable,
})

this.disposers.push(
@@ -453,9 +468,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
options: {
showToast?: boolean
note?: SNNote
onUploadStart?: (fileUuid: string) => void
onUploadFinish?: () => void
} = {},
): Promise<FileItem | undefined> {
const { showToast = true, note } = options
const { showToast = true, note, onUploadStart, onUploadFinish } = options

let toastId: string | undefined
let canShowProgressNotification = false
@@ -482,6 +499,17 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
return
}

const uuid = UuidGenerator.GenerateUuid()

this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: 0,
})

if (onUploadStart) {
onUploadStart(uuid)
}

const vaultForNote = note ? this.vaults.getItemVault(note) : undefined

const operation = await this.files.beginNewFileUpload(
@@ -499,6 +527,11 @@ export class FilesController extends AbstractViewController<FilesControllerEvent

const initialProgress = operation.getProgress().percentComplete

this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: initialProgress,
})

if (showToast) {
if (this.mobileDevice && canShowProgressNotification) {
toastId = await this.mobileDevice.displayNotification({
@@ -521,6 +554,10 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
await this.files.pushBytesForUpload(operation, data, index, isLast)

const percentComplete = Math.round(operation.getProgress().percentComplete)
this.uploadProgressMap.set(uuid, {
file: fileToUpload,
progress: percentComplete,
})
if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
await this.mobileDevice.displayNotification({
@@ -547,7 +584,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
fileResult.mimeType = await this.archiveService.getMimeType(ext)
}

const uploadedFile = await this.files.finishUpload(operation, fileResult)
const uploadedFile = await this.files.finishUpload(operation, fileResult, uuid)

if (uploadedFile instanceof ClientDisplayableError) {
addToast({
@@ -557,6 +594,14 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
throw new Error(uploadedFile.text)
}

if (onUploadFinish) {
onUploadFinish()
}

this.notifyEvent(FilesControllerEvent.FileUploadFinished, {
[FilesControllerEvent.FileUploadFinished]: { uploadedFile },
})

if (toastId) {
if (this.mobileDevice && canShowProgressNotification) {
this.mobileDevice.cancelNotification(toastId).catch(console.error)