fix: export/sharing notes on mobile webview (#1644)
This commit is contained in:
Binary file not shown.
@@ -69,7 +69,7 @@
|
|||||||
"react-native-screens": "3.13.1",
|
"react-native-screens": "3.13.1",
|
||||||
"react-native-search-bar": "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09",
|
"react-native-search-bar": "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09",
|
||||||
"react-native-search-box": "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7",
|
"react-native-search-box": "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7",
|
||||||
"react-native-share": "^7.3.7",
|
"react-native-share": "^7.9.0",
|
||||||
"react-native-sodium-jsi": "1.2.0",
|
"react-native-sodium-jsi": "1.2.0",
|
||||||
"react-native-static-server": "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836",
|
"react-native-static-server": "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836",
|
||||||
"react-native-store-review": "^0.2.0",
|
"react-native-store-review": "^0.2.0",
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
|
|||||||
[application],
|
[application],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!application) {
|
const shouldOpenWebApp = application?.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldOpenWebApp = application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean
|
|
||||||
|
|
||||||
if (IsMobileWeb || shouldOpenWebApp) {
|
if (IsMobileWeb || shouldOpenWebApp) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,16 +7,27 @@ import {
|
|||||||
LegacyRawKeychainValue,
|
LegacyRawKeychainValue,
|
||||||
MobileDeviceInterface,
|
MobileDeviceInterface,
|
||||||
NamespacedRootKeyInKeychain,
|
NamespacedRootKeyInKeychain,
|
||||||
|
Platform as SNPlatform,
|
||||||
RawKeychainValue,
|
RawKeychainValue,
|
||||||
removeFromArray,
|
removeFromArray,
|
||||||
TransferPayload,
|
TransferPayload,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { Alert, Linking, Platform, StatusBar } from 'react-native'
|
import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native'
|
||||||
import FingerprintScanner from 'react-native-fingerprint-scanner'
|
import FingerprintScanner from 'react-native-fingerprint-scanner'
|
||||||
import FlagSecure from 'react-native-flag-secure-android'
|
import FlagSecure from 'react-native-flag-secure-android'
|
||||||
|
import {
|
||||||
|
CachesDirectoryPath,
|
||||||
|
DocumentDirectoryPath,
|
||||||
|
DownloadDirectoryPath,
|
||||||
|
exists,
|
||||||
|
unlink,
|
||||||
|
writeFile,
|
||||||
|
} from 'react-native-fs'
|
||||||
import { hide, show } from 'react-native-privacy-snapshot'
|
import { hide, show } from 'react-native-privacy-snapshot'
|
||||||
|
import Share from 'react-native-share'
|
||||||
import { AppStateObserverService } from './../AppStateObserverService'
|
import { AppStateObserverService } from './../AppStateObserverService'
|
||||||
import Keychain from './Keychain'
|
import Keychain from './Keychain'
|
||||||
|
import { SNReactNativeCrypto } from './ReactNativeCrypto'
|
||||||
import { IsMobileWeb } from './Utils'
|
import { IsMobileWeb } from './Utils'
|
||||||
|
|
||||||
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
|
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
|
||||||
@@ -64,10 +75,14 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => {
|
|||||||
|
|
||||||
export class MobileDevice implements MobileDeviceInterface {
|
export class MobileDevice implements MobileDeviceInterface {
|
||||||
environment: Environment.Mobile = Environment.Mobile
|
environment: Environment.Mobile = Environment.Mobile
|
||||||
|
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
|
||||||
private eventObservers: MobileDeviceEventHandler[] = []
|
private eventObservers: MobileDeviceEventHandler[] = []
|
||||||
public isDarkMode = false
|
public isDarkMode = false
|
||||||
|
private crypto: SNReactNativeCrypto
|
||||||
|
|
||||||
constructor(private stateObserverService?: AppStateObserverService) {}
|
constructor(private stateObserverService?: AppStateObserverService) {
|
||||||
|
this.crypto = new SNReactNativeCrypto()
|
||||||
|
}
|
||||||
|
|
||||||
deinit() {
|
deinit() {
|
||||||
this.stateObserverService?.deinit()
|
this.stateObserverService?.deinit()
|
||||||
@@ -455,4 +470,76 @@ export class MobileDevice implements MobileDeviceInterface {
|
|||||||
isDeviceDestroyed() {
|
isDeviceDestroyed() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFileAtPathIfExists(path: string) {
|
||||||
|
if (await exists(path)) {
|
||||||
|
await unlink(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareBase64AsFile(base64: string, filename: string) {
|
||||||
|
let downloadedTempFilePath: string | undefined
|
||||||
|
try {
|
||||||
|
downloadedTempFilePath = await this.downloadBase64AsFile(base64, filename, true)
|
||||||
|
if (!downloadedTempFilePath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Share.open({
|
||||||
|
url: `file://${downloadedTempFilePath}`,
|
||||||
|
failOnCancel: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.consoleLog(`${error}`)
|
||||||
|
} finally {
|
||||||
|
if (downloadedTempFilePath) {
|
||||||
|
void this.deleteFileAtPathIfExists(downloadedTempFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileDestinationPath(filename: string, saveInTempLocation: boolean): string {
|
||||||
|
let directory = DocumentDirectoryPath
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
directory = saveInTempLocation ? CachesDirectoryPath : DownloadDirectoryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${directory}/${filename}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasStoragePermissionOnAndroid(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const grantedStatus = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
|
||||||
|
if (grantedStatus === PermissionsAndroid.RESULTS.GRANTED) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadBase64AsFile(
|
||||||
|
base64: string,
|
||||||
|
filename: string,
|
||||||
|
saveInTempLocation = false,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const isGrantedStoragePermissionOnAndroid = await this.hasStoragePermissionOnAndroid()
|
||||||
|
|
||||||
|
if (!isGrantedStoragePermissionOnAndroid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = this.getFileDestinationPath(filename, saveInTempLocation)
|
||||||
|
void this.deleteFileAtPathIfExists(path)
|
||||||
|
const decodedContents = this.crypto.base64Decode(base64.replace(/data.*base64,/, ''))
|
||||||
|
await writeFile(path, decodedContents)
|
||||||
|
return path
|
||||||
|
} catch (error) {
|
||||||
|
this.consoleLog(`${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
|||||||
constructor(messageSender) {
|
constructor(messageSender) {
|
||||||
this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})'
|
this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})'
|
||||||
this.environment = 4
|
this.environment = 4
|
||||||
|
this.platform = ${device.platform}
|
||||||
this.databases = []
|
this.databases = []
|
||||||
this.messageSender = messageSender
|
this.messageSender = messageSender
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { DeviceInterface } from './DeviceInterface'
|
import { DeviceInterface } from './DeviceInterface'
|
||||||
import { Environment, RawKeychainValue } from '@standardnotes/models'
|
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
|
||||||
|
|
||||||
export interface MobileDeviceInterface extends DeviceInterface {
|
export interface MobileDeviceInterface extends DeviceInterface {
|
||||||
environment: Environment.Mobile
|
environment: Environment.Mobile
|
||||||
|
platform: Platform.Ios | Platform.Android
|
||||||
|
|
||||||
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
|
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
|
||||||
getDeviceBiometricsAvailability(): Promise<boolean>
|
getDeviceBiometricsAvailability(): Promise<boolean>
|
||||||
@@ -12,4 +13,6 @@ export interface MobileDeviceInterface extends DeviceInterface {
|
|||||||
stopHidingMobileInterfaceFromScreenshots(): void
|
stopHidingMobileInterfaceFromScreenshots(): void
|
||||||
consoleLog(...args: any[]): void
|
consoleLog(...args: any[]): void
|
||||||
handleThemeSchemeChange(isDark: boolean): void
|
handleThemeSchemeChange(isDark: boolean): void
|
||||||
|
shareBase64AsFile(base64: string, filename: string): Promise<void>
|
||||||
|
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { ContentType } from '@standardnotes/common'
|
import { ContentType } from '@standardnotes/common'
|
||||||
import { ApplicationInterface } from '@standardnotes/services'
|
import { ApplicationInterface } from '@standardnotes/services'
|
||||||
|
|
||||||
function sanitizeFileName(name: string): string {
|
export function sanitizeFileName(name: string): string {
|
||||||
return name.trim().replace(/[.\\/:"?*|<>]/g, '_')
|
return name.trim().replace(/[.\\/:"?*|<>]/g, '_')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,13 +52,16 @@ export class ArchiveManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (encrypted) {
|
if (encrypted) {
|
||||||
this.downloadData(blobData, `Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`)
|
this.downloadData(
|
||||||
|
blobData,
|
||||||
|
`Standard Notes Encrypted Backup and Import File - ${this.formattedDateForExports()}.txt`,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.downloadZippedDecryptedItems(data).catch(console.error)
|
this.downloadZippedDecryptedItems(data).catch(console.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formattedDate() {
|
formattedDateForExports() {
|
||||||
const string = `${new Date()}`
|
const string = `${new Date()}`
|
||||||
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
|
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
|
||||||
const matches = string.match(/^(.*?) \(/)
|
const matches = string.match(/^(.*?) \(/)
|
||||||
@@ -108,7 +111,7 @@ export class ArchiveManager {
|
|||||||
await nextFile()
|
await nextFile()
|
||||||
} else {
|
} else {
|
||||||
const finalBlob = await zipWriter.close()
|
const finalBlob = await zipWriter.close()
|
||||||
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDate()}.zip`)
|
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDateForExports()}.zip`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +146,7 @@ export class ArchiveManager {
|
|||||||
|
|
||||||
async downloadDataAsZip(data: ZippableData) {
|
async downloadDataAsZip(data: ZippableData) {
|
||||||
const zipFileAsBlob = await this.zipData(data)
|
const zipFileAsBlob = await this.zipData(data)
|
||||||
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDate()}.zip`)
|
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDateForExports()}.zip`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private hrefForData(data: Blob) {
|
private hrefForData(data: Blob) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const createApplication = (
|
|||||||
device: WebOrDesktopDevice,
|
device: WebOrDesktopDevice,
|
||||||
webSocketUrl: string,
|
webSocketUrl: string,
|
||||||
) => {
|
) => {
|
||||||
const platform = getPlatform()
|
const platform = getPlatform(device)
|
||||||
|
|
||||||
const application = new WebApplication(
|
const application = new WebApplication(
|
||||||
deviceInterface,
|
deviceInterface,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const ICONS = {
|
|||||||
security: icons.SecurityIcon,
|
security: icons.SecurityIcon,
|
||||||
server: icons.ServerIcon,
|
server: icons.ServerIcon,
|
||||||
settings: icons.SettingsIcon,
|
settings: icons.SettingsIcon,
|
||||||
|
share: icons.ShareIcon,
|
||||||
signIn: icons.SignInIcon,
|
signIn: icons.SignInIcon,
|
||||||
signOut: icons.SignOutIcon,
|
signOut: icons.SignOutIcon,
|
||||||
spreadsheets: icons.SpreadsheetsIcon,
|
spreadsheets: icons.SpreadsheetsIcon,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const NotesContextMenu = ({
|
|||||||
side="right"
|
side="right"
|
||||||
togglePopover={closeMenu}
|
togglePopover={closeMenu}
|
||||||
>
|
>
|
||||||
<div ref={contextMenuRef}>
|
<div className="select-none" ref={contextMenuRef}>
|
||||||
<NotesOptions
|
<NotesOptions
|
||||||
application={application}
|
application={application}
|
||||||
navigationController={navigationController}
|
navigationController={navigationController}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Icon from '@/Components/Icon/Icon'
|
|||||||
import Switch from '@/Components/Switch/Switch'
|
import Switch from '@/Components/Switch/Switch'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
|
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 { KeyboardModifier } from '@standardnotes/ui-services'
|
||||||
import ChangeEditorOption from './ChangeEditorOption'
|
import ChangeEditorOption from './ChangeEditorOption'
|
||||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
||||||
@@ -15,6 +15,9 @@ import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
|||||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||||
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||||
|
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||||
|
import { shareSelectedItems } from '@/NativeMobileWeb/ShareSelectedItems'
|
||||||
|
import { downloadSelectedItemsOnAndroid } from '@/NativeMobileWeb/DownloadSelectedItemsOnAndroid'
|
||||||
|
|
||||||
type DeletePermanentlyButtonProps = {
|
type DeletePermanentlyButtonProps = {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -95,7 +98,7 @@ const NoteAttributes: FunctionComponent<{
|
|||||||
const format = editor?.package_info?.file_type || 'txt'
|
const format = editor?.package_info?.file_type || 'txt'
|
||||||
|
|
||||||
return (
|
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') ? (
|
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
@@ -220,18 +223,11 @@ const NotesOptions = ({
|
|||||||
}
|
}
|
||||||
}, [application])
|
}, [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 () => {
|
const downloadSelectedItems = useCallback(async () => {
|
||||||
if (notes.length === 1) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,8 +239,8 @@ const NotesOptions = ({
|
|||||||
await application.getArchiveService().downloadDataAsZip(
|
await application.getArchiveService().downloadDataAsZip(
|
||||||
notes.map((note) => {
|
notes.map((note) => {
|
||||||
return {
|
return {
|
||||||
name: getNoteFileName(note),
|
name: getNoteFileName(application, note),
|
||||||
content: new Blob([note.text]),
|
content: getNoteBlob(application, note),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -254,7 +250,7 @@ const NotesOptions = ({
|
|||||||
message: `Exported ${notes.length} notes`,
|
message: `Exported ${notes.length} notes`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [application, getNoteFileName, notes])
|
}, [application, notes])
|
||||||
|
|
||||||
const closeMenuAndToggleNotesList = useCallback(() => {
|
const closeMenuAndToggleNotesList = useCallback(() => {
|
||||||
toggleAppPane(AppPaneId.Items)
|
toggleAppPane(AppPaneId.Items)
|
||||||
@@ -358,11 +354,22 @@ const NotesOptions = ({
|
|||||||
)}
|
)}
|
||||||
<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"
|
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} />
|
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
|
||||||
Export
|
{application.platform === Platform.Android ? 'Share' : 'Export'}
|
||||||
</button>
|
</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
|
<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"
|
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}
|
onClick={duplicateSelectedItems}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const NotesOptionsPanel = ({
|
|||||||
>
|
>
|
||||||
<Icon type="more" />
|
<Icon type="more" />
|
||||||
</button>
|
</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
|
<NotesOptions
|
||||||
application={application}
|
application={application}
|
||||||
navigationController={navigationController}
|
navigationController={navigationController}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const StringUtils = {
|
|||||||
if (!application.hasAccount()) {
|
if (!application.hasAccount()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const platform = getPlatform()
|
const platform = getPlatform(application.deviceInterface)
|
||||||
const keychainName =
|
const keychainName =
|
||||||
platform === Platform.WindowsDesktop
|
platform === Platform.WindowsDesktop
|
||||||
? 'credential manager'
|
? '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 { IsDesktopPlatform, IsWebPlatform } from '@/Constants/Version'
|
||||||
import { EMAIL_REGEX } from '../Constants/Constants'
|
import { EMAIL_REGEX } from '../Constants/Constants'
|
||||||
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
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())
|
return platformFromString(getPlatformString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,3 +208,17 @@ export const disableIosTextFieldZoom = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isMobileScreen = () => !window.matchMedia(MediaQueryBreakpoints.md).matches
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@@ -7195,7 +7195,7 @@ __metadata:
|
|||||||
react-native-screens: 3.13.1
|
react-native-screens: 3.13.1
|
||||||
react-native-search-bar: "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09"
|
react-native-search-bar: "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09"
|
||||||
react-native-search-box: "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7"
|
react-native-search-box: "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7"
|
||||||
react-native-share: ^7.3.7
|
react-native-share: ^7.9.0
|
||||||
react-native-sodium-jsi: 1.2.0
|
react-native-sodium-jsi: 1.2.0
|
||||||
react-native-static-server: "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836"
|
react-native-static-server: "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836"
|
||||||
react-native-store-review: ^0.2.0
|
react-native-store-review: ^0.2.0
|
||||||
@@ -32971,10 +32971,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-native-share@npm:^7.3.7":
|
"react-native-share@npm:^7.9.0":
|
||||||
version: 7.6.4
|
version: 7.9.0
|
||||||
resolution: "react-native-share@npm:7.6.4"
|
resolution: "react-native-share@npm:7.9.0"
|
||||||
checksum: 22764354738587d9166653b09e014ddecf54562696b7f92353980b12be0f9adef58c5d9f927bd05a5e19f50956e6f14fbe2b7a3105675e5982823512945be315
|
checksum: 8eb2f5b4be8df11224f8f00b2761c2c3b4231590d945f57e8ed91899a455d7b5083bb62aa8a017ed8c15fbdfb95e85f9ee4060ac00c01897ef5c351e756c46af
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user