feat: refine share flow and download handling

This commit is contained in:
xixu-me committed 2026-03-22 13:50:36 +08:00
1 parent f8b727c3f6
commit 2f3a5daafc
16 files changed
+222 -323

No files matched your search

+3 -3
View File
@@ -19,7 +19,7 @@ export function NotFoundPage() {
<h2>This page was not found.</h2>
<p className="muted page-intro">
The address does not map to a page in Xdrop. If this came from a shared transfer, ask
for the full URL, including the <code>#k=...</code> decryption fragment.
the sender to resend the complete share details.
</p>
</div>
</Card>
@@ -47,8 +47,8 @@ export function NotFoundPage() {
<div className="file-stack">
<strong>Check the route family</strong>
<p className="muted">
Shared downloads live under <code>/t/:transferId</code>. Sender history lives under{' '}
<code>/transfers</code>.
Shared downloads and sender history use different pages. Return to a known page and
open the transfer again from there.
</p>
</div>
</li>
+10 -1
View File
@@ -4,12 +4,14 @@ import { describe, expect, it, vi } from 'vitest'
const { usePageMetadataMock, useTransfersMock } = vi.hoisted(() => ({
usePageMetadataMock: vi.fn(),
extendTransferMock: vi.fn(async () => {}),
useTransfersMock: vi.fn(() => ({
transfers: [
{
id: 't1',
},
],
extendTransfer: vi.fn(async () => {}),
})),
}))
@@ -46,9 +48,16 @@ describe('SharePage', () => {
expect(usePageMetadataMock).toHaveBeenCalledWith(
expect.objectContaining({
exposeUrl: false,
title: 'Share the Full Link | Xdrop',
title: 'Share This Transfer | Xdrop',
}),
)
expect(screen.getByText('Share t1')).toBeInTheDocument()
expect(shareCardMock).toHaveBeenCalledWith(
expect.objectContaining({
transfer: { id: 't1' },
onExtendTransfer: expect.any(Function),
}),
undefined,
)
})
})
+2 -2
View File
@@ -8,7 +8,7 @@ import { usePageMetadata } from '@/lib/seo/usePageMetadata'
/** SharePage shows sender-side progress and sharing controls for one local transfer. */
export function SharePage() {
const { transferId } = useParams()
const { transfers } = useTransfers()
const { transfers, extendTransfer } = useTransfers()
const transfer = transfers.find((item) => item.id === transferId)
usePageMetadata({
@@ -18,5 +18,5 @@ export function SharePage() {
exposeUrl: false,
})
return <ShareCard transfer={transfer} />
return <ShareCard transfer={transfer} onExtendTransfer={extendTransfer} />
}
@@ -4,17 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LocalTransferRecord } from '@/lib/indexeddb/db'
const { deleteTransferMock, extendTransferMock, transfersState } = vi.hoisted(() => ({
const { deleteTransferMock, transfersState } = vi.hoisted(() => ({
transfersState: [] as LocalTransferRecord[],
deleteTransferMock: vi.fn(async () => {}),
extendTransferMock: vi.fn(async () => {}),
}))
vi.mock('@/features/upload/TransferContext', () => ({
useTransfers: () => ({
transfers: transfersState,
deleteTransfer: deleteTransferMock,
extendTransfer: extendTransferMock,
}),
}))
@@ -54,12 +52,6 @@ describe('HistoryBoard', () => {
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
transfersState.splice(0, transfersState.length)
deleteTransferMock.mockClear()
extendTransferMock.mockClear()
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {}),
},
})
})
afterEach(() => {
@@ -95,9 +87,8 @@ describe('HistoryBoard', () => {
expect(expiredCard).not.toBeNull()
expect(screen.getByText('Expired transfer')).toBeInTheDocument()
expect(
within(expiredCard as HTMLElement).queryByRole('button', { name: /Copy link/i }),
screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Restore for 1 week/i })).not.toBeInTheDocument()
})
it('does not show the empty state when only expired transfers exist', () => {
@@ -118,7 +109,7 @@ describe('HistoryBoard', () => {
expect(screen.getByText('No transfers on this device')).toBeInTheDocument()
})
it('copies links, shows progress, and renders local warnings for active transfers', async () => {
it('shows progress and renders local warnings for active transfers', () => {
transfersState.splice(
0,
transfersState.length,
@@ -146,25 +137,6 @@ describe('HistoryBoard', () => {
expect(
within(uploadingCard as HTMLElement).getByRole('link', { name: 'Open share page' }),
).toHaveAttribute('href', '/share/uploading-1')
fireEvent.click(within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }))
await act(async () => {
await Promise.resolve()
})
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/t/uploading-1')
expect(
within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copied' }),
).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1800)
})
expect(
within(uploadingCard as HTMLElement).getByRole('button', { name: 'Copy link' }),
).toBeInTheDocument()
})
it('surfaces async action failures and clears stale errors before retrying', async () => {
@@ -173,33 +145,11 @@ describe('HistoryBoard', () => {
transfersState.length,
createTransfer('ready-1', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
)
extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
deleteTransferMock.mockRejectedValueOnce('unexpected')
renderBoard()
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByText('Extend failed')).toBeInTheDocument()
expect(extendTransferMock).toHaveBeenCalledWith('ready-1', expect.any(Number))
extendTransferMock.mockResolvedValueOnce(undefined)
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
await act(async () => {
await Promise.resolve()
})
expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(
screen.getByText(/Confirm delete to remove this transfer from this device/i),
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Confirm delete' }))
await act(async () => {
@@ -209,31 +159,4 @@ describe('HistoryBoard', () => {
expect(screen.getByText('Could not update this transfer right now.')).toBeInTheDocument()
expect(deleteTransferMock).toHaveBeenCalledWith('ready-1')
})
it('shows a scoped fallback when clipboard access is unavailable', async () => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {
throw new Error('denied')
}),
},
})
transfersState.splice(
0,
transfersState.length,
createTransfer('ready-2', 'Ready transfer', '2026-03-21T10:00:00.000Z'),
)
renderBoard()
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
await act(async () => {
await Promise.resolve()
})
expect(
screen.getByText(/Open the share page to copy the full link manually/i),
).toBeInTheDocument()
})
})
+3 -44
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/Button'
@@ -14,8 +13,7 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
/** HistoryBoard is the sender-side dashboard for transfer status, copy, extend, and delete actions. */
export function HistoryBoard() {
const { transfers, deleteTransfer, extendTransfer } = useTransfers()
const [copiedTransferId, setCopiedTransferId] = useState<string>()
const { transfers, deleteTransfer } = useTransfers()
const [expandedExpired, setExpandedExpired] = useState(false)
const [now, setNow] = useState(() => Date.now())
const [actionErrors, setActionErrors] = useState<Record<string, string>>({})
@@ -63,33 +61,6 @@ export function HistoryBoard() {
})
}
/** copyLink keeps link-copy feedback local to the transfer the user interacted with. */
const copyLink = async (transferId: string, shareUrl: string | undefined) => {
if (!shareUrl) {
return
}
clearActionError(transferId)
try {
await navigator.clipboard.writeText(shareUrl)
setCopiedTransferId(transferId)
window.setTimeout(() => setCopiedTransferId(undefined), 1800)
} catch {
setActionErrors((current) => ({
...current,
[transferId]: 'Copy failed. Open the share page to copy the full link manually.',
}))
}
}
const handleExtend = async (transferId: string) => {
clearActionError(transferId)
try {
await extendTransfer(transferId, MAX_EXPIRY_SECONDS)
} catch (error) {
setActionError(transferId, error)
}
}
const handleDelete = async (transferId: string) => {
clearActionError(transferId)
try {
@@ -187,21 +158,11 @@ export function HistoryBoard() {
) : null}
</div>
<div className="history-actions">
{transfer.shareUrl && !expired ? (
<Button tone="ghost" onClick={() => void copyLink(transfer.id, transfer.shareUrl)}>
{copiedTransferId === transfer.id ? 'Copied' : 'Copy link'}
</Button>
) : null}
{!expired ? (
<Link className="button button--ghost" to={`/share/${transfer.id}`}>
Open share page
</Link>
) : null}
{canManageTransfer && !expired ? (
<Button tone="ghost" onClick={() => void handleExtend(transfer.id)}>
{`Set expiry to ${getExpiryOptionLabel(MAX_EXPIRY_SECONDS)} from now`}
</Button>
) : null}
{pendingDeleteTransferId === transfer.id ? (
<>
<Button tone="danger" onClick={() => void handleDelete(transfer.id)}>
@@ -232,11 +193,9 @@ export function HistoryBoard() {
</div>
</div>
{pendingDeleteTransferId === transfer.id ? (
{pendingDeleteTransferId === transfer.id && !canManageTransfer ? (
<p aria-live="polite" className="warning">
{canManageTransfer
? 'Confirm delete to remove this transfer from this device.'
: 'Confirm forget to remove this local record from this device.'}
Confirm forget to remove this local record from this device.
</p>
) : null}
{transfer.localManagementCleared ? (
@@ -12,10 +12,10 @@ import {
} from '@/lib/crypto/envelope'
import { parseLinkKey } from '@/lib/crypto/urlKey'
import { createDecryptedReadableStream, decryptFileToBlob } from '@/lib/download/decrypt'
import { buildZipFromStreams, writeZipStream } from '@/lib/download/zip'
import { buildZipFromStreams } from '@/lib/download/zip'
import { formatBytes } from '@/lib/files/formatBytes'
import { safeDownloadName, sanitizePath } from '@/lib/files/paths'
import { isAbortError, openSaveWritable, saveBlob, supportsStreamingSave } from '@/lib/files/save'
import { saveBlob } from '@/lib/files/save'
import { formatLocalDateTime } from '@/lib/i18n/formatDateTime'
import { PRIVATE_ROBOTS, RECEIVE_PAGE_DESCRIPTION, RECEIVE_PAGE_TITLE } from '@/lib/seo/site'
import { usePageMetadata } from '@/lib/seo/usePageMetadata'
@@ -94,7 +94,7 @@ export function ReceiveTransfer({ transferId }: Props) {
})()
}, [fragmentKey, transferId])
/** downloadFile streams to disk when possible and falls back to an in-memory Blob otherwise. */
/** downloadFile decrypts to a Blob and hands it to the browser download manager. */
const downloadFile = async (file: ManifestFileEntry) => {
if (!rootKey) {
return
@@ -115,34 +115,18 @@ export function ReceiveTransfer({ transferId }: Props) {
setDownloadProgress(Math.round((completedBytes / Math.max(totalBytes, 1)) * 100)),
}
if (supportsStreamingSave()) {
const writable = await openSaveWritable(filename, {
mimeType: file.mimeType || 'application/octet-stream',
})
if (writable) {
await createDecryptedReadableStream(downloadArgs).pipeTo(writable)
} else {
const blob = await decryptFileToBlob(downloadArgs)
saveBlob(blob, filename)
}
} else {
const blob = await decryptFileToBlob(downloadArgs)
saveBlob(blob, filename)
}
const blob = await decryptFileToBlob(downloadArgs)
saveBlob(blob, filename)
setDownloadProgress(100)
} catch (caughtError) {
if (isAbortError(caughtError)) {
setDownloadProgress(0)
return
}
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
} finally {
setActiveDownload((current) => (current === file.fileId ? null : current))
}
}
/** downloadAll streams ZIP creation to disk when possible and avoids per-file buffering. */
/** downloadAll creates one ZIP Blob and hands it to the browser download manager. */
const downloadAll = async () => {
if (!manifest || !rootKey) {
return
@@ -176,25 +160,11 @@ export function ReceiveTransfer({ transferId }: Props) {
},
}))
if (supportsStreamingSave()) {
const writable = await openSaveWritable(zipName, { mimeType: 'application/zip' })
if (writable) {
await writeZipStream(createZipEntries(), writable)
} else {
const zip = await buildZipFromStreams(createZipEntries())
saveBlob(zip, zipName)
}
} else {
const zip = await buildZipFromStreams(createZipEntries())
saveBlob(zip, zipName)
}
const zip = await buildZipFromStreams(createZipEntries())
saveBlob(zip, zipName)
setDownloadProgress(100)
} catch (caughtError) {
if (isAbortError(caughtError)) {
setDownloadProgress(0)
return
}
setDownloadError(caughtError instanceof Error ? caughtError.message : 'Download failed.')
} finally {
setActiveDownload((current) => (current === 'zip' ? null : current))
+92 -9
View File
@@ -45,10 +45,13 @@ function createTransfer(
}
describe('ShareCard', () => {
const extendTransferMock = vi.fn(async () => {})
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
toDataUrlMock.mockClear()
extendTransferMock.mockClear()
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {}),
@@ -66,6 +69,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-1', 'Active transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -74,10 +78,15 @@ describe('ShareCard', () => {
await Promise.resolve()
})
expect(screen.getByRole('heading', { name: /Share the full link/i })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /Share this transfer/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Copy link/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Share link/i })).toBeInTheDocument()
expect(screen.getByDisplayValue('https://example.com/t/active-1#k=test')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Set expiry to 1 week from now/i }),
).toBeInTheDocument()
expect(
screen.queryByDisplayValue('https://example.com/t/active-1#k=test'),
).not.toBeInTheDocument()
expect(screen.getByAltText('Transfer QR code')).toBeInTheDocument()
expect(toDataUrlMock).toHaveBeenCalledTimes(1)
})
@@ -119,6 +128,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-2', 'Copied transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -153,6 +163,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-3', 'Shared transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -180,6 +191,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-4', 'Fallback transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -200,7 +212,7 @@ describe('ShareCard', () => {
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('shows a manual-copy fallback when clipboard access fails', async () => {
it('shows a retry-focused fallback when clipboard access fails', async () => {
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(async () => {
@@ -213,6 +225,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-6', 'Clipboard fallback', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -227,8 +240,12 @@ describe('ShareCard', () => {
await Promise.resolve()
})
expect(screen.getByText(/Select the full link below to copy it manually/i)).toBeInTheDocument()
expect(screen.getByDisplayValue('https://example.com/t/active-6#k=test')).toBeInTheDocument()
expect(
screen.getByText(/Use your browser share tools or try again on this device/i),
).toBeInTheDocument()
expect(
screen.queryByDisplayValue('https://example.com/t/active-6#k=test'),
).not.toBeInTheDocument()
})
it('ignores canceled share-sheet requests without surfacing an error', async () => {
@@ -241,6 +258,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-7', 'Dismissed share', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
@@ -302,7 +320,7 @@ describe('ShareCard', () => {
const { rerender } = render(
<MemoryRouter>
<ShareCard transfer={pausedTransfer} />
<ShareCard transfer={pausedTransfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
@@ -320,7 +338,7 @@ describe('ShareCard', () => {
rerender(
<MemoryRouter>
<ShareCard transfer={failedTransfer} />
<ShareCard transfer={failedTransfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
await act(async () => {
@@ -330,7 +348,7 @@ describe('ShareCard', () => {
rerender(
<MemoryRouter>
<ShareCard transfer={deletedTransfer} />
<ShareCard transfer={deletedTransfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
await act(async () => {
@@ -341,7 +359,7 @@ describe('ShareCard', () => {
rerender(
<MemoryRouter>
<ShareCard transfer={uploadingTransfer} />
<ShareCard transfer={uploadingTransfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
await act(async () => {
@@ -350,6 +368,70 @@ describe('ShareCard', () => {
expect(screen.getByText('The link will work once the upload finishes.')).toBeInTheDocument()
})
it('extends expiry from the share page and surfaces failures locally', async () => {
extendTransferMock.mockRejectedValueOnce(new Error('Extend failed'))
const transfer = createTransfer('active-8', 'Extend transfer', '2026-03-21T10:00:00.000Z')
const { rerender } = render(
<MemoryRouter>
<ShareCard transfer={transfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
await act(async () => {
await Promise.resolve()
})
expect(extendTransferMock).toHaveBeenCalledWith('active-8', 7 * 24 * 60 * 60)
expect(screen.getByText('Extend failed')).toBeInTheDocument()
extendTransferMock.mockResolvedValueOnce(undefined)
rerender(
<MemoryRouter>
<ShareCard transfer={transfer} onExtendTransfer={extendTransferMock} />
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: /Set expiry to 1 week from now/i }))
await act(async () => {
await Promise.resolve()
})
expect(screen.queryByText('Extend failed')).not.toBeInTheDocument()
})
it('hides the extend action when local management is unavailable', async () => {
render(
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-9', 'No manage token', '2026-03-21T10:00:00.000Z', {
manageToken: '',
})}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
await act(async () => {
await Promise.resolve()
})
expect(
screen.queryByRole('button', { name: /Set expiry to 1 week from now/i }),
).not.toBeInTheDocument()
})
it('keeps the QR placeholder when QR generation fails', async () => {
toDataUrlMock.mockRejectedValueOnce(new Error('QR failed'))
@@ -357,6 +439,7 @@ describe('ShareCard', () => {
<MemoryRouter>
<ShareCard
transfer={createTransfer('active-5', 'Broken QR transfer', '2026-03-21T10:00:00.000Z')}
onExtendTransfer={extendTransferMock}
/>
</MemoryRouter>,
)
+41 -20
View File
@@ -1,3 +1,4 @@
import { MAX_EXPIRY_SECONDS, getExpiryOptionLabel } from '@xdrop/shared'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import QRCode from 'qrcode'
@@ -14,17 +15,25 @@ import { isExpiredTransfer } from '@/lib/transfers/expiry'
type Props = {
transfer: LocalTransferRecord | undefined
onExtendTransfer?: (transferId: string, expiresInSeconds: number) => Promise<void>
}
/** ShareCard shows sender-side sharing controls and current upload state for one transfer. */
export function ShareCard({ transfer }: Props) {
export function ShareCard({ transfer, onExtendTransfer }: Props) {
const [now, setNow] = useState(() => Date.now())
const [qrState, setQrState] = useState<{ dataUrl?: string; shareUrl?: string }>({})
const [copyState, setCopyState] = useState<'idle' | 'copied'>('idle')
const [isExtending, setIsExtending] = useState(false)
const [actionError, setActionError] = useState<string>()
const progress = transfer ? (transfer.uploadedBytes / Math.max(transfer.totalBytes, 1)) * 100 : 0
const expired = transfer ? isExpiredTransfer(transfer, now) : false
const canShareLink = Boolean(transfer?.shareUrl) && !expired && transfer?.status !== 'deleted'
const canExtendTransfer =
Boolean(transfer?.manageToken) &&
!expired &&
transfer?.status !== 'deleted' &&
transfer?.localManagementCleared !== true &&
onExtendTransfer !== undefined
const qrShareUrl = canShareLink ? transfer?.shareUrl : undefined
const qrDataUrl = qrShareUrl && qrState.shareUrl === qrShareUrl ? qrState.dataUrl : undefined
@@ -80,7 +89,7 @@ export function ShareCard({ transfer }: Props) {
window.setTimeout(() => setCopyState('idle'), 1800)
} catch {
setCopyState('idle')
setActionError('Copy failed. Select the full link below to copy it manually.')
setActionError('Copy failed. Use your browser share tools or try again on this device.')
}
}
@@ -110,6 +119,24 @@ export function ShareCard({ transfer }: Props) {
await copyLink()
}
const extendExpiry = async () => {
if (!transfer || !canExtendTransfer || !onExtendTransfer) {
return
}
try {
setActionError(undefined)
setIsExtending(true)
await onExtendTransfer(transfer.id, MAX_EXPIRY_SECONDS)
} catch (error) {
setActionError(
error instanceof Error ? error.message : 'Could not update this transfer right now.',
)
} finally {
setIsExtending(false)
}
}
if (!transfer) {
return (
<PageStateCard
@@ -173,7 +200,7 @@ export function ShareCard({ transfer }: Props) {
<div className="page-hero-head">
<div className="page-hero-copy">
<p className="eyebrow">Share</p>
<h2>Share the full link.</h2>
<h2>Share this transfer.</h2>
<p className="page-summary-name">{transfer.displayName}</p>
<p className="muted page-intro">
The server stores ciphertext and operational metadata, not plaintext file names, file
@@ -208,23 +235,17 @@ export function ShareCard({ transfer }: Props) {
<Button onClick={() => void shareLink()} tone="ghost" disabled={!canShareLink}>
Share link
</Button>
{canExtendTransfer ? (
<Button tone="ghost" onClick={() => void extendExpiry()} disabled={isExtending}>
{isExtending
? 'Updating expiry…'
: `Set expiry to ${getExpiryOptionLabel(MAX_EXPIRY_SECONDS)} from now`}
</Button>
) : null}
<Link className="button button--ghost" to="/transfers">
Manage transfers
</Link>
</div>
{transfer.shareUrl ? (
<label className="field">
<span>Full link</span>
<input
autoComplete="off"
name="shareUrl"
readOnly
spellCheck={false}
value={transfer.shareUrl}
onFocus={(event) => event.currentTarget.select()}
/>
</label>
) : null}
{actionError ? (
<p aria-live="polite" className="warning">
{actionError}
@@ -232,13 +253,13 @@ export function ShareCard({ transfer }: Props) {
) : null}
{transfer.localManagementCleared ? (
<p className="warning">
Privacy mode removed local transfer controls after upload. Keep the share link safe.
Extend and delete are no longer available here.
Privacy mode removed local transfer controls after upload. Keep your saved share access
safe. Extend and delete are no longer available here.
</p>
) : null}
<p className="warning">
Keep the full link, including the <code>#k=</code> part. Without it, the files cannot be
decrypted.
Keep the original share details intact. If the decryption key is missing, the files cannot
be opened.
</p>
</Card>
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { saveBlob } from './save'
describe('saveBlob', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('uses the browser download manager instead of a file picker', () => {
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:https://example.com/download')
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
const clickSpy = vi.fn()
const anchor = document.createElement('a')
anchor.click = clickSpy
const originalCreateElement = document.createElement.bind(document)
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName.toLowerCase() === 'a') {
return anchor
}
return originalCreateElement(tagName)
})
const blob = new Blob(['hello'], { type: 'text/plain' })
saveBlob(blob, 'hello.txt')
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(anchor.download).toBe('hello.txt')
expect(anchor.href).toBe('blob:https://example.com/download')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(setTimeoutSpy).toHaveBeenCalledOnce()
const revokeCallback = setTimeoutSpy.mock.calls[0]?.[0]
expect(revokeCallback).toBeTypeOf('function')
;(revokeCallback as () => void)()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:https://example.com/download')
createElementSpy.mockRestore()
})
})
+6 -104
View File
@@ -1,114 +1,16 @@
/**
* Download helpers that use the File System Access API when available and anchor downloads otherwise.
* Download helpers that hand files to the browser's normal download manager.
*/
type FilePickerAcceptTypeLike = {
description?: string
accept: Record<string, string[]>
}
type FileSystemWriteChunk =
| BufferSource
| Blob
| string
| {
type: 'write'
position?: number
data: BufferSource | Blob | string
}
| {
type: 'seek'
position: number
}
| {
type: 'truncate'
size: number
}
export type FileSystemWritableLike = WritableStream<Uint8Array> & {
write: (data: FileSystemWriteChunk) => Promise<void>
close: () => Promise<void>
abort: (reason?: unknown) => Promise<void>
}
type FileSystemFileHandleLike = {
createWritable: (options?: { keepExistingData?: boolean }) => Promise<FileSystemWritableLike>
}
type WindowWithSavePicker = Window & {
showSaveFilePicker?: (options?: {
suggestedName?: string
types?: FilePickerAcceptTypeLike[]
excludeAcceptAllOption?: boolean
}) => Promise<FileSystemFileHandleLike>
}
/** supportsStreamingSave detects whether the browser can stream directly to a picked file. */
export function supportsStreamingSave() {
return (
typeof window !== 'undefined' &&
navigator.webdriver !== true &&
typeof (window as WindowWithSavePicker).showSaveFilePicker === 'function'
)
}
/** openSaveWritable opens a writable file handle for streaming downloads when supported. */
export async function openSaveWritable(suggestedName: string, options?: { mimeType?: string }) {
const picker = (window as WindowWithSavePicker).showSaveFilePicker
if (!picker) {
return null
}
const type = buildPickerType(suggestedName, options?.mimeType)
const handle = await picker(
type
? {
suggestedName,
types: [type],
}
: { suggestedName },
)
return handle.createWritable({ keepExistingData: false })
}
/** saveBlob falls back to a standard browser download using an object URL. */
/** saveBlob triggers a standard browser download using an object URL. */
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.rel = 'noopener'
anchor.click()
URL.revokeObjectURL(url)
}
/** isAbortError normalizes user-cancelled file picker errors for callers. */
export function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === 'AbortError'
}
function buildPickerType(
suggestedName: string,
mimeType?: string,
): FilePickerAcceptTypeLike | undefined {
const extension = extensionFromName(suggestedName)
if (!mimeType || !extension) {
return undefined
}
return {
description: mimeType,
accept: {
[mimeType]: [extension],
},
}
}
function extensionFromName(filename: string) {
const dot = filename.lastIndexOf('.')
if (dot <= 0) {
return undefined
}
return filename.slice(dot)
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
+4 -4
View File
@@ -15,15 +15,15 @@ export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER
export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop'
export const HISTORY_PAGE_DESCRIPTION =
'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.'
export const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop'
export const SHARE_PAGE_TITLE = 'Share This Transfer | Xdrop'
export const SHARE_PAGE_DESCRIPTION =
'Review upload status and copy the full share link for a transfer staged in this browser on this device.'
'Review upload status and share a transfer staged in this browser on this device without exposing its address on the page.'
export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop'
export const RECEIVE_PAGE_DESCRIPTION =
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.'
'Download files from this transfer and decrypt them in the browser. The decryption key stays in the browser and never reaches the server.'
export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop'
export const NOT_FOUND_PAGE_DESCRIPTION =
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.'
'The address does not map to a page in Xdrop. If this came from a shared transfer, ask the sender to resend the complete share details.'
export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png'
export const DEFAULT_OG_IMAGE_ALT = 'Xdrop horizontal brand lockup'
export const DEFAULT_LOGO_PATH = '/brand-symbol-512.png'