fix: Make the onCopyUrl work on Safari (#27215)

* Add a new hook for copying URLs.

* Refactor the copyToClipboard function.

* Make the onCopyUrl function awaitable.

* Fix the test.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ivan VasilovandJoshen Lim authored and GitHub committed 2024-08-07 13:50:50 +08:00
1 parent c9ebb812c9
commit 0895081f4f
11 files changed
+99 -93

No files matched your search

@@ -4,10 +4,7 @@ import { Button, Form, Input, Listbox, Modal } from 'ui'
import { DATETIME_FORMAT } from 'lib/constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
export interface CustomExpiryModalProps {
onCopyUrl: (name: string, url: string) => void
}
import { useCopyUrl } from './useCopyUrl'
const unitMap = {
days: 3600 * 24,
@@ -16,8 +13,9 @@ const unitMap = {
years: 3600 * 24 * 365,
}
const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => {
const CustomExpiryModal = () => {
const storageExplorerStore = useStorageStore()
const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef)
const { getFileUrl, selectedFileCustomExpiry, setSelectedFileCustomExpiry } = storageExplorerStore
const visible = selectedFileCustomExpiry !== undefined
@@ -38,9 +36,9 @@ const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => {
initialValues={{ expiresIn: '', units: 'days' }}
onSubmit={async (values: any, { setSubmitting }: any) => {
setSubmitting(true)
onCopyUrl(
await onCopyUrl(
selectedFileCustomExpiry!.name,
await getFileUrl(
getFileUrl(
selectedFileCustomExpiry!,
values.expiresIn * unitMap[values.units as 'days' | 'weeks' | 'months' | 'years']
)
@@ -20,7 +20,6 @@ export interface FileExplorerProps {
onSelectAllItemsInColumn: (index: number) => void
onSelectColumnEmptySpace: (index: number) => void
onColumnLoadMore: (index: number, column: StorageColumn) => void
onCopyUrl: (name: string, url: string) => void
}
const FileExplorer = ({
@@ -34,7 +33,6 @@ const FileExplorer = ({
onSelectAllItemsInColumn = noop,
onSelectColumnEmptySpace = noop,
onColumnLoadMore = noop,
onCopyUrl = noop,
}: FileExplorerProps) => {
const fileExplorerRef = useRef<any>(null)
@@ -53,7 +51,7 @@ const FileExplorer = ({
className="file-explorer flex flex-grow overflow-x-auto justify-between h-full w-full"
>
<ColumnContextMenu id={CONTEXT_MENU_KEYS.STORAGE_COLUMN} />
<ItemContextMenu id={CONTEXT_MENU_KEYS.STORAGE_ITEM} onCopyUrl={onCopyUrl} />
<ItemContextMenu id={CONTEXT_MENU_KEYS.STORAGE_ITEM} />
<FolderContextMenu id={CONTEXT_MENU_KEYS.STORAGE_FOLDER} />
{view === STORAGE_VIEWS.COLUMNS ? (
<div className="flex">
@@ -71,7 +69,6 @@ const FileExplorer = ({
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onColumnLoadMore={onColumnLoadMore}
onCopyUrl={onCopyUrl}
/>
))}
</div>
@@ -90,7 +87,6 @@ const FileExplorer = ({
onSelectAllItemsInColumn={onSelectAllItemsInColumn}
onSelectColumnEmptySpace={onSelectColumnEmptySpace}
onColumnLoadMore={onColumnLoadMore}
onCopyUrl={onCopyUrl}
/>
)}
</>
@@ -66,7 +66,6 @@ export interface FileExplorerColumnProps {
onSelectAllItemsInColumn: (index: number) => void
onSelectColumnEmptySpace: (index: number) => void
onColumnLoadMore: (index: number, column: StorageColumn) => void
onCopyUrl: (name: string, url: string) => void
}
const FileExplorerColumn = ({
@@ -82,7 +81,6 @@ const FileExplorerColumn = ({
onSelectAllItemsInColumn = noop,
onSelectColumnEmptySpace = noop,
onColumnLoadMore = noop,
onCopyUrl = noop,
}: FileExplorerColumnProps) => {
const [isDraggedOver, setIsDraggedOver] = useState(false)
const fileExplorerColumnRef = useRef<any>(null)
@@ -224,7 +222,6 @@ const FileExplorerColumn = ({
selectedItems,
openedFolders,
selectedFilePreview,
onCopyUrl,
}}
ItemComponent={FileExplorerRow}
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
@@ -43,6 +43,7 @@ import {
import { StorageItem, StorageItemWithColumn } from '../Storage.types'
import FileExplorerRowEditing from './FileExplorerRowEditing'
import { copyPathToFolder } from './StorageExplorer.utils'
import { useCopyUrl } from './useCopyUrl'
export const RowIcon = ({
view,
@@ -97,7 +98,6 @@ export interface FileExplorerRowProps {
selectedItems: StorageItemWithColumn[]
openedFolders: StorageItem[]
selectedFilePreview: (StorageItemWithColumn & { previewUrl: string | undefined }) | null
onCopyUrl: (name: string, url: string) => void
}
const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
@@ -108,7 +108,6 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
selectedItems = [],
openedFolders = [],
selectedFilePreview,
onCopyUrl,
}) => {
const storageExplorerStore = useStorageStore()
const {
@@ -130,6 +129,7 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
downloadFolder,
selectRangeItems,
} = storageExplorerStore
const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef)
const isPublic = selectedBucket.public
const itemWithColumnIndex = { ...item, columnIndex }
@@ -213,11 +213,8 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
{
name: 'Get URL',
icon: <IconClipboard size="tiny" />,
onClick: async () =>
onCopyUrl(
itemWithColumnIndex.name,
await getFileUrl(itemWithColumnIndex)
),
onClick: () =>
onCopyUrl(itemWithColumnIndex.name, getFileUrl(itemWithColumnIndex)),
},
]
: [
@@ -227,26 +224,26 @@ const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
children: [
{
name: 'Expire in 1 week',
onClick: async () =>
onClick: () =>
onCopyUrl(
itemWithColumnIndex.name,
await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.WEEK)
getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.WEEK)
),
},
{
name: 'Expire in 1 month',
onClick: async () =>
onClick: () =>
onCopyUrl(
itemWithColumnIndex.name,
await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.MONTH)
getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.MONTH)
),
},
{
name: 'Expire in 1 year',
onClick: async () =>
onClick: () =>
onCopyUrl(
itemWithColumnIndex.name,
await getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.YEAR)
getFileUrl(itemWithColumnIndex, URL_EXPIRY_DURATION.YEAR)
),
},
{
@@ -1,5 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop } from 'lodash'
import { observer } from 'mobx-react-lite'
import { Item, Menu, Separator, Submenu } from 'react-contexify'
import 'react-contexify/dist/ReactContexify.css'
@@ -9,13 +8,13 @@ import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStor
import { IconChevronRight, IconClipboard, IconDownload, IconEdit, IconMove, IconTrash2 } from 'ui'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { StorageItemWithColumn } from '../Storage.types'
import { useCopyUrl } from './useCopyUrl'
interface ItemContextMenuProps {
id: string
onCopyUrl: (name: string, url: string) => void
}
const ItemContextMenu = ({ id = '', onCopyUrl = noop }: ItemContextMenuProps) => {
const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => {
const storageExplorerStore = useStorageStore()
const {
getFileUrl,
@@ -26,6 +25,7 @@ const ItemContextMenu = ({ id = '', onCopyUrl = noop }: ItemContextMenuProps) =>
setSelectedItemsToMove,
setSelectedFileCustomExpiry,
} = storageExplorerStore
const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef)
const isPublic = selectedBucket.public
const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
@@ -34,7 +34,7 @@ const ItemContextMenu = ({ id = '', onCopyUrl = noop }: ItemContextMenuProps) =>
switch (event) {
case 'copy':
if (expiresIn !== undefined && expiresIn < 0) return setSelectedFileCustomExpiry(item)
else return onCopyUrl(item.name, await getFileUrl(item, expiresIn))
else return onCopyUrl(item.name, getFileUrl(item, expiresIn))
case 'rename':
return setSelectedItemToRename(item)
case 'move':
@@ -23,6 +23,7 @@ import {
IconX,
} from 'ui'
import { URL_EXPIRY_DURATION } from '../Storage.constants'
import { useCopyUrl } from './useCopyUrl'
const PreviewFile = ({ mimeType, previewUrl }: { mimeType?: string; previewUrl?: string }) => {
if (!mimeType || !previewUrl) {
@@ -99,11 +100,7 @@ const PreviewFile = ({ mimeType, previewUrl }: { mimeType?: string; previewUrl?:
)
}
export interface PreviewPaneProps {
onCopyUrl: (name: string, url: string) => void
}
const PreviewPane = ({ onCopyUrl }: PreviewPaneProps) => {
const PreviewPane = () => {
const storageExplorerStore = useStorageStore()
const {
getFileUrl,
@@ -114,6 +111,7 @@ const PreviewPane = ({ onCopyUrl }: PreviewPaneProps) => {
setSelectedItemsToDelete,
setSelectedFileCustomExpiry,
} = storageExplorerStore
const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef)
const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
@@ -208,7 +206,7 @@ const PreviewPane = ({ onCopyUrl }: PreviewPaneProps) => {
<Button
type="outline"
icon={<IconClipboard size={16} strokeWidth={2} />}
onClick={async () => onCopyUrl(file.name, await getFileUrl(file))}
onClick={() => onCopyUrl(file.name, getFileUrl(file))}
disabled={file.isCorrupted}
>
Get URL
@@ -228,24 +226,24 @@ const PreviewPane = ({ onCopyUrl }: PreviewPaneProps) => {
<DropdownMenuContent side="bottom" align="center">
<DropdownMenuItem
key="expires-one-week"
onClick={async () =>
onCopyUrl(file.name, await getFileUrl(file, URL_EXPIRY_DURATION.WEEK))
onClick={() =>
onCopyUrl(file.name, getFileUrl(file, URL_EXPIRY_DURATION.WEEK))
}
>
Expire in 1 week
</DropdownMenuItem>
<DropdownMenuItem
key="expires-one-month"
onClick={async () =>
onCopyUrl(file.name, await getFileUrl(file, URL_EXPIRY_DURATION.MONTH))
onClick={() =>
onCopyUrl(file.name, getFileUrl(file, URL_EXPIRY_DURATION.MONTH))
}
>
Expire in 1 month
</DropdownMenuItem>
<DropdownMenuItem
key="expires-one-year"
onClick={async () =>
onCopyUrl(file.name, await getFileUrl(file, URL_EXPIRY_DURATION.YEAR))
onClick={() =>
onCopyUrl(file.name, getFileUrl(file, URL_EXPIRY_DURATION.YEAR))
}
>
Expire in 1 year
@@ -2,14 +2,10 @@ import { useParams } from 'common'
import { compact, get, isEmpty, uniqBy } from 'lodash'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { useProjectSettingsQuery } from 'data/config/project-settings-query'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import type { Bucket } from 'data/storage/buckets-query'
import { DEFAULT_PROJECT_API_SERVICE_ID, IS_PLATFORM } from 'lib/constants'
import { copyToClipboard } from 'lib/helpers'
import { IS_PLATFORM } from 'lib/constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
import ConfirmDeleteModal from './ConfirmDeleteModal'
@@ -57,19 +53,11 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
const storageExplorerRef = useRef(null)
const { ref } = useParams()
const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref })
const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref })
// [Joshen] This is to ensure that StorageExplorerStore can get the storage file size limit
// Will be better once we deprecate the mobx store entirely, which we will get there
useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const apiService = (projectSettings?.services ?? []).find(
(x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID
)
const apiConfig = apiService?.app_config
const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}`
// This state exists outside of the header because FileExplorerColumn needs to listen to these as well
// I'm keeping them outside of the mobx store as I feel that the store should contain persistent data
// Things like showing results from a search filter is "temporary", hence we use react state to manage
@@ -190,16 +178,6 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
clearSelectedItems()
}
const onCopyUrl = (name: string, url: string) => {
const formattedUrl =
customDomainData?.customDomain?.status === 'active'
? url.replace(apiUrl, `https://${customDomainData.customDomain.hostname}`)
: url
copyToClipboard(formattedUrl, () => {
toast.success(`Copied URL for ${name} to clipboard.`)
})
}
return (
<div
ref={storageExplorerRef}
@@ -231,9 +209,8 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
onColumnLoadMore={(index, column) =>
fetchMoreFolderContents(index, column, itemSearchString)
}
onCopyUrl={onCopyUrl}
/>
<PreviewPane onCopyUrl={onCopyUrl} />
<PreviewPane />
</div>
<ConfirmDeleteModal
visible={selectedItemsToDelete.length > 0}
@@ -248,7 +225,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
onSelectCancel={clearSelectedItemsToMove}
onSelectMove={onMoveSelectedFiles}
/>
<CustomExpiryModal onCopyUrl={onCopyUrl} />
<CustomExpiryModal />
</div>
)
}
@@ -0,0 +1,30 @@
import { useProjectSettingsQuery } from 'data/config/project-settings-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants/infrastructure'
import { copyToClipboard } from 'lib/helpers'
import toast from 'react-hot-toast'
export const useCopyUrl = (ref: string) => {
const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref })
const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref })
const apiService = (projectSettings?.services ?? []).find(
(x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID
)
const apiConfig = apiService?.app_config
const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}`
const onCopyUrl = (name: string, url: string | Promise<string>) => {
const formattedUrl = Promise.resolve(url).then((url) => {
return customDomainData?.customDomain?.status === 'active'
? url.replace(apiUrl, `https://${customDomainData.customDomain.hostname}`)
: url
})
return copyToClipboard(formattedUrl, () => {
toast.success(`Copied URL for ${name} to clipboard.`)
})
}
return { onCopyUrl }
}
+4 -3
View File
@@ -1,6 +1,7 @@
import { copyToClipboard } from 'lib/helpers'
import { Check, Clipboard } from 'lucide-react'
import { useEffect, useState } from 'react'
import { copyToClipboard } from 'lib/helpers'
import { Button, ButtonProps } from 'ui'
export interface CopyButtonProps extends ButtonProps {
@@ -28,9 +29,9 @@ const CopyButton = ({
return (
<Button
onClick={(e) => {
onClick={async (e) => {
setShowCopied(true)
copyToClipboard(text)
await copyToClipboard(text)
onClick?.(e)
}}
icon={
+31 -19
View File
@@ -1,5 +1,7 @@
import { noop } from 'lodash'
import toast from 'react-hot-toast'
export { default as passwordStrength } from './password-strength'
export { default as uuidv4 } from './uuid'
export const tryParseJson = (jsonString: any) => {
@@ -136,28 +138,38 @@ export const snakeToCamel = (str: string) =>
)
/**
* Copy text content (string or Promise<string>) into Clipboard.
* Safari doesn't support write text into clipboard async, so if you need to load
* text content async before coping, please use Promise<string> for the 1st arg.
* Copy text content (string or Promise<string>) into Clipboard. Safari doesn't support write text into clipboard async,
* so if you need to load text content async before coping, please use Promise<string> for the 1st arg.
*
* IF YOU NEED TO CHANGE THIS FUNCTION, PLEASE TEST IT IN SAFARI with a promised string. Expiring URL to a file in a
* private bucket will do.
*
* Copied code from https://wolfgangrittner.dev/how-to-use-clipboard-api-in-firefox/
*/
export const copyToClipboard = async (str: string | Promise<string>, callback = () => {}) => {
try {
const focused = window.document.hasFocus()
if (focused) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
const text = await Promise.resolve(str)
Promise.resolve(window.navigator?.clipboard?.writeText(text)).then(callback)
return
}
Promise.resolve(str)
.then((text) => window.navigator?.clipboard?.writeText(text))
.then(callback)
export const copyToClipboard = async (str: string | Promise<string>, callback = noop) => {
const focused = window.document.hasFocus()
if (focused) {
if (typeof ClipboardItem && navigator.clipboard?.write) {
// NOTE: Safari locks down the clipboard API to only work when triggered
// by a direct user interaction. You can't use it async in a promise.
// But! You can wrap the promise in a ClipboardItem, and give that to
// the clipboard API.
// Found this on https://developer.apple.com/forums/thread/691873
const text = new ClipboardItem({
'text/plain': Promise.resolve(str).then((text) => new Blob([text], { type: 'text/plain' })),
})
navigator.clipboard.write([text]).then(callback)
} else {
console.warn('Unable to copy to clipboard')
// NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write,
// but those are behind `dom.events.asyncClipboard.clipboardItem` preference.
// Good news is that other than Safari, Firefox does not care about
// Clipboard API being used async in a Promise.
Promise.resolve(str)
.then((text) => navigator.clipboard?.writeText(text))
.then(callback)
}
} catch (error: any) {
toast.error(`Unable to copy to clipboard: ${error.message}`)
} else {
toast.error('Unable to copy to clipboard')
}
}
@@ -65,7 +65,7 @@ const EMPTY_FOLDER_PLACEHOLDER_FILE_NAME = '.emptyFolderPlaceholder'
const STORAGE_PROGRESS_INFO_TEXT = "Do not close the browser until it's completed"
class StorageExplorerStore {
private projectRef: string = ''
projectRef: string = ''
view: STORAGE_VIEWS = STORAGE_VIEWS.COLUMNS
sortBy: STORAGE_SORT_BY = STORAGE_SORT_BY.NAME
sortByOrder: STORAGE_SORT_BY_ORDER = STORAGE_SORT_BY_ORDER.ASC