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() { +
+ + + +
+
+ @@ -11,15 +11,16 @@ - - - - - - - - - - - + + + + + + + + + + + + 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(