feat: add safari clipboard copy support
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
|
||||||
|
|
||||||
export type PasswordRevealProps = {
|
export type PasswordRevealProps = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export type CopiedValue = string | null;
|
|
||||||
export type CopyFn = (_text: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
|
||||||
const [copiedText, setCopiedText] = useState<CopiedValue>(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];
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,9 @@ 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 { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||||
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 { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -22,16 +19,13 @@ 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 [, copyToClipboard] = useCopyToClipboard();
|
const { copyShareLink, isCopyingShareLink } = useCopyShareLink();
|
||||||
|
|
||||||
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;
|
||||||
@@ -41,20 +35,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(`${window.location.origin}/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,
|
||||||
@@ -80,8 +60,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={() => {
|
||||||
|
void copyShareLink({
|
||||||
|
token: recipient?.token,
|
||||||
|
documentId: row.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/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 { 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,
|
||||||
@@ -28,9 +28,6 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} 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 = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -41,16 +38,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 [, copyToClipboard] = useCopyToClipboard();
|
const { copyShareLink, isCopyingShareLink } = useCopyShareLink();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
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;
|
||||||
@@ -60,20 +54,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
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(`${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 () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
@@ -159,8 +139,15 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Resend
|
Resend
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onShareClick}>
|
<DropdownMenuItem
|
||||||
{isCreatingShareLink ? (
|
onClick={() => {
|
||||||
|
void copyShareLink({
|
||||||
|
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" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HTMLAttributes, useState } from 'react';
|
|||||||
|
|
||||||
import { Copy, Share, Twitter } from 'lucide-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 { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -15,9 +16,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} 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<HTMLButtonElement> & {
|
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -25,8 +23,7 @@ export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { copyShareLink, isCopyingShareLink } = useCopyShareLink();
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -48,23 +45,14 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCopyClick = async () => {
|
const onCopyClick = async () => {
|
||||||
let { slug = '' } = shareLink || {};
|
const copyToClipboardValue = shareLink
|
||||||
|
? `${window.location.origin}/share/${shareLink.slug}`
|
||||||
|
: {
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
};
|
||||||
|
|
||||||
if (!slug) {
|
await copyShareLink(copyToClipboardValue);
|
||||||
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.',
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
@@ -99,9 +87,9 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!token || !documentId}
|
disabled={!token || !documentId}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
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>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export type CopiedValue = string | null;
|
|
||||||
export type CopyFn = (_text: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
|
||||||
const [copiedText, setCopiedText] = useState<CopiedValue>(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];
|
|
||||||
}
|
|
||||||
54
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
54
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
55
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
55
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type CopiedValue = string | null;
|
||||||
|
export type CopyFn = (_text: CopyValue, _blobType?: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
type CopyValue = Promise<string> | string;
|
||||||
|
|
||||||
|
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
||||||
|
const [copiedText, setCopiedText] = useState<CopiedValue>(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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user