fix: export/sharing notes on mobile webview (#1644)
This commit is contained in:
@@ -20,7 +20,7 @@ const createApplication = (
|
||||
device: WebOrDesktopDevice,
|
||||
webSocketUrl: string,
|
||||
) => {
|
||||
const platform = getPlatform()
|
||||
const platform = getPlatform(device)
|
||||
|
||||
const application = new WebApplication(
|
||||
deviceInterface,
|
||||
|
||||
@@ -86,6 +86,7 @@ export const ICONS = {
|
||||
security: icons.SecurityIcon,
|
||||
server: icons.ServerIcon,
|
||||
settings: icons.SettingsIcon,
|
||||
share: icons.ShareIcon,
|
||||
signIn: icons.SignInIcon,
|
||||
signOut: icons.SignOutIcon,
|
||||
spreadsheets: icons.SpreadsheetsIcon,
|
||||
|
||||
@@ -41,7 +41,7 @@ const NotesContextMenu = ({
|
||||
side="right"
|
||||
togglePopover={closeMenu}
|
||||
>
|
||||
<div ref={contextMenuRef}>
|
||||
<div className="select-none" ref={contextMenuRef}>
|
||||
<NotesOptions
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Icon from '@/Components/Icon/Icon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
|
||||
import { SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
|
||||
import { Platform, SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
|
||||
import { KeyboardModifier } from '@standardnotes/ui-services'
|
||||
import ChangeEditorOption from './ChangeEditorOption'
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
||||
@@ -15,6 +15,9 @@ import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||
import { shareSelectedItems } from '@/NativeMobileWeb/ShareSelectedItems'
|
||||
import { downloadSelectedItemsOnAndroid } from '@/NativeMobileWeb/DownloadSelectedItemsOnAndroid'
|
||||
|
||||
type DeletePermanentlyButtonProps = {
|
||||
onClick: () => void
|
||||
@@ -95,7 +98,7 @@ const NoteAttributes: FunctionComponent<{
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
|
||||
return (
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-neutral">
|
||||
<div className="select-text px-3 py-1.5 text-xs font-medium text-neutral">
|
||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||
<>
|
||||
<div className="mb-1">
|
||||
@@ -220,18 +223,11 @@ const NotesOptions = ({
|
||||
}
|
||||
}, [application])
|
||||
|
||||
const getNoteFileName = useCallback(
|
||||
(note: SNNote): string => {
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
return `${note.title}.${format}`
|
||||
},
|
||||
[application.componentManager],
|
||||
)
|
||||
|
||||
const downloadSelectedItems = useCallback(async () => {
|
||||
if (notes.length === 1) {
|
||||
application.getArchiveService().downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]))
|
||||
const note = notes[0]
|
||||
const blob = getNoteBlob(application, note)
|
||||
application.getArchiveService().downloadData(blob, getNoteFileName(application, note))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,8 +239,8 @@ const NotesOptions = ({
|
||||
await application.getArchiveService().downloadDataAsZip(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
name: getNoteFileName(note),
|
||||
content: new Blob([note.text]),
|
||||
name: getNoteFileName(application, note),
|
||||
content: getNoteBlob(application, note),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -254,7 +250,7 @@ const NotesOptions = ({
|
||||
message: `Exported ${notes.length} notes`,
|
||||
})
|
||||
}
|
||||
}, [application, getNoteFileName, notes])
|
||||
}, [application, notes])
|
||||
|
||||
const closeMenuAndToggleNotesList = useCallback(() => {
|
||||
toggleAppPane(AppPaneId.Items)
|
||||
@@ -358,11 +354,22 @@ const NotesOptions = ({
|
||||
)}
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={downloadSelectedItems}
|
||||
onClick={() => {
|
||||
application.isNativeMobileWeb() ? shareSelectedItems(application, notes) : downloadSelectedItems()
|
||||
}}
|
||||
>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
|
||||
{application.platform === Platform.Android ? 'Share' : 'Export'}
|
||||
</button>
|
||||
{application.platform === Platform.Android && (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={() => downloadSelectedItemsOnAndroid(application, notes)}
|
||||
>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={duplicateSelectedItems}
|
||||
|
||||
@@ -48,7 +48,7 @@ const NotesOptionsPanel = ({
|
||||
>
|
||||
<Icon type="more" />
|
||||
</button>
|
||||
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="py-2">
|
||||
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="select-none py-2">
|
||||
<NotesOptions
|
||||
application={application}
|
||||
navigationController={navigationController}
|
||||
|
||||
@@ -121,7 +121,7 @@ export const StringUtils = {
|
||||
if (!application.hasAccount()) {
|
||||
return null
|
||||
}
|
||||
const platform = getPlatform()
|
||||
const platform = getPlatform(application.deviceInterface)
|
||||
const keychainName =
|
||||
platform === Platform.WindowsDesktop
|
||||
? 'credential manager'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { getBase64FromBlob } from '@/Utils'
|
||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||
import { parseFileName } from '@standardnotes/filepicker'
|
||||
import { Platform, SNNote } from '@standardnotes/snjs'
|
||||
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
||||
import { sanitizeFileName } from '@standardnotes/ui-services'
|
||||
|
||||
export const downloadSelectedItemsOnAndroid = async (application: WebApplication, notes: SNNote[]) => {
|
||||
if (!application.isNativeMobileWeb() || application.platform !== Platform.Android) {
|
||||
throw new Error('Function being used on non-android platform')
|
||||
}
|
||||
if (notes.length === 1) {
|
||||
const note = notes[0]
|
||||
const blob = getNoteBlob(application, note)
|
||||
const base64 = await getBase64FromBlob(blob)
|
||||
const { name, ext } = parseFileName(getNoteFileName(application, note))
|
||||
const filename = `${sanitizeFileName(name)}.${ext}`
|
||||
const loadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Exporting ${filename}..`,
|
||||
})
|
||||
const downloaded = await application.mobileDevice.downloadBase64AsFile(base64, filename)
|
||||
if (downloaded) {
|
||||
dismissToast(loadingToastId)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${filename}`,
|
||||
})
|
||||
} else {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: `Could not export ${filename}`,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (notes.length > 1) {
|
||||
const zippedDataBlob = await application.getArchiveService().zipData(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
name: getNoteFileName(application, note),
|
||||
content: getNoteBlob(application, note),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob)
|
||||
const filename = `Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip`
|
||||
const loadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Exporting ${filename}..`,
|
||||
})
|
||||
const downloaded = await application.mobileDevice.downloadBase64AsFile(zippedDataAsBase64, filename)
|
||||
if (downloaded) {
|
||||
dismissToast(loadingToastId)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${filename}`,
|
||||
})
|
||||
} else {
|
||||
addToast({
|
||||
type: ToastType.Error,
|
||||
message: `Could not export ${filename}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { getBase64FromBlob } from '@/Utils'
|
||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||
import { parseFileName } from '@standardnotes/filepicker'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { sanitizeFileName } from '@standardnotes/ui-services'
|
||||
|
||||
export const shareSelectedItems = async (application: WebApplication, notes: SNNote[]) => {
|
||||
if (!application.isNativeMobileWeb()) {
|
||||
throw new Error('Share function being used outside mobile webview')
|
||||
}
|
||||
if (notes.length === 1) {
|
||||
const note = notes[0]
|
||||
const blob = getNoteBlob(application, note)
|
||||
const base64 = await getBase64FromBlob(blob)
|
||||
const { name, ext } = parseFileName(getNoteFileName(application, note))
|
||||
const filename = `${sanitizeFileName(name)}.${ext}`
|
||||
application.mobileDevice.shareBase64AsFile(base64, filename)
|
||||
return
|
||||
}
|
||||
if (notes.length > 1) {
|
||||
const zippedDataBlob = await application.getArchiveService().zipData(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
name: getNoteFileName(application, note),
|
||||
content: getNoteBlob(application, note),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob)
|
||||
application.mobileDevice.shareBase64AsFile(
|
||||
zippedDataAsBase64,
|
||||
`Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip`,
|
||||
)
|
||||
}
|
||||
}
|
||||
36
packages/web/src/javascripts/Utils/NoteExportUtils.ts
Normal file
36
packages/web/src/javascripts/Utils/NoteExportUtils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
|
||||
export const getNoteFormat = (application: WebApplication, note: SNNote) => {
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor?.package_info?.file_type || 'txt'
|
||||
return format
|
||||
}
|
||||
|
||||
export const getNoteFileName = (application: WebApplication, note: SNNote): string => {
|
||||
const format = getNoteFormat(application, note)
|
||||
return `${note.title}.${format}`
|
||||
}
|
||||
|
||||
export const getNoteBlob = (application: WebApplication, note: SNNote) => {
|
||||
const format = getNoteFormat(application, note)
|
||||
let type: string
|
||||
switch (format) {
|
||||
case 'html':
|
||||
type = 'text/html'
|
||||
break
|
||||
case 'json':
|
||||
type = 'application/json'
|
||||
break
|
||||
case 'md':
|
||||
type = 'text/markdown'
|
||||
break
|
||||
default:
|
||||
type = 'text/plain'
|
||||
break
|
||||
}
|
||||
const blob = new Blob([note.text], {
|
||||
type,
|
||||
})
|
||||
return blob
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform, platformFromString } from '@standardnotes/snjs'
|
||||
import { DeviceInterface, MobileDeviceInterface, Platform, platformFromString } from '@standardnotes/snjs'
|
||||
import { IsDesktopPlatform, IsWebPlatform } from '@/Constants/Version'
|
||||
import { EMAIL_REGEX } from '../Constants/Constants'
|
||||
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
||||
@@ -31,7 +31,11 @@ export function getPlatformString() {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatform(): Platform {
|
||||
export function getPlatform(device: DeviceInterface | MobileDeviceInterface): Platform {
|
||||
if ('platform' in device) {
|
||||
return device.platform
|
||||
}
|
||||
|
||||
return platformFromString(getPlatformString())
|
||||
}
|
||||
|
||||
@@ -204,3 +208,17 @@ export const disableIosTextFieldZoom = () => {
|
||||
}
|
||||
|
||||
export const isMobileScreen = () => !window.matchMedia(MediaQueryBreakpoints.md).matches
|
||||
|
||||
export const getBase64FromBlob = (blob: Blob) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result.toString())
|
||||
} else {
|
||||
reject()
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user