diff --git a/dotcom-rendering/.storybook/preview.ts b/dotcom-rendering/.storybook/preview.ts index 678a597e431..ed1b9394e85 100644 --- a/dotcom-rendering/.storybook/preview.ts +++ b/dotcom-rendering/.storybook/preview.ts @@ -24,6 +24,9 @@ import { palette as sourcePalette } from '@guardian/source/foundations'; sb.mock(import('../src/lib/useNewsletterSubscription.ts'), { spy: true }); sb.mock(import('../src/lib/useAuthStatus.ts'), { spy: true }); sb.mock(import('../src/lib/fetchEmail.ts'), { spy: true }); +sb.mock(import('../src/lib/useIsMyGuardianEnabled.ts'), { spy: true }); +sb.mock(import('../src/lib/useIsBridgetCompatible.ts'), { spy: true }); +sb.mock(import('../src/lib/bridgetApi.ts'), { spy: true }); // Prevent components being lazy rendered when we're taking Chromatic snapshots Lazy.disabled = isChromatic(); diff --git a/dotcom-rendering/src/components/ContributorFollowBlock.importable.tsx b/dotcom-rendering/src/components/ContributorFollowBlock.importable.tsx new file mode 100644 index 00000000000..f2399bb0c4e --- /dev/null +++ b/dotcom-rendering/src/components/ContributorFollowBlock.importable.tsx @@ -0,0 +1,303 @@ +/** + * There is a lot of repeated code between this component and FollowWrapper. + * This is intentional so that we can implement it as an A/B test. + * We will clean this up once the test is complete. + */ +import { css } from '@emotion/react'; +import { Topic } from '@guardian/bridget/Topic'; +import { isUndefined, log } from '@guardian/libs'; +import { space, textSans12, textSans15 } from '@guardian/source/foundations'; +import { + Button, + SvgCheckmark, + SvgNotificationsOn, + SvgPlus, +} from '@guardian/source/react-components'; +import { + StraightLines, + ToggleSwitch, +} from '@guardian/source-development-kitchen/react-components'; +import { useEffect, useState } from 'react'; +import { getNotificationsClient, getTagClient } from '../lib/bridgetApi'; +import { useIsBridgetCompatible } from '../lib/useIsBridgetCompatible'; +import { useIsMyGuardianEnabled } from '../lib/useIsMyGuardianEnabled'; +import { palette } from '../palette'; +import { isNotInBlockList } from './FollowWrapper.importable'; + +// -- Follow button -- + +type FollowButtonProps = { + isFollowing: boolean; + onClickHandler: () => void; +}; + +const FollowButtonPillStyle = ({ + isFollowing, + onClickHandler, +}: FollowButtonProps) => { + return ( + + ); +}; + +// -- notifications -- + +const notificationAlertStyles = css` + ${textSans15} + color: ${palette('--follow-text')}; + min-height: ${space[6]}px; + width: 100%; +`; + +const notificationAlertRowStyles = css` + display: flex; + column-gap: ${space[6]}px; + justify-content: space-between; + width: 100%; +`; + +const notificationIconStyles = css` + display: flex; + margin-right: ${space[1]}px; + + svg { + margin-top: -${space[1] - 1}px; + fill: currentColor; + } +`; +const notificationLabelStyles = css` + display: flex; + align-items: flex-start; + ${textSans12} +`; + +const NotificationAlert = ({ + isFollowing, + onClickHandler, + displayName, +}: FollowButtonProps & { displayName?: string }) => { + return ( +
+
+
+
+ +
+ + Receive an alert when {displayName ?? 'this author'}{' '} + publishes an article + +
+
+ +
+
+
+ ); +}; + +const followBlockStyles = css` + display: flex; + flex-direction: column; + width: 100%; + align-items: flex-start; +`; + +type FollowBlockProps = { + contributorId: string; + displayName: string; +}; + +export const ContributorFollowBlock = ({ + contributorId, + displayName, +}: FollowBlockProps) => { + const [isFollowingNotifications, setIsFollowingNotifications] = useState< + boolean | undefined + >(undefined); + const [isFollowingContributor, setIsFollowingContributor] = useState< + boolean | undefined + >(undefined); + + const isMyGuardianEnabled = useIsMyGuardianEnabled(); + const isBridgetCompatible = useIsBridgetCompatible('2.5.0'); + + const shouldShowFollow = + isBridgetCompatible && + isMyGuardianEnabled && + isNotInBlockList(contributorId); + + useEffect(() => { + const topic = new Topic({ + id: contributorId, + displayName, + type: 'tag-contributor', + }); + + void getNotificationsClient() + .isFollowing(topic) + .then(setIsFollowingNotifications) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'bridget-getNotificationsClient-isFollowing-error', + ); + log( + 'dotcom', + 'Bridget getNotificationsClient.isFollowing Error:', + error, + ); + }); + + void getTagClient() + .isFollowing(topic) + .then(setIsFollowingContributor) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'bridget-getTagClient-isFollowing-error', + ); + log('dotcom', 'Bridget getTagClient.isFollowing Error:', error); + }); + }, [contributorId, displayName]); + + const tagHandler = () => { + const topic = new Topic({ + id: contributorId, + displayName, + type: 'tag-contributor', + }); + + if (isFollowingContributor) { + void getTagClient() + .unfollow(topic) + .then((success) => { + if (success) { + setIsFollowingContributor(false); + } + }) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'bridget-getTagClient-unfollow-error', + ); + log( + 'dotcom', + 'Bridget getTagClient.unfollow Error:', + error, + ); + }); + } else { + void getTagClient() + .follow(topic) + .then((success) => { + if (success) { + setIsFollowingContributor(true); + } + }) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'bridget-getTagClient-follow-error', + ); + log('dotcom', 'Bridget getTagClient.follow Error:', error); + }); + } + }; + + const notificationsHandler = () => { + const topic = new Topic({ + id: contributorId, + displayName, + type: 'tag-contributor', + }); + + if (isFollowingNotifications) { + void getNotificationsClient() + .unfollow(topic) + .then((success) => { + if (success) { + setIsFollowingNotifications(false); + } + }) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'briidget-getNotificationsClient-unfollow-error', + ); + log( + 'dotcom', + 'Bridget getNotificationsClient.unfollow Error:', + error, + ); + }); + } else { + void getNotificationsClient() + .follow(topic) + .then((success) => { + if (success) { + setIsFollowingNotifications(true); + } + }) + .catch((error) => { + window.guardian.modules.sentry.reportError( + error, + 'bridget-getNotificationsClient-follow-error', + ); + log( + 'dotcom', + 'Bridget getNotificationsClient.follow Error:', + error, + ); + }); + } + }; + + if (!shouldShowFollow) { + return null; + } + + return ( +
+ undefined + } + /> + {isFollowingContributor && ( +
+ + undefined + } + displayName={displayName} + /> +
+ )} +
+ ); +}; diff --git a/dotcom-rendering/src/components/ContributorFollowBlockComponent.importable.tsx b/dotcom-rendering/src/components/ContributorFollowBlockComponent.importable.tsx new file mode 100644 index 00000000000..5045b17a521 --- /dev/null +++ b/dotcom-rendering/src/components/ContributorFollowBlockComponent.importable.tsx @@ -0,0 +1,128 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold17, + space, + textEgyptian14, +} from '@guardian/source/foundations'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import sanitise from 'sanitize-html'; +import { palette } from '../palette'; +import { Avatar } from './Avatar'; +import { ContributorFollowBlock } from './ContributorFollowBlock.importable'; +import { Island } from './Island'; + +type Props = { + contributorId: string; + displayName: string; + avatarUrl?: string; + bio?: string; +}; + +const contributorBlockStyles = css` + display: flex; + flex-direction: column; + padding: ${space[2]}px ${space[2]}px ${space[2]}px 0; +`; + +const topRowStyles = css` + display: flex; + gap: ${space[3]}px; + margin: ${space[2]}px ${space[3]}px ${space[4]}px 0; +`; + +const avatarContainerStyles = css` + width: 60px; + height: 60px; + flex-shrink: 0; + + ${from.tablet} { + width: 80px; + height: 80px; + } +`; + +const nameAndBioStyles = css` + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +`; + +const titleStyles = css` + ${headlineBold17}; + color: ${palette('--contributor-follow-fill')}; + margin: 0 0 ${space[2]}px; +`; + +const bioStyles = css` + ${textEgyptian14}; + font-weight: 500; + color: ${palette('--contributor-follow-bio-text')}; + margin: 0; +`; + +const followButtonContainerStyles = css` + display: flex; + margin-bottom: ${space[4]}px; +`; + +const containsText = (html: string) => { + const htmlWithoutTags = sanitise(html, { + allowedTags: [], + allowedAttributes: {}, + }); + return htmlWithoutTags.length > 0; +}; + +export const ContributorFollowBlockComponent = ({ + contributorId, + displayName, + avatarUrl, + bio, +}: Props) => { + const hasBio = bio !== undefined && containsText(bio); + const sanitizedBio = hasBio ? sanitise(bio, {}) : undefined; + + return ( +
+ +
+ {!!avatarUrl && ( +
+ +
+ )} +
+

{displayName}

+ {!!sanitizedBio && ( +
+ )} +
+
+
+ + + +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/ContributorFollowBlockComponent.stories.tsx b/dotcom-rendering/src/components/ContributorFollowBlockComponent.stories.tsx new file mode 100644 index 00000000000..de7c695987a --- /dev/null +++ b/dotcom-rendering/src/components/ContributorFollowBlockComponent.stories.tsx @@ -0,0 +1,114 @@ +import { css } from '@emotion/react'; +import { mocked } from 'storybook/test'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { getNotificationsClient, getTagClient } from '../lib/bridgetApi'; +import { useIsBridgetCompatible } from '../lib/useIsBridgetCompatible'; +import { useIsMyGuardianEnabled } from '../lib/useIsMyGuardianEnabled'; +import { ContributorFollowBlockComponent } from './ContributorFollowBlockComponent.importable'; + +const opinionFormat = { + display: ArticleDisplay.Standard, + design: ArticleDesign.Comment, + theme: Pillar.Opinion, +}; + +export default { + component: ContributorFollowBlockComponent, + title: 'Components/ContributorFollowBlockComponent', + async beforeEach() { + mocked(useIsMyGuardianEnabled).mockReturnValue(true); + mocked(useIsBridgetCompatible).mockReturnValue(true); + }, + parameters: { + formats: [opinionFormat], + }, +}; + +const contributorData = { + contributorId: 'profile/george-monbiot', + displayName: 'George Monbiot', + avatarUrl: + 'https://i.guim.co.uk/img/uploads/2017/10/06/George-Monbiot,-L.png?width=300&quality=85&auto=format&fit=max&s=b4786b498dac53ea736ef05160a2a172', + bio: '

George Monbiot is a Guardian columnist and author of The Invisible Doctrine: The Secret History of Neoliberalism

', +}; + +const containerStyles = css` + max-width: 620px; + padding: 16px; +`; + +const mockBridgetClients = ( + isFollowingTag: boolean, + isFollowingNotifications: boolean, +) => { + mocked(getTagClient).mockReturnValue({ + isFollowing: () => Promise.resolve(isFollowingTag), + follow: () => Promise.resolve(true), + unfollow: () => Promise.resolve(true), + } as unknown as ReturnType); + mocked(getNotificationsClient).mockReturnValue({ + isFollowing: () => Promise.resolve(isFollowingNotifications), + follow: () => Promise.resolve(true), + unfollow: () => Promise.resolve(true), + } as unknown as ReturnType); +}; + +const ContributorFollowBlockStory = () => ( +
+ +
+); + +export const NotFollowing = () => ; +NotFollowing.storyName = 'Not Following'; +NotFollowing.beforeEach = () => { + mockBridgetClients(false, false); +}; + +export const Following = () => ; +Following.storyName = 'Following'; +Following.beforeEach = () => { + mockBridgetClients(true, false); +}; + +export const FollowingWithNotifications = () => ; +FollowingWithNotifications.storyName = 'Following with Notifications'; +FollowingWithNotifications.beforeEach = () => { + mockBridgetClients(true, true); +}; + +export const AllPillars = () => ; +AllPillars.storyName = 'All Pillars'; +AllPillars.parameters = { + formats: [ + opinionFormat, + { + display: ArticleDisplay.Standard, + design: ArticleDesign.Comment, + theme: Pillar.News, + }, + { + display: ArticleDisplay.Standard, + design: ArticleDesign.Comment, + theme: Pillar.Sport, + }, + { + display: ArticleDisplay.Standard, + design: ArticleDesign.Comment, + theme: Pillar.Culture, + }, + { + display: ArticleDisplay.Standard, + design: ArticleDesign.Comment, + theme: Pillar.Lifestyle, + }, + ], +}; +AllPillars.beforeEach = () => { + mockBridgetClients(false, false); +}; diff --git a/dotcom-rendering/src/components/FollowWrapper.importable.tsx b/dotcom-rendering/src/components/FollowWrapper.importable.tsx index 7208609214c..7bed3a45498 100644 --- a/dotcom-rendering/src/components/FollowWrapper.importable.tsx +++ b/dotcom-rendering/src/components/FollowWrapper.importable.tsx @@ -13,6 +13,11 @@ type Props = { displayName: string; }; +export const isNotInBlockList = (tagId: string) => { + const blockList = ['profile/anas-al-sharif']; + return !blockList.includes(tagId); +}; + export const FollowWrapper = ({ id, displayName }: Props) => { const [isFollowingNotifications, setIsFollowingNotifications] = useState< boolean | undefined @@ -26,11 +31,6 @@ export const FollowWrapper = ({ id, displayName }: Props) => { const isMyGuardianEnabled = useIsMyGuardianEnabled(); const isBridgetCompatible = useIsBridgetCompatible('2.5.0'); - const isNotInBlockList = (tagId: string) => { - const blockList = ['profile/anas-al-sharif']; - return !blockList.includes(tagId); - }; - if (isBridgetCompatible && isMyGuardianEnabled && isNotInBlockList(id)) { setShowFollowTagButton(true); } diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 372abaf0599..2f9ed2febed 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -109,6 +109,9 @@ "type": "string" } } + }, + "bio": { + "type": "string" } }, "required": [ @@ -629,6 +632,9 @@ { "$ref": "#/definitions/ContentAtomBlockElement" }, + { + "$ref": "#/definitions/ContributorFollowBlockElement" + }, { "$ref": "#/definitions/DisclaimerBlockElement" }, @@ -1864,6 +1870,36 @@ "elementId" ] }, + "ContributorFollowBlockElement": { + "type": "object", + "properties": { + "_type": { + "type": "string", + "const": "model.dotcomrendering.pageElements.ContributorFollowBlockElement" + }, + "elementId": { + "type": "string" + }, + "contributorId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + } + }, + "required": [ + "_type", + "contributorId", + "displayName", + "elementId" + ] + }, "DisclaimerBlockElement": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 4c7ae4fa39d..ca8bd495407 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -9,6 +9,7 @@ import { CartoonComponent } from '../components/CartoonComponent'; import { ChartAtom } from '../components/ChartAtom.importable'; import { CodeBlockComponent } from '../components/CodeBlockComponent'; import { CommentBlockComponent } from '../components/CommentBlockComponent'; +import { ContributorFollowBlockComponent } from '../components/ContributorFollowBlockComponent.importable'; import { CrosswordComponent } from '../components/CrosswordComponent.importable'; import { DividerBlockComponent } from '../components/DividerBlockComponent'; import { DocumentBlockComponent } from '../components/DocumentBlockComponent.importable'; @@ -285,6 +286,15 @@ export const renderElement = ({ permalink={element.permalink} /> ); + case 'model.dotcomrendering.pageElements.ContributorFollowBlockElement': + return ( + + ); case 'model.dotcomrendering.pageElements.DividerBlockElement': return ( + element._type === 'model.dotcomrendering.pageElements.TextBlockElement'; + +export const enhanceContributorFollowBlock = + ( + format: ArticleFormat, + renderingTarget: RenderingTarget, + tags: TagType[] | undefined, + byline: string | undefined, + ) => + (elements: FEElement[]): FEElement[] => { + // TODO: replace with A/B test check + const enabled = false; + if (!enabled) { + return elements; + } + + if (renderingTarget !== 'Apps') { + return elements; + } + + if (format.design !== ArticleDesign.Comment) { + return elements; + } + + const soleContributor = getSoleContributor(tags ?? [], byline); + if (!soleContributor) { + return elements; + } + + let paragraphCount = 0; + let insertPosition: number | null = null; + + for (let i = 0; i < elements.length; i++) { + if (isParagraph(elements[i]!)) { + paragraphCount++; + if (paragraphCount === PARAGRAPH_POSITION) { + insertPosition = i + 1; + break; + } + } + } + + // If an article has <= 4 paragraphs, insert at the end + if (insertPosition === null) { + insertPosition = elements.length; + } + + const contributorFollowBlockElement: ContributorFollowBlockElement = { + _type: 'model.dotcomrendering.pageElements.ContributorFollowBlockElement', + elementId: `contributor-profile-${soleContributor.id}`, + contributorId: soleContributor.id, + displayName: soleContributor.title, + avatarUrl: soleContributor.bylineLargeImageUrl, + bio: soleContributor.bio, + }; + + return [ + ...elements.slice(0, insertPosition), + contributorFollowBlockElement, + ...elements.slice(insertPosition), + ]; + }; diff --git a/dotcom-rendering/src/model/enhanceBlocks.ts b/dotcom-rendering/src/model/enhanceBlocks.ts index cdbf26ff205..aaa4bedb472 100644 --- a/dotcom-rendering/src/model/enhanceBlocks.ts +++ b/dotcom-rendering/src/model/enhanceBlocks.ts @@ -12,6 +12,7 @@ import type { RenderingTarget } from '../types/renderingTarget'; import type { TagType } from '../types/tag'; import { enhanceAdPlaceholders } from './enhance-ad-placeholders'; import { enhanceBlockquotes } from './enhance-blockquotes'; +import { enhanceContributorFollowBlock } from './enhance-contributor-follow-block'; import { enhanceDisclaimer } from './enhance-disclaimer'; import { enhanceDividers } from './enhance-dividers'; import { enhanceDots } from './enhance-dots'; @@ -40,6 +41,7 @@ type Options = { pageId: string; serverSideABTests?: Record; switches?: Switches; + byline?: string; }; const enhanceNewsletterSignup = @@ -100,6 +102,12 @@ export const enhanceElements = options.renderingTarget, options.shouldHideAds, ), + enhanceContributorFollowBlock( + format, + options.renderingTarget, + options.tags, + options.byline, + ), enhanceDisclaimer(options.hasAffiliateLinksDisclaimer, isNested), enhanceProductSummary({ pageId: options.pageId, diff --git a/dotcom-rendering/src/model/enhanceTags.ts b/dotcom-rendering/src/model/enhanceTags.ts index 84c857e49ce..5df9b3b2db2 100644 --- a/dotcom-rendering/src/model/enhanceTags.ts +++ b/dotcom-rendering/src/model/enhanceTags.ts @@ -11,6 +11,7 @@ const enhanceTag = ({ twitterHandle, bylineImageUrl, contributorLargeImagePath: bylineLargeImageUrl, + bio, }, }: FETagType): TagType => ({ id, @@ -19,4 +20,5 @@ const enhanceTag = ({ twitterHandle, bylineImageUrl, bylineLargeImageUrl, + bio, }); diff --git a/dotcom-rendering/src/paletteDeclarations.ts b/dotcom-rendering/src/paletteDeclarations.ts index cc604489aea..61daa0107ae 100644 --- a/dotcom-rendering/src/paletteDeclarations.ts +++ b/dotcom-rendering/src/paletteDeclarations.ts @@ -6792,6 +6792,18 @@ const paletteColours = { light: commentFormInputBackgroundLight, dark: commentFormInputBackgroundDark, }, + '--contributor-follow-fill': { + light: followIconFillLight, + dark: followIconFillDark, + }, + '--contributor-follow-bio-text': { + light: () => sourcePalette.neutral[10], + dark: () => sourcePalette.neutral[86], + }, + '--contributor-follow-straight-lines': { + light: () => sourcePalette.neutral[86], + dark: () => sourcePalette.neutral[38], + }, '--cricket-scoreboard-border-top': { light: cricketScoreboardBorderTop, dark: cricketScoreboardBorderTop, diff --git a/dotcom-rendering/src/types/article.ts b/dotcom-rendering/src/types/article.ts index fce46392341..32bec0b9b73 100644 --- a/dotcom-rendering/src/types/article.ts +++ b/dotcom-rendering/src/types/article.ts @@ -106,6 +106,7 @@ export const enhanceArticleType = ( pageId: data.pageId, serverSideABTests: data.config.serverSideABTests, switches: data.config.switches, + byline: data.byline, }); const crosswordBlock = buildCrosswordBlock(data); diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 721ef37ae90..cedc3d2f4a0 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -606,6 +606,15 @@ export interface TextBlockElement { html: string; } +export interface ContributorFollowBlockElement { + _type: 'model.dotcomrendering.pageElements.ContributorFollowBlockElement'; + elementId: string; + contributorId: string; + displayName: string; + avatarUrl?: string; + bio?: string; +} + export type DCRTimelineEvent = { date: string; title?: string; @@ -832,6 +841,7 @@ export type FEElement = | CodeBlockElement | CommentBlockElement | ContentAtomBlockElement + | ContributorFollowBlockElement | DisclaimerBlockElement | DividerBlockElement | DocumentBlockElement diff --git a/dotcom-rendering/src/types/tag.ts b/dotcom-rendering/src/types/tag.ts index aca68f30f28..1d4c3534735 100644 --- a/dotcom-rendering/src/types/tag.ts +++ b/dotcom-rendering/src/types/tag.ts @@ -57,4 +57,5 @@ export type TagType = { bylineImageUrl?: string; bylineLargeImageUrl?: string; podcast?: Podcast; + bio?: string; };