feat: add safari clipboard copy support (#486)
This commit is contained in:
@@ -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>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/lib/constants/toast.ts
Normal file
13
packages/lib/constants/toast.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user