Skip to content

Commit 0919c1d

Browse files
✨ Added ability to put a share button on posts (#26866)
ref https://linear.app/ghost/issue/FEA-480/native-share-buttons-with-link-based-referral-param-for-attribution This PR adds a new small modal component to Portal that allows a reader to more easily share a post. It fires by adding #/share on any post link. The modal lets visitors copy the link to the post and share to top social platforms. --------- Co-authored-by: Troy Ciesco <tmciesco@gmail.com>
1 parent 770d438 commit 0919c1d

91 files changed

Lines changed: 1732 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/portal/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ By default, the script adds a default floating trigger button on the bottom righ
2222

2323
Its possible to add custom trigger button of your own by adding data attribute `data-portal` to any HTML tag on page, and also specify a specific [page](https://github.com/TryGhost/Ghost/blob/main/ghost/portal/src/pages.js#L13-L22) to open from it by using it as `data-portal=signup`.
2424

25+
Share modal can be opened with `data-portal="share"` (or `#/share`).
26+
27+
Default (zero-config) usage:
28+
```html
29+
<button type="button" data-portal="share">Share</button>
30+
```
31+
32+
On pages where `{{ghost_head}}` is rendered, Portal will auto-resolve metadata from DOM tags:
33+
- URL: canonical URL (or current URL fallback)
34+
- Title: Open Graph title (or document title fallback)
35+
- Image: Open Graph image (or Twitter image fallback)
36+
37+
Troubleshooting missing preview metadata:
38+
1. Verify the template includes `{{ghost_head}}`.
39+
2. Verify rendered HTML contains canonical + OG/Twitter tags.
40+
2541
The script also adds custom class names to this element for open and close state of popup - `gh-portal-open` and `gh-portal-close`, allowing devs to update its UI based on popup state.
2642

2743
Refer the [docs](https://ghost.org/help/setup-members/#customize-portal-settings) to read about ways in which Portal can be customized for your site.

apps/portal/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/portal",
3-
"version": "2.67.12",
3+
"version": "2.68.0",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

apps/portal/src/app.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ export default class App extends React.Component {
187187
if (!linkData) {
188188
return;
189189
}
190-
191190
const {page, pageQuery, pageData} = linkData;
192191
if (this.state.initStatus === 'success') {
193192
if (page === 'gift' && !hasGiftSubscriptions({site: this.state.site})) {
@@ -657,6 +656,7 @@ export default class App extends React.Component {
657656
const offersRegex = /^offers\/(\w+?)\/?$/;
658657
const giftRedemptionRegex = /^\/portal\/gift\/redeem\/([^/?#]+)\/?$/;
659658
const linkRegex = /^\/portal\/?(?:\/(\w+(?:\/\w+)*))?\/?$/;
659+
const shareRegex = /^\/share\/?$/;
660660
const feedbackRegex = /^\/feedback\/(\w+?)\/(\w+?)\/?$/;
661661

662662
if (path && feedbackRegex.test(path)) {
@@ -705,8 +705,16 @@ export default class App extends React.Component {
705705

706706
return giftLinkData;
707707
}
708+
if (path && shareRegex.test(path)) {
709+
return {
710+
showPopup: true,
711+
page: 'share'
712+
};
713+
}
714+
708715
if (path && linkRegex.test(path)) {
709716
const [,pagePath] = path.match(linkRegex);
717+
710718
const {page, pageQuery, pageData} = this.getPageFromLinkPath(pagePath, site) || {};
711719

712720
// If user is not logged in and trying to access an account page,
@@ -1123,6 +1131,10 @@ export default class App extends React.Component {
11231131
return {
11241132
page: 'gift'
11251133
};
1134+
} else if (path === 'share') {
1135+
return {
1136+
page: 'share'
1137+
};
11261138
} else if (path === 'account/newsletters/help') {
11271139
return {
11281140
page: 'emailReceivingFAQ',
@@ -1288,7 +1300,7 @@ export default class App extends React.Component {
12881300
* portal. Especially useful for copy/pasted links from Admin screens.
12891301
*/
12901302
transformPortalLinksToRelative() {
1291-
document.querySelectorAll('a[href*="#/portal"]').forEach(transformPortalAnchorToRelative);
1303+
document.querySelectorAll('a[href*="#/portal"], a[href*="#/share"]').forEach(transformPortalAnchorToRelative);
12921304
}
12931305

12941306
render() {

apps/portal/src/components/frame.styles.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {GiftRedemptionStyles} from './pages/gift-redemption-page';
2525
import {GiftSuccessStyle} from './pages/gift-success-page';
2626
import {TipsAndDonationsErrorStyle} from './pages/support-error';
2727
import {RecommendationsPageStyles} from './pages/recommendations-page';
28+
import {ShareModalStyles} from './pages/share/share-modal.styles';
2829
import {TransistorPodcastsActionStyles} from './pages/AccountHomePage/components/transistor-podcasts-action';
2930
import NotificationStyle from './notification.styles';
3031

@@ -1318,6 +1319,7 @@ export function getFrameStyles({site}) {
13181319
TipsAndDonationsErrorStyle +
13191320
GiftSuccessStyle +
13201321
RecommendationsPageStyles +
1322+
ShareModalStyles +
13211323
TransistorPodcastsActionStyles;
13221324
return FrameStyle;
13231325
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import CloseButton from '../../common/close-button';
2+
import copyTextToClipboard from '../../../utils/copy-to-clipboard';
3+
import useShareData from './use-share-data';
4+
import {ReactComponent as BlueSkyIcon} from '../../../images/icons/share-bluesky.svg';
5+
import {ReactComponent as CheckmarkIcon} from '../../../images/icons/checkmark.svg';
6+
import {ReactComponent as EnvelopeIcon} from '../../../images/icons/envelope.svg';
7+
import {ReactComponent as EllipsisIcon} from '../../../images/icons/ellipsis.svg';
8+
import {ReactComponent as FacebookIcon} from '../../../images/icons/share-facebook.svg';
9+
import {ReactComponent as LinkIcon} from '../../../images/icons/share-link.svg';
10+
import {ReactComponent as LinkedinIcon} from '../../../images/icons/share-linkedin.svg';
11+
import {ReactComponent as ThreadsIcon} from '../../../images/icons/share-threads.svg';
12+
import {ReactComponent as XIcon} from '../../../images/icons/share-x.svg';
13+
import {useEffect, useRef, useState} from 'react';
14+
import {t} from '../../../utils/i18n';
15+
16+
const ShareModal = () => {
17+
const [copied, setCopied] = useState(false);
18+
const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false);
19+
const copyTimeoutRef = useRef();
20+
const moreMenuRef = useRef(null);
21+
22+
const {shareUrl, shareTitle, shareExcerpt, shareImage, shareFavicon, shareSiteName, shareAuthor, socialLinks} = useShareData();
23+
24+
useEffect(() => {
25+
return () => {
26+
clearTimeout(copyTimeoutRef.current);
27+
};
28+
}, []);
29+
30+
useEffect(() => {
31+
if (!isMoreMenuOpen) {
32+
return;
33+
}
34+
35+
const onDocumentMouseDown = (event) => {
36+
if (moreMenuRef.current && !moreMenuRef.current.contains(event.target)) {
37+
setIsMoreMenuOpen(false);
38+
}
39+
};
40+
41+
const onDocumentKeyDown = (event) => {
42+
if (event.key === 'Escape') {
43+
setIsMoreMenuOpen(false);
44+
}
45+
};
46+
47+
document.addEventListener('mousedown', onDocumentMouseDown);
48+
document.addEventListener('keydown', onDocumentKeyDown);
49+
50+
return () => {
51+
document.removeEventListener('mousedown', onDocumentMouseDown);
52+
document.removeEventListener('keydown', onDocumentKeyDown);
53+
};
54+
}, [isMoreMenuOpen]);
55+
56+
const onCopy = async () => {
57+
const copySuccess = await copyTextToClipboard(shareUrl);
58+
if (!copySuccess) {
59+
return;
60+
}
61+
62+
setCopied(true);
63+
clearTimeout(copyTimeoutRef.current);
64+
copyTimeoutRef.current = setTimeout(() => {
65+
setCopied(false);
66+
}, 2000);
67+
};
68+
69+
const onToggleMoreMenu = () => {
70+
setIsMoreMenuOpen(isOpen => !isOpen);
71+
};
72+
73+
const onClickMoreItem = () => {
74+
setIsMoreMenuOpen(false);
75+
};
76+
77+
return (
78+
<div className='gh-portal-content gh-portal-share'>
79+
<CloseButton />
80+
<div className='gh-portal-share-header'>
81+
<h1 className='gh-portal-main-title'>{t('Share')}</h1>
82+
</div>
83+
84+
<div className='gh-portal-share-preview'>
85+
{shareImage && <img className='gh-portal-share-preview-image' src={shareImage} alt='' data-testid='share-preview-image' />}
86+
<div className='gh-portal-share-preview-content'>
87+
{shareTitle && <h2 className='gh-portal-share-preview-title'>{shareTitle}</h2>}
88+
{shareExcerpt && <p className='gh-portal-share-preview-excerpt'>{shareExcerpt}</p>}
89+
{(shareFavicon || shareSiteName || shareAuthor) && (
90+
<div className='gh-portal-share-preview-footer'>
91+
{shareFavicon && (
92+
<img
93+
className='gh-portal-share-preview-favicon'
94+
src={shareFavicon}
95+
alt=''
96+
data-testid='share-preview-favicon'
97+
/>
98+
)}
99+
<div className='gh-portal-share-preview-meta'>
100+
{shareSiteName && <span className='gh-portal-share-preview-site'>{shareSiteName}</span>}
101+
{shareAuthor && (
102+
<span className='gh-portal-share-preview-author'>
103+
{shareSiteName ? `| ${shareAuthor}` : shareAuthor}
104+
</span>
105+
)}
106+
</div>
107+
</div>
108+
)}
109+
</div>
110+
</div>
111+
112+
<div className='gh-portal-share-actions'>
113+
<button
114+
className='gh-portal-btn gh-portal-share-action copy'
115+
type='button'
116+
onClick={onCopy}
117+
aria-label={copied ? t('Copied') : t('Copy link')}
118+
title={copied ? t('Copied') : t('Copy link')}
119+
>
120+
{copied ? (
121+
<span className='gh-portal-share-icon copied' aria-hidden='true'>
122+
<CheckmarkIcon />
123+
</span>
124+
) : (
125+
<span className='gh-portal-share-icon' aria-hidden='true'>
126+
<LinkIcon />
127+
</span>
128+
)}
129+
<span className='gh-portal-share-label'>{copied ? t('Copied') : t('Copy link')}</span>
130+
</button>
131+
132+
<a
133+
className='gh-portal-btn gh-portal-share-action twitter'
134+
href={socialLinks.twitter}
135+
target='_blank'
136+
rel='noopener noreferrer'
137+
aria-label={t('X (Twitter)')}
138+
title={t('X (Twitter)')}
139+
>
140+
<span className='gh-portal-share-icon x' aria-hidden='true'>
141+
<XIcon />
142+
</span>
143+
</a>
144+
145+
<a
146+
className='gh-portal-btn gh-portal-share-action linkedin'
147+
href={socialLinks.linkedin}
148+
target='_blank'
149+
rel='noopener noreferrer'
150+
aria-label={t('LinkedIn')}
151+
title={t('LinkedIn')}
152+
>
153+
<span className='gh-portal-share-icon' aria-hidden='true'>
154+
<LinkedinIcon />
155+
</span>
156+
</a>
157+
158+
<a
159+
className='gh-portal-btn gh-portal-share-action email'
160+
href={socialLinks.email}
161+
target='_blank'
162+
rel='noopener noreferrer'
163+
aria-label={t('Email')}
164+
title={t('Email')}
165+
>
166+
<span className='gh-portal-share-icon' aria-hidden='true'>
167+
<EnvelopeIcon />
168+
</span>
169+
</a>
170+
171+
<div className='gh-portal-share-more' ref={moreMenuRef}>
172+
<button
173+
className='gh-portal-btn gh-portal-share-action more'
174+
type='button'
175+
onClick={onToggleMoreMenu}
176+
aria-label={t('More options')}
177+
title={t('More options')}
178+
aria-haspopup='menu'
179+
aria-expanded={isMoreMenuOpen}
180+
>
181+
<span className='gh-portal-share-icon' aria-hidden='true'>
182+
<EllipsisIcon />
183+
</span>
184+
</button>
185+
{isMoreMenuOpen && (
186+
<div className='gh-portal-share-more-menu' role='menu' aria-label={t('More options')}>
187+
<a
188+
className='gh-portal-share-more-item'
189+
href={socialLinks.facebook}
190+
target='_blank'
191+
rel='noopener noreferrer'
192+
role='menuitem'
193+
onClick={onClickMoreItem}
194+
>
195+
<span className='gh-portal-share-more-item-icon' aria-hidden='true'>
196+
<FacebookIcon />
197+
</span>
198+
<span>{t('Facebook')}</span>
199+
</a>
200+
<a
201+
className='gh-portal-share-more-item'
202+
href={socialLinks.threads}
203+
target='_blank'
204+
rel='noopener noreferrer'
205+
role='menuitem'
206+
onClick={onClickMoreItem}
207+
>
208+
<span className='gh-portal-share-more-item-icon' aria-hidden='true'>
209+
<ThreadsIcon />
210+
</span>
211+
<span>{t('Threads')}</span>
212+
</a>
213+
<a
214+
className='gh-portal-share-more-item'
215+
href={socialLinks.bluesky}
216+
target='_blank'
217+
rel='noopener noreferrer'
218+
role='menuitem'
219+
onClick={onClickMoreItem}
220+
>
221+
<span className='gh-portal-share-more-item-icon' aria-hidden='true'>
222+
<BlueSkyIcon />
223+
</span>
224+
<span>{t('Bluesky')}</span>
225+
</a>
226+
</div>
227+
)}
228+
</div>
229+
</div>
230+
</div>
231+
);
232+
};
233+
234+
export default ShareModal;

0 commit comments

Comments
 (0)