mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-24 12:57:55 +02:00
fix(sharing): copy share link to clipboard from within the user gesture (#2135)
The share URL is only known after the create-share request resolves, so the
previous navigator.clipboard.writeText() in the mutation onSuccess callback ran
outside the originating click's user activation. Firefox and Safari reject such
writes ('Clipboard write was blocked due to lack of user activation'), so the
link was never copied (and the toast still claimed success).
Issue clipboard.write() synchronously inside the submit gesture with a
ClipboardItem whose text/plain value is a promise resolving to the share URL,
which preserves the user activation while the share is created. Falls back to
writeText, and finally to the existing 'click to open' toast when the clipboard
is unavailable or blocked, with the toast text reflecting whether the copy
actually succeeded.
Co-authored-by: ilusha <ilusha.basic@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,18 @@ export const ShareItemContextModal = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
shareItemMutation.mutate(
|
const canUseClipboard = Boolean(navigator.clipboard) && window.isSecureContext;
|
||||||
{
|
|
||||||
|
// The share URL only exists once the create request resolves. Calling
|
||||||
|
// navigator.clipboard.writeText() from that async callback runs outside
|
||||||
|
// the click's user activation, so Firefox/Safari reject it ("Clipboard
|
||||||
|
// write was blocked due to lack of user activation") and nothing is
|
||||||
|
// copied. Instead, call clipboard.write() synchronously within this
|
||||||
|
// gesture with a ClipboardItem whose value is a promise that resolves to
|
||||||
|
// the URL — this preserves the activation while the share is created.
|
||||||
|
// Falls back to writeText, then to the "click to open" toast.
|
||||||
|
const shareUrlPromise = shareItemMutation
|
||||||
|
.mutateAsync({
|
||||||
apiClientProps: { serverId: server?.id || '' },
|
apiClientProps: { serverId: server?.id || '' },
|
||||||
body: {
|
body: {
|
||||||
description: values.description,
|
description: values.description,
|
||||||
@@ -53,35 +63,51 @@ export const ShareItemContextModal = ({
|
|||||||
resourceIds: itemIds.join(),
|
resourceIds: itemIds.join(),
|
||||||
resourceType,
|
resourceType,
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
{
|
.then((data) => {
|
||||||
onError: () => {
|
|
||||||
toast.error({
|
|
||||||
message: t('form.shareItem.createFailed'),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (_data) => {
|
|
||||||
if (!server) throw new Error('Server not found');
|
if (!server) throw new Error('Server not found');
|
||||||
if (!_data?.id) throw new Error('Failed to share item');
|
if (!data?.id) throw new Error('Failed to share item');
|
||||||
|
|
||||||
const serverUrl = getServerUrl(server, true);
|
const serverUrl = getServerUrl(server, true);
|
||||||
if (!serverUrl) throw new Error('Server URL not found');
|
if (!serverUrl) throw new Error('Server URL not found');
|
||||||
const shareUrl = `${serverUrl}/share/${_data.id}`;
|
return `${serverUrl}/share/${data.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
const canUseClipboard = navigator.clipboard && window.isSecureContext;
|
let copied = false;
|
||||||
if (canUseClipboard) {
|
if (canUseClipboard) {
|
||||||
navigator.clipboard.writeText(shareUrl);
|
try {
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/plain': shareUrlPromise.then(
|
||||||
|
(url) => new Blob([url], { type: 'text/plain' }),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(await shareUrlPromise);
|
||||||
|
}
|
||||||
|
copied = true;
|
||||||
|
} catch {
|
||||||
|
copied = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shareUrl: string;
|
||||||
|
try {
|
||||||
|
shareUrl = await shareUrlPromise;
|
||||||
|
} catch {
|
||||||
|
toast.error({
|
||||||
|
message: t('form.shareItem.createFailed'),
|
||||||
|
});
|
||||||
|
closeModal(id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success({
|
toast.success({
|
||||||
autoClose: canUseClipboard ? 5000 : 15000,
|
autoClose: copied ? 5000 : 15000,
|
||||||
id: 'share-item-toast',
|
id: 'share-item-toast',
|
||||||
message: t(
|
message: t(copied ? 'form.shareItem.success' : 'form.shareItem.successMustClick', {}),
|
||||||
canUseClipboard
|
|
||||||
? 'form.shareItem.success'
|
|
||||||
: 'form.shareItem.successMustClick',
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
onClick: (a) => {
|
onClick: (a) => {
|
||||||
if (!(a.target instanceof HTMLElement)) return;
|
if (!(a.target instanceof HTMLElement)) return;
|
||||||
|
|
||||||
@@ -92,12 +118,8 @@ export const ShareItemContextModal = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
closeModal(id);
|
closeModal(id);
|
||||||
return null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user