diff --git a/apps/marketing/src/components/(marketing)/password-reveal.tsx b/apps/marketing/src/components/(marketing)/password-reveal.tsx index b31765943..450221339 100644 --- a/apps/marketing/src/components/(marketing)/password-reveal.tsx +++ b/apps/marketing/src/components/(marketing)/password-reveal.tsx @@ -1,9 +1,8 @@ 'use client'; +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; - export type PasswordRevealProps = { password: string; }; diff --git a/apps/marketing/src/hooks/use-copy-to-clipboard.ts b/apps/marketing/src/hooks/use-copy-to-clipboard.ts deleted file mode 100644 index d449ded16..000000000 --- a/apps/marketing/src/hooks/use-copy-to-clipboard.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -export type CopiedValue = string | null; -export type CopyFn = (_text: string) => Promise; - -export function useCopyToClipboard(): [CopiedValue, CopyFn] { - const [copiedText, setCopiedText] = useState(null); - - const copy: CopyFn = async (text) => { - if (!navigator?.clipboard) { - console.warn('Clipboard not supported'); - return false; - } - - // Try to save to clipboard then save it in the state if worked - try { - await navigator.clipboard.writeText(text); - setCopiedText(text); - return true; - } catch (error) { - console.warn('Copy failed', error); - setCopiedText(null); - return false; - } - }; - - return [copiedText, copy]; -} diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 4443981f8..692dfeda5 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,12 +6,9 @@ import { Edit, Pencil, Share } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; +import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link'; import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; export type DataTableActionButtonProps = { row: Document & { @@ -22,16 +19,13 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const { data: session } = useSession(); - const { toast } = useToast(); - const [, copyToClipboard] = useCopyToClipboard(); + + const { copyShareLink, isCopyingShareLink } = useCopyShareLink(); if (!session) { return null; } - const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } = - trpc.shareLink.createOrGetShareLink.useMutation(); - const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const isOwner = row.User.id === session.user.id; @@ -41,20 +35,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const onShareClick = async () => { - const { slug } = await createOrGetShareLink({ - token: recipient?.token, - documentId: row.id, - }); - - await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); - - toast({ - title: 'Copied to clipboard', - description: 'The sharing link has been copied to your clipboard.', - }); - }; - return match({ isOwner, isRecipient, @@ -80,8 +60,17 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { )) .otherwise(() => ( - )); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 666930b65..b127eede1 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -16,11 +16,11 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; +import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; -import { trpc as trpcReact } from '@documenso/trpc/react'; import { DropdownMenu, DropdownMenuContent, @@ -28,9 +28,6 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; export type DataTableActionDropdownProps = { row: Document & { @@ -41,16 +38,13 @@ export type DataTableActionDropdownProps = { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { const { data: session } = useSession(); - const { toast } = useToast(); - const [, copyToClipboard] = useCopyToClipboard(); + + const { copyShareLink, isCopyingShareLink } = useCopyShareLink(); if (!session) { return null; } - const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } = - trpcReact.shareLink.createOrGetShareLink.useMutation(); - const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const isOwner = row.User.id === session.user.id; @@ -60,20 +54,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const onShareClick = async () => { - const { slug } = await createOrGetShareLink({ - token: recipient?.token, - documentId: row.id, - }); - - await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); - - toast({ - title: 'Copied to clipboard', - description: 'The sharing link has been copied to your clipboard.', - }); - }; - const onDownloadClick = async () => { let document: DocumentWithData | null = null; @@ -159,8 +139,15 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Resend - - {isCreatingShareLink ? ( + { + void copyShareLink({ + token: recipient?.token, + documentId: row.id, + }); + }} + > + {isCopyingShareLink ? ( ) : ( diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/share-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/share-button.tsx index caa27cc50..c4eb5c76e 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/share-button.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/share-button.tsx @@ -4,6 +4,7 @@ import { HTMLAttributes, useState } from 'react'; import { Copy, Share, Twitter } from 'lucide-react'; +import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link'; import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -15,9 +16,6 @@ import { DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; export type ShareButtonProps = HTMLAttributes & { token: string; @@ -25,8 +23,7 @@ export type ShareButtonProps = HTMLAttributes & { }; export const ShareButton = ({ token, documentId }: ShareButtonProps) => { - const { toast } = useToast(); - const [, copyToClipboard] = useCopyToClipboard(); + const { copyShareLink, isCopyingShareLink } = useCopyShareLink(); const [isOpen, setIsOpen] = useState(false); @@ -48,23 +45,14 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => { }; const onCopyClick = async () => { - let { slug = '' } = shareLink || {}; + const copyToClipboardValue = shareLink + ? `${window.location.origin}/share/${shareLink.slug}` + : { + token, + documentId, + }; - if (!slug) { - const result = await createOrGetShareLink({ - token, - documentId, - }); - - slug = result.slug; - } - - await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); - - toast({ - title: 'Copied to clipboard', - description: 'The sharing link has been copied to your clipboard.', - }); + await copyShareLink(copyToClipboardValue); setIsOpen(false); }; @@ -99,9 +87,9 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => { variant="outline" disabled={!token || !documentId} className="flex-1" - loading={isLoading} + loading={isLoading || isCopyingShareLink} > - {!isLoading && } + {!isLoading && !isCopyingShareLink && } Share diff --git a/apps/web/src/hooks/use-copy-to-clipboard.ts b/apps/web/src/hooks/use-copy-to-clipboard.ts deleted file mode 100644 index d449ded16..000000000 --- a/apps/web/src/hooks/use-copy-to-clipboard.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState } from 'react'; - -export type CopiedValue = string | null; -export type CopyFn = (_text: string) => Promise; - -export function useCopyToClipboard(): [CopiedValue, CopyFn] { - const [copiedText, setCopiedText] = useState(null); - - const copy: CopyFn = async (text) => { - if (!navigator?.clipboard) { - console.warn('Clipboard not supported'); - return false; - } - - // Try to save to clipboard then save it in the state if worked - try { - await navigator.clipboard.writeText(text); - setCopiedText(text); - return true; - } catch (error) { - console.warn('Copy failed', error); - setCopiedText(null); - return false; - } - }; - - return [copiedText, copy]; -} diff --git a/packages/lib/client-only/hooks/use-copy-share-link.ts b/packages/lib/client-only/hooks/use-copy-share-link.ts new file mode 100644 index 000000000..8f5e36e05 --- /dev/null +++ b/packages/lib/client-only/hooks/use-copy-share-link.ts @@ -0,0 +1,54 @@ +import { trpc } from '@documenso/trpc/react'; +import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCopyToClipboard } from './use-copy-to-clipboard'; + +export function useCopyShareLink() { + const { toast } = useToast(); + + const [, copyToClipboard] = useCopyToClipboard(); + + const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } = + trpc.shareLink.createOrGetShareLink.useMutation(); + + /** + * Copy a share link to the user's clipboard. + * + * Will create or get a share link if one is not provided. + * + * @param payload Either the share link itself or the input to create a new share link. + */ + const copyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema | string) => { + const valueToCopy = + typeof payload === 'string' + ? payload + : createOrGetShareLink(payload).then( + (result) => `${window.location.origin}/share/${result.slug}`, + ); + + try { + const isCopySuccess = await copyToClipboard(valueToCopy); + if (!isCopySuccess) { + throw new Error('Copy to clipboard failed'); + } + + toast({ + title: 'Copied to clipboard', + description: 'The sharing link has been copied to your clipboard.', + }); + } catch { + toast({ + variant: 'destructive', + title: 'Something went wrong', + description: 'The sharing link could not be created at this time. Please try again.', + duration: 5000, + }); + } + }; + + return { + isCopyingShareLink: isCreatingShareLink, + copyShareLink, + }; +} diff --git a/packages/lib/client-only/hooks/use-copy-to-clipboard.ts b/packages/lib/client-only/hooks/use-copy-to-clipboard.ts new file mode 100644 index 000000000..eeb4805de --- /dev/null +++ b/packages/lib/client-only/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +export type CopiedValue = string | null; +export type CopyFn = (_text: CopyValue, _blobType?: string) => Promise; + +type CopyValue = Promise | string; + +export function useCopyToClipboard(): [CopiedValue, CopyFn] { + const [copiedText, setCopiedText] = useState(null); + + const copy: CopyFn = async (text, blobType = 'text/plain') => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported'); + return false; + } + + const isClipboardApiSupported = Boolean(typeof ClipboardItem && navigator.clipboard.write); + + // Try to save to clipboard then save it in the state if worked + try { + isClipboardApiSupported + ? await handleClipboardApiCopy(text, blobType) + : await handleWriteTextCopy(text); + + setCopiedText(await text); + return true; + } catch (error) { + console.warn('Copy failed', error); + setCopiedText(null); + return false; + } + }; + + /** + * Handle copying values to the clipboard using the ClipboardItem API. + * + * Allows us to copy async values for Safari. Does not work in FireFox. + * + * https://caniuse.com/mdn-api_clipboarditem + */ + const handleClipboardApiCopy = async (value: CopyValue, blobType = 'text/plain') => { + await navigator.clipboard.write([new ClipboardItem({ [blobType]: value })]); + }; + + /** + * Handle copying values to the clipboard using `writeText`. + * + * Will not work in Safari for async values. + */ + const handleWriteTextCopy = async (value: CopyValue) => { + await navigator.clipboard.writeText(await value); + }; + + return [copiedText, copy]; +}