feat: add safari clipboard copy support (#486)

This commit is contained in:
David Nguyen
2023-10-17 12:40:36 +11:00
committed by GitHub
parent 8d2e50d1fe
commit 4b09693862
8 changed files with 163 additions and 67 deletions

View File

@@ -6,9 +6,12 @@ import { Edit, Pencil, Share } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -21,16 +24,18 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
if (!session) { if (!session) {
return null; return null;
} }
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id; const isOwner = row.User.id === session.user.id;
@@ -40,20 +45,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
return match({ return match({
isOwner, isOwner,
isRecipient, isRecipient,
@@ -79,8 +70,17 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button> </Button>
)) ))
.otherwise(() => ( .otherwise(() => (
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}> <Button
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />} className="w-24"
loading={isCopyingShareLink}
onClick={async () =>
createAndCopyShareLink({
token: recipient?.token,
documentId: row.id,
})
}
>
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share Share
</Button> </Button>
)); ));

View File

@@ -18,12 +18,15 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -44,8 +47,13 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -53,9 +61,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
return null; return null;
} }
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpcReact.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id; const isOwner = row.User.id === session.user.id;
@@ -66,20 +71,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT; const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; let document: DocumentWithData | null = null;
@@ -165,8 +156,16 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Resend Resend
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={isDraft} onClick={onShareClick}> <DropdownMenuItem
{isCreatingShareLink ? ( disabled={isDraft}
onClick={async () =>
createAndCopyShareLink({
token: recipient?.token,
documentId: row.id,
})
}
>
{isCopyingShareLink ? (
<Loader className="mr-2 h-4 w-4" /> <Loader className="mr-2 h-4 w-4" />
) : ( ) : (
<Share className="mr-2 h-4 w-4" /> <Share className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,53 @@
import { trpc } from '@documenso/trpc/react';
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import { useCopyToClipboard } from './use-copy-to-clipboard';
export type UseCopyShareLinkOptions = {
onSuccess?: () => void;
onError?: () => void;
};
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
*
* @param payload The payload to create or get a share link.
*/
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
const valueToCopy = createOrGetShareLink(payload).then(
(result) => `${window.location.origin}/share/${result.slug}`,
);
await copyShareLink(valueToCopy);
};
/**
* Copy a share link to the user's clipboard.
*
* @param shareLink Either the share link itself or a promise that returns a shared link.
*/
const copyShareLink = async (shareLink: Promise<string> | string) => {
try {
const isCopySuccess = await copyToClipboard(shareLink);
if (!isCopySuccess) {
throw new Error('Copy to clipboard failed');
}
onSuccess?.();
} catch (e) {
onError?.();
}
};
return {
createAndCopyShareLink,
copyShareLink,
isCopyingShareLink: isCreatingShareLink,
};
}

View File

@@ -1,21 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
export type CopiedValue = string | null; export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>; export type CopyFn = (_text: CopyValue, _blobType?: string) => Promise<boolean>;
type CopyValue = Promise<string> | string;
export function useCopyToClipboard(): [CopiedValue, CopyFn] { export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null); const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => { const copy: CopyFn = async (text, blobType = 'text/plain') => {
if (!navigator?.clipboard) { if (!navigator?.clipboard) {
console.warn('Clipboard not supported'); console.warn('Clipboard not supported');
return false; return false;
} }
const isClipboardApiSupported = Boolean(typeof ClipboardItem && navigator.clipboard.write);
// Try to save to clipboard then save it in the state if worked // Try to save to clipboard then save it in the state if worked
try { try {
await navigator.clipboard.writeText(text); isClipboardApiSupported
setCopiedText(text); ? await handleClipboardApiCopy(text, blobType)
: await handleWriteTextCopy(text);
setCopiedText(await text);
return true; return true;
} catch (error) { } catch (error) {
console.warn('Copy failed', error); console.warn('Copy failed', error);
@@ -24,5 +31,30 @@ export function useCopyToClipboard(): [CopiedValue, CopyFn] {
} }
}; };
/**
* Handle copying values to the clipboard using the ClipboardItem API.
*
* Works in all browsers except FireFox.
*
* https://caniuse.com/mdn-api_clipboarditem
*/
const handleClipboardApiCopy = async (value: CopyValue, blobType = 'text/plain') => {
try {
await navigator.clipboard.write([new ClipboardItem({ [blobType]: value })]);
} catch (e) {
// Fallback attempt.
await handleWriteTextCopy(value);
}
};
/**
* Handle copying values to the clipboard using `writeText`.
*
* Works in all browsers except Safari for async values.
*/
const handleWriteTextCopy = async (value: CopyValue) => {
await navigator.clipboard.writeText(await value);
};
return [copiedText, copy]; return [copiedText, copy];
} }

View File

@@ -0,0 +1,13 @@
import { Toast } from '@documenso/ui/primitives/use-toast';
export const TOAST_DOCUMENT_SHARE_SUCCESS: Toast = {
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
} as const;
export const TOAST_DOCUMENT_SHARE_ERROR: Toast = {
variant: 'destructive',
title: 'Something went wrong',
description: 'The sharing link could not be created at this time. Please try again.',
duration: 5000,
};

View File

@@ -2,7 +2,7 @@ import { prisma } from '@documenso/prisma';
export type DeleteUserOptions = { export type DeleteUserOptions = {
email: string; email: string;
} };
export const deleteUser = async ({ email }: DeleteUserOptions) => { export const deleteUser = async ({ email }: DeleteUserOptions) => {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({

View File

@@ -5,7 +5,11 @@ import { HTMLAttributes, useState } from 'react';
import { Copy, Share } from 'lucide-react'; import { Copy, Share } from 'lucide-react';
import { FaXTwitter } from 'react-icons/fa6'; import { FaXTwitter } from 'react-icons/fa6';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent'; import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -27,7 +31,11 @@ export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => { export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const { copyShareLink, createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -49,24 +57,15 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
}; };
const onCopyClick = async () => { const onCopyClick = async () => {
let { slug = '' } = shareLink || {}; if (shareLink) {
await copyShareLink(`${window.location.origin}/share/${shareLink.slug}`);
if (!slug) { } else {
const result = await createOrGetShareLink({ await createAndCopyShareLink({
token, token,
documentId, documentId,
}); });
slug = result.slug;
} }
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
setIsOpen(false); setIsOpen(false);
}; };
@@ -100,9 +99,9 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
variant="outline" variant="outline"
disabled={!token || !documentId} disabled={!token || !documentId}
className={cn('flex-1', className)} className={cn('flex-1', className)}
loading={isLoading} loading={isLoading || isCopyingShareLink}
> >
{!isLoading && <Share className="mr-2 h-5 w-5" />} {!isLoading && !isCopyingShareLink && <Share className="mr-2 h-5 w-5" />}
Share Share
</Button> </Button>
</DialogTrigger> </DialogTrigger>

View File

@@ -133,7 +133,7 @@ function dispatch(action: Action) {
}); });
} }
type Toast = Omit<ToasterToast, 'id'>; export type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId(); const id = genId();