diff --git a/.changeset/bold-quotes-post.md b/.changeset/bold-quotes-post.md
new file mode 100644
index 00000000..c278ca61
--- /dev/null
+++ b/.changeset/bold-quotes-post.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Add a `content` prop to `TumblrShareButton` and `buildTumblrShareUrl`, and list the other Tumblr post types for `posttype`. For post types other than `link`, `content` carries the actual post data, such as the quote text. Based on #564 by @me4502.
diff --git a/.changeset/kind-mails-arrive.md b/.changeset/kind-mails-arrive.md
new file mode 100644
index 00000000..50b829db
--- /dev/null
+++ b/.changeset/kind-mails-arrive.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Add a `to` prop to `EmailShareButton` and `buildEmailShareUrl` for prefilling the recipient. It accepts one address or an array of addresses. Requested in #418.
diff --git a/.changeset/open-new-tabs.md b/.changeset/open-new-tabs.md
new file mode 100644
index 00000000..307ce8fd
--- /dev/null
+++ b/.changeset/open-new-tabs.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Add an `openInNewTab` prop to popup-based share buttons, and a matching `newTab` option for `openShareWindow`, to open the share page in a regular browser tab instead of a popup window. Requested in #449 and proposed in #342.
diff --git a/.changeset/purple-casts-share.md b/.changeset/purple-casts-share.md
new file mode 100644
index 00000000..d45891c3
--- /dev/null
+++ b/.changeset/purple-casts-share.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Add `FarcasterShareButton`, `FarcasterIcon`, `FarcasterLegacyIcon`, and `buildFarcasterShareUrl` for sharing to Farcaster (formerly Warpcast) through its compose intent. The shared URL is attached as an embed, and `text`, `channelKey`, and `parentCastHash` are supported. Requested in #558.
diff --git a/.changeset/quiet-posts-text.md b/.changeset/quiet-posts-text.md
new file mode 100644
index 00000000..fc79d147
--- /dev/null
+++ b/.changeset/quiet-posts-text.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Make `url` optional on `XShareButton` and `buildXShareUrl` so text-only posts can be shared by passing only `text`. At least one of `url` or `text` is still required. Requested in #570.
diff --git a/.changeset/warm-phones-ring.md b/.changeset/warm-phones-ring.md
new file mode 100644
index 00000000..0fb9a2b1
--- /dev/null
+++ b/.changeset/warm-phones-ring.md
@@ -0,0 +1,5 @@
+---
+'react-share': minor
+---
+
+Add a `phone` prop to `WhatsappShareButton` and `buildWhatsappShareUrl` for sharing to a specific number. It takes the number in international format and removes everything except digits. It applies to both the web and the app link. Requested in #359.
diff --git a/README.md b/README.md
index c0b00716..1004ccf5 100644
--- a/README.md
+++ b/README.md
@@ -74,31 +74,32 @@ See every button in action in the [deployed demo](https://npm-react-share-demo.n
## Supported networks
-| Network | Button | Icon | Network-specific props |
-| ------------------ | --------------------------------------------------------------- | ----------------------- | -------------------------------------------------- |
-| Bluesky | [`BlueskyShareButton`](#blueskysharebutton) | `BlueskyIcon` | `text`, `separator` |
-| Email | [`EmailShareButton`](#emailsharebutton) | `EmailIcon` | `subject`, `body`, `separator` |
-| Facebook | [`FacebookShareButton`](#facebooksharebutton) | `FacebookIcon` | `hashtag` |
-| Facebook Messenger | [`FacebookMessengerShareButton`](#facebookmessengersharebutton) | `FacebookMessengerIcon` | **`appId`**, `redirectUri`, `to`, `mobileBehavior` |
-| Gab | [`GabShareButton`](#gabsharebutton) | `GabIcon` | `text` |
-| Hatena | [`HatenaShareButton`](#hatenasharebutton) | `HatenaIcon` | `shareTitle` |
-| Instapaper | [`InstapaperShareButton`](#instapapersharebutton) | `InstapaperIcon` | `shareTitle`, `description` |
-| Line | [`LineShareButton`](#linesharebutton) | `LineIcon` | `text` |
-| LinkedIn | [`LinkedinShareButton`](#linkedinsharebutton) | `LinkedinIcon` | – |
-| LiveJournal | [`LivejournalShareButton`](#livejournalsharebutton) | `LivejournalIcon` | `shareTitle`, `description` |
-| Mail.Ru | [`MailruShareButton`](#mailrusharebutton) | `MailruIcon` | `shareTitle`, `description`, `imageUrl` |
-| Odnoklassniki | [`OKShareButton`](#oksharebutton) | `OKIcon` | `shareTitle`, `description`, `image` |
-| Pinterest | [`PinterestShareButton`](#pinterestsharebutton) | `PinterestIcon` | **`media`**, `description`, `pinId` |
-| Reddit | [`RedditShareButton`](#redditsharebutton) | `RedditIcon` | `shareTitle` |
-| Telegram | [`TelegramShareButton`](#telegramsharebutton) | `TelegramIcon` | `text`, `mobileBehavior` |
-| Threads | [`ThreadsShareButton`](#threadssharebutton) | `ThreadsIcon` | `text` |
-| Tumblr | [`TumblrShareButton`](#tumblrsharebutton) | `TumblrIcon` | `postTitle`, `tags`, `caption`, `posttype` |
-| Viber | [`ViberShareButton`](#vibersharebutton) | `ViberIcon` | `message`, `separator`, `mobileBehavior` |
-| VK | [`VKShareButton`](#vksharebutton) | `VKIcon` | `shareTitle`, `image`, `noParse`, `noVkLinks` |
-| Weibo | [`WeiboShareButton`](#weibosharebutton) | `WeiboIcon` | `shareTitle`, `image` |
-| WhatsApp | [`WhatsappShareButton`](#whatsappsharebutton) | `WhatsappIcon` | `message`, `separator`, `mobileBehavior` |
-| X | [`XShareButton`](#xsharebutton) | `XIcon` | `text`, `via`, `hashtags`, `related` |
-| Device share sheet | [`NativeShareButton`](#nativesharebutton) | `NativeShareIcon` | `title`, `text`, `url`, `files` |
+| Network | Button | Icon | Network-specific props |
+| ------------------ | --------------------------------------------------------------- | ----------------------- | ----------------------------------------------------- |
+| Bluesky | [`BlueskyShareButton`](#blueskysharebutton) | `BlueskyIcon` | `text`, `separator` |
+| Email | [`EmailShareButton`](#emailsharebutton) | `EmailIcon` | `to`, `subject`, `body`, `separator` |
+| Facebook | [`FacebookShareButton`](#facebooksharebutton) | `FacebookIcon` | `hashtag` |
+| Facebook Messenger | [`FacebookMessengerShareButton`](#facebookmessengersharebutton) | `FacebookMessengerIcon` | **`appId`**, `redirectUri`, `to`, `mobileBehavior` |
+| Farcaster | [`FarcasterShareButton`](#farcastersharebutton) | `FarcasterIcon` | `text`, `channelKey`, `parentCastHash` |
+| Gab | [`GabShareButton`](#gabsharebutton) | `GabIcon` | `text` |
+| Hatena | [`HatenaShareButton`](#hatenasharebutton) | `HatenaIcon` | `shareTitle` |
+| Instapaper | [`InstapaperShareButton`](#instapapersharebutton) | `InstapaperIcon` | `shareTitle`, `description` |
+| Line | [`LineShareButton`](#linesharebutton) | `LineIcon` | `text` |
+| LinkedIn | [`LinkedinShareButton`](#linkedinsharebutton) | `LinkedinIcon` | – |
+| LiveJournal | [`LivejournalShareButton`](#livejournalsharebutton) | `LivejournalIcon` | `shareTitle`, `description` |
+| Mail.Ru | [`MailruShareButton`](#mailrusharebutton) | `MailruIcon` | `shareTitle`, `description`, `imageUrl` |
+| Odnoklassniki | [`OKShareButton`](#oksharebutton) | `OKIcon` | `shareTitle`, `description`, `image` |
+| Pinterest | [`PinterestShareButton`](#pinterestsharebutton) | `PinterestIcon` | **`media`**, `description`, `pinId` |
+| Reddit | [`RedditShareButton`](#redditsharebutton) | `RedditIcon` | `shareTitle` |
+| Telegram | [`TelegramShareButton`](#telegramsharebutton) | `TelegramIcon` | `text`, `mobileBehavior` |
+| Threads | [`ThreadsShareButton`](#threadssharebutton) | `ThreadsIcon` | `text` |
+| Tumblr | [`TumblrShareButton`](#tumblrsharebutton) | `TumblrIcon` | `postTitle`, `tags`, `caption`, `posttype`, `content` |
+| Viber | [`ViberShareButton`](#vibersharebutton) | `ViberIcon` | `phone`, `message`, `separator`, `mobileBehavior` |
+| VK | [`VKShareButton`](#vksharebutton) | `VKIcon` | `shareTitle`, `image`, `noParse`, `noVkLinks` |
+| Weibo | [`WeiboShareButton`](#weibosharebutton) | `WeiboIcon` | `shareTitle`, `image` |
+| WhatsApp | [`WhatsappShareButton`](#whatsappsharebutton) | `WhatsappIcon` | `message`, `separator`, `mobileBehavior` |
+| X | [`XShareButton`](#xsharebutton) | `XIcon` | `text`, `via`, `hashtags`, `related` |
+| Device share sheet | [`NativeShareButton`](#nativesharebutton) | `NativeShareIcon` | `title`, `text`, `url`, `files` |
Required props are in **bold**. Every icon also has a badge-style `*LegacyIcon` variant (see [Icons](#icons)), and every network button has a matching `build*ShareUrl` helper (see [Building share URLs yourself](#building-share-urls-yourself)).
@@ -139,6 +140,7 @@ These props apply to every network share button. `NativeShareButton` has its [ow
| `onShareWindowClose` | `() => void` | – | Called after the popup closes. |
| `windowWidth` | `number` | – | Overrides the popup width. |
| `windowHeight` | `number` | – | Overrides the popup height. |
+| `openInNewTab` | `boolean` | `false` | Opens the share page in a regular browser tab instead of a popup window. The `window*` props are ignored. |
| `windowPosition` | `"windowCenter" \| "screenCenter"` | – | Controls popup positioning. |
| `mobileBehavior` | `"web" \| "app" \| "auto"` | `"web"` | Only on `FacebookMessengerShareButton`, `TelegramShareButton`, `ViberShareButton`, and `WhatsappShareButton`. See [Mobile app handoff](#mobile-app-handoff). |
@@ -185,11 +187,12 @@ import { BlueskyShareButton, BlueskyIcon } from "react-share";
#### EmailShareButton
-| Prop | Type | Default | Description |
-| ----------- | -------- | ------- | ----------------------------------------------------------------------------- |
-| `subject` | `string` | – | Email subject line. |
-| `body` | `string` | – | Text prepended before the shared URL. |
-| `separator` | `string` | `"\n"` | Separator between the body and URL. The default puts the URL on its own line. |
+| Prop | Type | Default | Description |
+| ----------- | -------------------- | ------- | ----------------------------------------------------------------------------- |
+| `to` | `string \| string[]` | – | Recipient address, or several addresses. |
+| `subject` | `string` | – | Email subject line. |
+| `body` | `string` | – | Text prepended before the shared URL. |
+| `separator` | `string` | `"\n"` | Separator between the body and URL. The default puts the URL on its own line. |
- Uses `mailto:` navigation instead of opening a popup.
- Does not expose `openShareDialogOnClick` or `onClick`.
@@ -260,6 +263,30 @@ import { FacebookMessengerShareButton, FacebookMessengerIcon } from "react-share
+#### FarcasterShareButton
+
+| Prop | Type | Default | Description |
+| ---------------- | -------- | ------- | --------------------------------------------------- |
+| `text` | `string` | – | Text of the cast. |
+| `channelKey` | `string` | – | Channel to post the cast to, such as `"farcaster"`. |
+| `parentCastHash` | `string` | – | Hash of a cast to reply to. |
+
+- The shared `url` is attached to the cast as an embed.
+- Opens the [Farcaster compose intent](https://docs.farcaster.xyz/reference/farcaster/intent-urls) (formerly Warpcast).
+
+
+Example
+
+```jsx
+import { FarcasterShareButton, FarcasterIcon } from "react-share";
+
+
+
+;
+```
+
+
+
#### GabShareButton
| Prop | Type | Default | Description |
@@ -532,12 +559,13 @@ import { ThreadsShareButton, ThreadsIcon } from "react-share";
#### TumblrShareButton
-| Prop | Type | Default | Description |
-| ----------- | ---------- | -------- | --------------------------------------- |
-| `postTitle` | `string` | – | Title sent in the Tumblr post payload. |
-| `tags` | `string[]` | – | Tags to attach to the post. |
-| `caption` | `string` | – | Description shown with the shared page. |
-| `posttype` | `string` | `"link"` | Tumblr post type. |
+| Prop | Type | Default | Description |
+| ----------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `postTitle` | `string` | – | Title sent in the Tumblr post payload. |
+| `tags` | `string[]` | – | Tags to attach to the post. |
+| `caption` | `string` | – | Description shown with the shared page. |
+| `posttype` | `string` | `"link"` | Tumblr post type, such as `"quote"`. |
+| `content` | `string` | – | Post content for post types other than `"link"`. See [Tumblr's documentation](https://help.tumblr.com/knowledge-base/share-button-documentation/). |
Example
@@ -642,6 +670,7 @@ import { WeiboShareButton, WeiboIcon } from "react-share";
| Prop | Type | Default | Description |
| ---------------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------- |
+| `phone` | `string` | – | Recipient phone number in international format. Non-digits are removed. |
| `message` | `string` | – | Message to share before the URL. |
| `separator` | `string` | `" "` | Separator between the message and URL. |
| `mobileBehavior` | `"web" \| "app" \| "auto"` | `"web"` | `"app"` uses a WhatsApp app link and `"auto"` uses it only on detected mobile environments. |
@@ -668,12 +697,13 @@ import { WhatsappShareButton, WhatsappIcon } from "react-share";
#### XShareButton
-| Prop | Type | Default | Description |
-| ---------- | ---------- | ------- | ---------------------------------- |
-| `text` | `string` | – | Text to share before the URL. |
-| `via` | `string` | – | Account to attribute the share to. |
-| `hashtags` | `string[]` | – | Hashtags without the leading `#`. |
-| `related` | `string[]` | – | Related accounts to recommend. |
+| Prop | Type | Default | Description |
+| ---------- | ---------- | ------- | ------------------------------------------------------------- |
+| `url` | `string` | – | Optional for X. Set `text` instead to share a text-only post. |
+| `text` | `string` | – | Text to share before the URL. |
+| `via` | `string` | – | Account to attribute the share to. |
+| `hashtags` | `string[]` | – | Hashtags without the leading `#`. |
+| `related` | `string[]` | – | Related accounts to recommend. |
- X can ignore or rewrite some share fields.
@@ -808,6 +838,44 @@ const url = buildXShareUrl(shareUrl, {
});
openShareWindow(url, { width: 550, height: 400 });
+
+// or open a regular browser tab instead of a popup window
+openShareWindow(url, { newTab: true });
+```
+
+### Custom networks
+
+For a network the library does not cover, build its share URL yourself and open it with `openShareWindow`:
+
+```jsx
+import { openShareWindow } from "react-share";
+
+;
+```
+
+### Preparing the share data on click
+
+Share buttons build their link from props at click time. If you need to fetch something first, such as a short link or a token, open the popup immediately so popup blockers allow it, then load the URL once it is ready:
+
+```jsx
+import { buildXShareUrl, openShareWindow } from "react-share";
+
+async function share() {
+ const popup = openShareWindow("about:blank");
+
+ try {
+ const shortUrl = await createShortLink(shareUrl);
+
+ popup?.location.replace(buildXShareUrl(shortUrl, { text: "Read this next" }));
+ } catch {
+ popup?.close();
+ }
+}
```
## Accessibility
diff --git a/demo/Demo.tsx b/demo/Demo.tsx
index e45daee4..700b0ad6 100644
--- a/demo/Demo.tsx
+++ b/demo/Demo.tsx
@@ -8,6 +8,8 @@ import {
FacebookMessengerIcon,
FacebookMessengerShareButton,
FacebookShareButton,
+ FarcasterIcon,
+ FarcasterShareButton,
GabIcon,
GabShareButton,
HatenaIcon,
@@ -194,6 +196,19 @@ export function Demo() {
+
+
+
+
+
+
+
diff --git a/src/EmailShareButton.tsx b/src/EmailShareButton.tsx
index a3cf3c59..dd539bef 100644
--- a/src/EmailShareButton.tsx
+++ b/src/EmailShareButton.tsx
@@ -6,19 +6,35 @@ type Options = {
body?: string;
separator?: string;
subject?: string;
+ /** Recipient address, or several addresses. */
+ to?: string | string[];
};
-type EmailShareButtonProps = Omit, 'onClick' | 'openShareDialogOnClick'> &
+type EmailShareButtonProps = Omit<
+ ShareButtonProps,
+ 'onClick' | 'openInNewTab' | 'openShareDialogOnClick'
+> &
Options;
-function emailLink(url: string, { subject, body, separator = '\n' }: Options) {
- return 'mailto:' + objectToGetParams({ subject, body: body ? body + separator + url : url });
+function emailRecipients(to: Options['to']) {
+ return (Array.isArray(to) ? to : [to])
+ .filter(Boolean)
+ .map(address => encodeURIComponent(address as string).replace(/%40/g, '@'))
+ .join(',');
+}
+
+function emailLink(url: string, { subject, body, separator = '\n', to }: Options) {
+ return (
+ 'mailto:' +
+ emailRecipients(to) +
+ objectToGetParams({ subject, body: body ? body + separator + url : url })
+ );
}
export const buildEmailShareUrl = emailLink;
export const EmailShareButton = forwardRef(
- ({ body, separator, subject, ...props }, ref) => (
+ ({ body, separator, subject, to, ...props }, ref) => (
),
diff --git a/src/FarcasterIcon.tsx b/src/FarcasterIcon.tsx
new file mode 100644
index 00000000..c94feddc
--- /dev/null
+++ b/src/FarcasterIcon.tsx
@@ -0,0 +1,14 @@
+import { RawIconBase, type RawIconProps } from './icons/RawIconBase';
+
+export function FarcasterIcon(props: RawIconProps) {
+ return (
+ }
+ color="#855DCD"
+ viewBox="0 0 24 24"
+ {...props}
+ >
+
+
+ );
+}
diff --git a/src/FarcasterLegacyIcon.tsx b/src/FarcasterLegacyIcon.tsx
new file mode 100644
index 00000000..7f979263
--- /dev/null
+++ b/src/FarcasterLegacyIcon.tsx
@@ -0,0 +1,11 @@
+import { LegacyIconBase, type LegacyIconProps } from './icons/LegacyIconBase';
+
+export function FarcasterLegacyIcon(props: LegacyIconProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/FarcasterShareButton.tsx b/src/FarcasterShareButton.tsx
new file mode 100644
index 00000000..96431752
--- /dev/null
+++ b/src/FarcasterShareButton.tsx
@@ -0,0 +1,50 @@
+import { forwardRef } from 'react';
+import { assert } from './utils/assert';
+import { objectToGetParams } from './utils/objectToGetParams';
+import { ShareButton, type ShareButtonProps } from './ShareButton';
+
+type FarcasterOptions = {
+ channelKey?: string;
+ parentCastHash?: string;
+ text?: string;
+};
+
+type FarcasterShareButtonProps = Omit, 'title'> &
+ FarcasterOptions;
+
+function farcasterLink(url: string, { channelKey, parentCastHash, text }: FarcasterOptions) {
+ assert(url, 'farcaster.url');
+
+ return (
+ 'https://farcaster.xyz/~/compose' +
+ objectToGetParams({
+ text,
+ 'embeds[]': url,
+ channelKey,
+ parentCastHash,
+ }).replace('embeds%5B%5D=', 'embeds[]=')
+ );
+}
+
+export const buildFarcasterShareUrl = farcasterLink;
+
+export const FarcasterShareButton = forwardRef(
+ ({ channelKey, parentCastHash, text, ...props }, ref) => (
+
+ ),
+);
+
+FarcasterShareButton.displayName = 'FarcasterShareButton';
diff --git a/src/ShareButton.tsx b/src/ShareButton.tsx
index db7d4843..72e5ec1f 100644
--- a/src/ShareButton.tsx
+++ b/src/ShareButton.tsx
@@ -12,6 +12,7 @@ const DEFAULT_ARIA_LABELS = {
email: 'Share by email',
facebook: 'Share on Facebook',
facebookmessenger: 'Share in Messenger',
+ farcaster: 'Share on Farcaster',
gab: 'Share on Gab',
hatena: 'Share on Hatena',
instapaper: 'Save to Instapaper',
@@ -71,6 +72,12 @@ export interface Props extends NativeButtonProps {
*/
onShareWindowOpen?: (shareWindow: Window | null) => void;
openShareDialogOnClick?: boolean;
+ /**
+ * Opens the share page in a regular browser tab instead of a popup window.
+ * `windowWidth`, `windowHeight`, and `windowPosition` are ignored in that case.
+ * @default false
+ */
+ openInNewTab?: boolean;
opts: LinkOptions;
resetButtonStyle?: boolean;
/**
@@ -104,6 +111,7 @@ export function ShareButton>({
onClick,
onShareWindowClose,
onShareWindowOpen,
+ openInNewTab = false,
openShareDialogOnClick = true,
opts,
resetButtonStyle = true,
@@ -136,6 +144,7 @@ export function ShareButton>({
const opensPopup = openShareDialogOnClick && linkBehavior === 'popup';
const windowOptions = {
height: windowHeight,
+ newTab: openInNewTab,
onClose: onShareWindowClose,
position: windowPosition,
width: windowWidth,
diff --git a/src/TumblrShareButton.tsx b/src/TumblrShareButton.tsx
index 4bd77251..4de8e0bb 100644
--- a/src/TumblrShareButton.tsx
+++ b/src/TumblrShareButton.tsx
@@ -3,6 +3,8 @@ import { assert } from './utils/assert';
import { objectToGetParams } from './utils/objectToGetParams';
import { ShareButton, type ShareButtonProps } from './ShareButton';
+type TumblrPostType = 'link' | 'text' | 'quote' | 'photo' | 'chat' | 'video' | (string & {});
+
function tumblrLink(
url: string,
{
@@ -10,7 +12,14 @@ function tumblrLink(
caption,
tags,
posttype,
- }: { postTitle?: string; caption?: string; tags?: string; posttype?: 'link' | string },
+ content,
+ }: {
+ postTitle?: string;
+ caption?: string;
+ tags?: string;
+ posttype?: TumblrPostType;
+ content?: string;
+ },
) {
assert(url, 'tumblr.url');
@@ -22,6 +31,7 @@ function tumblrLink(
caption,
tags,
posttype,
+ content,
})
);
}
@@ -31,14 +41,16 @@ export const buildTumblrShareUrl = tumblrLink;
type Options = {
postTitle?: string;
caption?: string;
- posttype?: 'link' | string;
+ /** The post content for post types other than `link`, such as the quote text or photo URL. */
+ content?: string;
+ posttype?: TumblrPostType;
};
type TumblrShareButtonProps = Omit, 'title'> &
Options & { tags?: string[] };
export const TumblrShareButton = forwardRef(
- ({ caption, postTitle, posttype, tags, ...props }, ref) => (
+ ({ caption, content, postTitle, posttype, tags, ...props }, ref) => (
,
- 'title'
-> & {
+type WhatsappOptions = { message?: string; phone?: string; separator?: string };
+
+type WhatsappShareButtonProps = Omit, 'title'> & {
message?: string;
mobileBehavior?: MobileBehavior;
+ /** Recipient phone number in international format, for example `+358401234567`. */
+ phone?: string;
separator?: string;
};
-function whatsappLink(
- url: string,
- { message, separator = ' ' }: { message?: string; separator?: string },
-) {
+function whatsappPhone(phone: string | undefined) {
+ return phone?.replace(/\D/g, '') || undefined;
+}
+
+function whatsappLink(url: string, { message, phone, separator = ' ' }: WhatsappOptions) {
assert(url, 'whatsapp.url');
return (
'https://api.whatsapp.com/send' +
objectToGetParams({
+ phone: whatsappPhone(phone),
text: message ? message + separator + url : url,
})
);
@@ -28,21 +31,19 @@ function whatsappLink(
export const buildWhatsappShareUrl = whatsappLink;
-function whatsappAppLink(
- url: string,
- { message, separator = ' ' }: { message?: string; separator?: string },
-) {
+function whatsappAppLink(url: string, { message, phone, separator = ' ' }: WhatsappOptions) {
assert(url, 'whatsapp.url');
return (
'whatsapp://send' +
objectToGetParams({
+ phone: whatsappPhone(phone),
text: message ? message + separator + url : url,
})
);
}
export const WhatsappShareButton = forwardRef(
- ({ message, mobileBehavior, separator, ...props }, ref) => {
+ ({ message, mobileBehavior, phone, separator, ...props }, ref) => {
const { linkBehavior, networkLink } = resolveMobileShareStrategy(
mobileBehavior,
buildWhatsappShareUrl,
@@ -58,6 +59,7 @@ export const WhatsappShareButton = forwardRef 0 ? hashtags.join(',') : undefined,
@@ -37,8 +37,10 @@ type XShareButtonProps = Omit<
hashtags?: string[];
related?: string[];
}>,
- 'title'
+ 'title' | 'url'
> & {
+ /** URL of the shared page. Optional when `text` is set, for text-only posts. */
+ url?: string;
text?: string;
via?: string;
hashtags?: string[];
@@ -46,10 +48,11 @@ type XShareButtonProps = Omit<
};
export const XShareButton = forwardRef(
- ({ hashtags, related, text, via, ...props }, ref) => (
+ ({ hashtags, related, text, url, via, ...props }, ref) => (
void;
position?: WindowPosition;
width?: number;
@@ -20,7 +22,13 @@ const getBoxPositionOnScreenCenter = (width: number, height: number) => ({
export function openShareWindow(
url: string,
- { height = 400, onClose, position = 'windowCenter', width = 550 }: ShareWindowOptions = {},
+ {
+ height = 400,
+ newTab = false,
+ onClose,
+ position = 'windowCenter',
+ width = 550,
+ }: ShareWindowOptions = {},
) {
const config: { [key: string]: string | number } = {
height,
@@ -39,13 +47,15 @@ export function openShareWindow(
: getBoxPositionOnScreenCenter(width, height)),
};
- const shareDialog = window.open(
- url,
- '',
- Object.keys(config)
- .map(key => `${key}=${config[key]}`)
- .join(', '),
- );
+ const shareDialog = newTab
+ ? window.open(url, '_blank')
+ : window.open(
+ url,
+ '',
+ Object.keys(config)
+ .map(key => `${key}=${config[key]}`)
+ .join(', '),
+ );
if (onClose) {
const interval = window.setInterval(() => {
diff --git a/test/EmailShareButton.test.tsx b/test/EmailShareButton.test.tsx
index 08d89d61..0cbeb4eb 100644
--- a/test/EmailShareButton.test.tsx
+++ b/test/EmailShareButton.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
-import { EmailShareButton } from '../src/EmailShareButton';
+import { EmailShareButton, buildEmailShareUrl } from '../src/EmailShareButton';
describe('EmailShareButton', () => {
it('navigates to a mailto link instead of opening a popup', () => {
@@ -57,4 +57,44 @@ describe('EmailShareButton', () => {
expect(hrefState.value).toBe('mailto:?subject=Subject&body=https%3A%2F%2Fexample.com');
});
+
+ it('adds one or more recipients to the mailto link', () => {
+ expect(
+ buildEmailShareUrl('https://example.com', { subject: 'Hi', to: 'you@example.com' }),
+ ).toBe('mailto:you@example.com?subject=Hi&body=https%3A%2F%2Fexample.com');
+ expect(
+ buildEmailShareUrl('https://example.com', { to: ['a@example.com', 'b@example.com'] }),
+ ).toBe('mailto:a@example.com,b@example.com?body=https%3A%2F%2Fexample.com');
+ });
+
+ it('ignores an empty recipient', () => {
+ expect(buildEmailShareUrl('https://example.com', { to: [] })).toBe(
+ 'mailto:?body=https%3A%2F%2Fexample.com',
+ );
+ });
+
+ it('passes the `to` prop through to the mailto link', () => {
+ const hrefState = { value: 'https://example.test/' };
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ get href() {
+ return hrefState.value;
+ },
+ set href(value: string) {
+ hrefState.value = value;
+ },
+ },
+ });
+
+ render(
+
+ Share by email
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share by email' }));
+
+ expect(hrefState.value).toBe('mailto:you@example.com?body=https%3A%2F%2Fexample.com');
+ });
});
diff --git a/test/NetworkShareButtons.test.tsx b/test/NetworkShareButtons.test.tsx
index 646feed9..bc55e0d5 100644
--- a/test/NetworkShareButtons.test.tsx
+++ b/test/NetworkShareButtons.test.tsx
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { BlueskyShareButton } from '../src/BlueskyShareButton';
import { FacebookShareButton } from '../src/FacebookShareButton';
+import { FarcasterShareButton } from '../src/FarcasterShareButton';
import { GabShareButton } from '../src/GabShareButton';
import { HatenaShareButton } from '../src/HatenaShareButton';
import { InstapaperShareButton } from '../src/InstapaperShareButton';
@@ -46,6 +47,17 @@ const shareButtonCases: ShareButtonCase[] = [
url: 'https://example.com',
},
},
+ {
+ children: 'Share on Farcaster',
+ Component: FarcasterShareButton,
+ expectedUrl:
+ 'https://farcaster.xyz/~/compose?text=Example%20title&embeds[]=https%3A%2F%2Fexample.com&channelKey=farcaster',
+ props: {
+ channelKey: 'farcaster',
+ text: 'Example title',
+ url: 'https://example.com',
+ },
+ },
{
children: 'Share on Gab',
Component: GabShareButton,
diff --git a/test/ShareButton.test.tsx b/test/ShareButton.test.tsx
index 9feebe3d..560073eb 100644
--- a/test/ShareButton.test.tsx
+++ b/test/ShareButton.test.tsx
@@ -38,6 +38,60 @@ describe('ShareButton', () => {
expect(onClick).toHaveBeenCalledWith(expect.any(Object), 'https://example.com/share');
});
+ it('opens a regular tab when openInNewTab is set', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+
+ render(
+ url}
+ networkName="test"
+ openInNewTab
+ opts={{}}
+ url="https://example.com"
+ >
+ Share
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }));
+
+ expect(openSpy).toHaveBeenCalledTimes(1);
+ expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank');
+ });
+
+ it('pre-opens a tab for async beforeOnClick when openInNewTab is set', async () => {
+ let resolveBeforeOnClick = () => {};
+ const shareWindow = { close: vi.fn(), closed: false, location: { replace: vi.fn() } };
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(shareWindow as unknown as Window);
+
+ render(
+
+ new Promise(resolve => {
+ resolveBeforeOnClick = resolve;
+ })
+ }
+ networkLink={(url: string) => url}
+ networkName="test"
+ openInNewTab
+ opts={{}}
+ url="https://example.com"
+ >
+ Share
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }));
+
+ expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank');
+
+ resolveBeforeOnClick();
+
+ await waitFor(() =>
+ expect(shareWindow.location.replace).toHaveBeenCalledWith('https://example.com'),
+ );
+ });
+
it('waits for beforeOnClick before invoking onClick', async () => {
const steps: string[] = [];
const onClick = vi.fn(() => {
diff --git a/test/ShareButtonComponents.test.tsx b/test/ShareButtonComponents.test.tsx
index 3fef20f5..890e0621 100644
--- a/test/ShareButtonComponents.test.tsx
+++ b/test/ShareButtonComponents.test.tsx
@@ -1,6 +1,6 @@
import React, { createRef } from 'react';
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
import { EmailIcon } from '../src/EmailIcon';
import { EmailShareButton } from '../src/EmailShareButton';
@@ -63,6 +63,27 @@ describe('share button components', () => {
expect(button).not.toHaveAttribute('posttype');
});
+ it('sends the Tumblr content field for non-link post types', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+
+ render(
+
+ Share on Tumblr
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Share on Tumblr' });
+
+ fireEvent.click(button);
+
+ expect(button).not.toHaveAttribute('content');
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://www.tumblr.com/widgets/share/tool?canonicalUrl=https%3A%2F%2Fexample.com&tags=&posttype=quote&content=The%20quote',
+ '',
+ expect.any(String),
+ );
+ });
+
it('adds the email fallback label for icon-only buttons', () => {
render(
diff --git a/test/ShareHelpers.test.ts b/test/ShareHelpers.test.ts
index c652db86..a634d8cf 100644
--- a/test/ShareHelpers.test.ts
+++ b/test/ShareHelpers.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
buildBlueskyShareUrl,
buildEmailShareUrl,
+ buildFarcasterShareUrl,
buildFacebookShareUrl,
buildLinkedinShareUrl,
buildViberShareUrl,
@@ -43,6 +44,11 @@ describe('share helpers', () => {
() => buildBlueskyShareUrl('https://example.com', { text: 'Hello' }),
'https://bsky.app/intent/compose?text=Hello%20https%3A%2F%2Fexample.com',
],
+ [
+ 'Farcaster',
+ () => buildFarcasterShareUrl('https://example.com', { parentCastHash: '0xabc', text: 'Hi' }),
+ 'https://farcaster.xyz/~/compose?text=Hi&embeds[]=https%3A%2F%2Fexample.com&parentCastHash=0xabc',
+ ],
[
'Email',
() => buildEmailShareUrl('https://example.com', { body: 'Hello' }),
@@ -97,4 +103,12 @@ describe('share helpers', () => {
expect.stringContaining('left=500'),
);
});
+
+ it('opens a regular tab instead of a popup when newTab is set', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+
+ openShareWindow('https://example.com/share', { newTab: true });
+
+ expect(openSpy).toHaveBeenCalledWith('https://example.com/share', '_blank');
+ });
});
diff --git a/test/WhatsappShareButton.test.tsx b/test/WhatsappShareButton.test.tsx
index 2855de4e..e8289d12 100644
--- a/test/WhatsappShareButton.test.tsx
+++ b/test/WhatsappShareButton.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
-import { WhatsappShareButton } from '../src/WhatsappShareButton';
+import { WhatsappShareButton, buildWhatsappShareUrl } from '../src/WhatsappShareButton';
describe('WhatsappShareButton', () => {
function stubLocation() {
@@ -79,4 +79,41 @@ describe('WhatsappShareButton', () => {
expect.any(String),
);
});
+
+ it('addresses the share to a phone number, keeping digits only', () => {
+ expect(
+ buildWhatsappShareUrl('https://example.com', { phone: '+358 40-123 4567', message: 'Hi' }),
+ ).toBe('https://api.whatsapp.com/send?phone=358401234567&text=Hi%20https%3A%2F%2Fexample.com');
+ });
+
+ it('passes the `phone` prop to the web and app links', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ const hrefState = stubLocation();
+
+ const { rerender } = render(
+
+ Share to WhatsApp
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share to WhatsApp' }));
+
+ expect(openSpy).toHaveBeenLastCalledWith(
+ 'https://api.whatsapp.com/send?phone=358401234567&text=https%3A%2F%2Fexample.com',
+ '',
+ expect.any(String),
+ );
+
+ rerender(
+
+ Share to WhatsApp
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share to WhatsApp' }));
+
+ expect(hrefState.value).toBe(
+ 'whatsapp://send?phone=358401234567&text=https%3A%2F%2Fexample.com',
+ );
+ });
});
diff --git a/test/XShareButton.test.tsx b/test/XShareButton.test.tsx
index 2cee7294..8b334591 100644
--- a/test/XShareButton.test.tsx
+++ b/test/XShareButton.test.tsx
@@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { XIcon } from '../src/XIcon';
-import { XShareButton } from '../src/XShareButton';
+import { XShareButton, buildXShareUrl } from '../src/XShareButton';
describe('XShareButton', () => {
it('builds a share url with hashtags and related accounts', () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
@@ -76,6 +76,24 @@ describe('XShareButton', () => {
);
});
+ it('shares text only when no url is given', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+
+ render(Share on X);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share on X' }));
+
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://x.com/intent/post?text=Just%20text',
+ '',
+ expect.any(String),
+ );
+ });
+
+ it('throws when neither url nor text is given', () => {
+ expect(() => buildXShareUrl(undefined, {})).toThrow('x.url or x.text');
+ });
+
it('applies the default X aria-label to icon-only buttons', () => {
render(