diff --git a/dotcom-rendering/src/components/ArticleMeta.apps.tsx b/dotcom-rendering/src/components/ArticleMeta.apps.tsx
index ea8b69fbc56..c2c592235d3 100644
--- a/dotcom-rendering/src/components/ArticleMeta.apps.tsx
+++ b/dotcom-rendering/src/components/ArticleMeta.apps.tsx
@@ -23,7 +23,7 @@ import { Dateline } from './Dateline';
import { FollowWrapper } from './FollowWrapper.island';
import { Island } from './Island';
import { ListenToArticle } from './ListenToArticle.island';
-import { LiveblogNotifications } from './LiveblogNotifications.island';
+import { NotificationsToggle } from './NotificationsToggle.island';
type Props = {
format: ArticleFormat;
@@ -249,9 +249,6 @@ export const ArticleMetaApps = ({
const shouldShowFollowButtons = (layoutOrDesignType: boolean) =>
layoutOrDesignType && !!byline && !isUndefined(soleContributor);
- const shouldShowLiveblogNotifications =
- isLiveBlog && !!pageId && !!headline;
-
const isImmersiveOrAnalysisWithMultipleAuthors =
(isAnalysis || isImmersive) && !!byline && isUndefined(soleContributor);
@@ -311,14 +308,11 @@ export const ArticleMetaApps = ({
/>
)}
- {shouldShowLiveblogNotifications && (
-
-
-
- )}
+
{isCommentable && (
);
};
+
+const LiveblogNotifications = (props: {
+ isLiveBlog: boolean;
+ headline: string | undefined;
+ pageId: string | undefined;
+}) =>
+ props.isLiveBlog && !!props.pageId && !!props.headline ? (
+
+
+
+
+
+ ) : null;
diff --git a/dotcom-rendering/src/components/LiveblogNotifications.island.tsx b/dotcom-rendering/src/components/LiveblogNotifications.island.tsx
deleted file mode 100644
index 93cc57abe76..00000000000
--- a/dotcom-rendering/src/components/LiveblogNotifications.island.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import { css } from '@emotion/react';
-import { Topic } from '@guardian/bridget/Topic';
-import { isUndefined, log } from '@guardian/libs';
-import { from, space } from '@guardian/source/foundations';
-import { useEffect, useState } from 'react';
-import { getNotificationsClient } from '../lib/bridgetApi';
-import { FollowNotificationsButton } from './FollowButtons';
-
-type Props = {
- id: string;
- displayName: string;
-};
-
-export const LiveblogNotifications = ({ id, displayName }: Props) => {
- const [isFollowingNotifications, setIsFollowingNotifications] = useState<
- boolean | undefined
- >(undefined);
-
- useEffect(() => {
- const topic = new Topic({
- id,
- displayName,
- type: 'content',
- });
-
- 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,
- );
- });
- }, [id, displayName]);
-
- const notificationsHandler = () => {
- const topic = new Topic({
- id,
- displayName,
- type: 'content',
- });
-
- 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,
- );
- });
- }
- };
-
- return (
-
- undefined
- }
- />
-
- );
-};
diff --git a/dotcom-rendering/src/components/NotificationsToggle.island.tsx b/dotcom-rendering/src/components/NotificationsToggle.island.tsx
new file mode 100644
index 00000000000..936010e2a35
--- /dev/null
+++ b/dotcom-rendering/src/components/NotificationsToggle.island.tsx
@@ -0,0 +1,15 @@
+import type { ComponentProps } from 'react';
+import { getNotificationsClient } from '../lib/bridgetApi';
+import { NotificationsToggle as NotificationsToggleComponent } from './NotificationsToggle';
+
+type Props = Omit<
+ ComponentProps,
+ 'notificationsClient'
+>;
+
+export const NotificationsToggle = (props: Props) => (
+
+);
diff --git a/dotcom-rendering/src/components/NotificationsToggle.stories.tsx b/dotcom-rendering/src/components/NotificationsToggle.stories.tsx
new file mode 100644
index 00000000000..bb9bd9070d5
--- /dev/null
+++ b/dotcom-rendering/src/components/NotificationsToggle.stories.tsx
@@ -0,0 +1,75 @@
+import { Topic } from '@guardian/bridget';
+import type { Meta, StoryObj } from '@storybook/react-webpack5';
+import { expect, fn, userEvent, within } from 'storybook/test';
+import type { NotificationsClient } from '../lib/bridgetApi';
+import { NotificationsToggle as NotificationsToggleComponent } from './NotificationsToggle';
+
+const meta = {
+ component: NotificationsToggleComponent,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+const mockNotificationsClient: NotificationsClient = (() => {
+ let following = false;
+
+ return {
+ follow: fn(() => {
+ following = true;
+ return Promise.resolve(true);
+ }),
+
+ unfollow: fn(() => {
+ following = false;
+ return Promise.resolve(true);
+ }),
+
+ isFollowing: fn(() => {
+ return Promise.resolve(following);
+ }),
+ };
+})();
+
+export const NotificationsToggle = {
+ args: {
+ displayName: 'A notification',
+ id: 'a-notification-id',
+ notificationType: 'content',
+ notificationsClient: mockNotificationsClient,
+ },
+ play: async ({ args, step, canvasElement }) => {
+ const canvas = within(canvasElement);
+ const button = canvas.getByRole('button');
+ const expectedTopic = new Topic({
+ displayName: args.displayName,
+ id: args.id,
+ type: args.notificationType,
+ });
+
+ await expect(button).toHaveTextContent('Notifications off');
+
+ await step('isFollowing is called', async () => {
+ await expect(
+ mockNotificationsClient.isFollowing,
+ ).toHaveBeenCalledWith(expectedTopic);
+ });
+
+ await step('follow is called when button is clicked', async () => {
+ await userEvent.click(button);
+ await expect(mockNotificationsClient.follow).toHaveBeenCalledWith(
+ expectedTopic,
+ );
+ await expect(button).toHaveTextContent('Notifications on');
+ });
+
+ await step('unfollow is called when button is clicked', async () => {
+ await userEvent.click(button);
+ await expect(mockNotificationsClient.unfollow).toHaveBeenCalledWith(
+ expectedTopic,
+ );
+ await expect(button).toHaveTextContent('Notifications off');
+ });
+ },
+} satisfies Story;
diff --git a/dotcom-rendering/src/components/NotificationsToggle.tsx b/dotcom-rendering/src/components/NotificationsToggle.tsx
new file mode 100644
index 00000000000..11de015bc9f
--- /dev/null
+++ b/dotcom-rendering/src/components/NotificationsToggle.tsx
@@ -0,0 +1,145 @@
+import { Topic } from '@guardian/bridget/Topic';
+import { log } from '@guardian/libs';
+import {
+ SvgNotificationsOff,
+ SvgNotificationsOn,
+} from '@guardian/source/react-components';
+import { useEffect, useState } from 'react';
+import type { NotificationsClient } from '../lib/bridgetApi';
+import { ToggleButton } from './ToggleButton';
+
+type Props = {
+ id: string;
+ displayName: string;
+ notificationType: 'content';
+ notificationsClient: NotificationsClient;
+};
+
+export const NotificationsToggle = ({
+ id,
+ displayName,
+ notificationType,
+ notificationsClient,
+}: Props) => {
+ const [isFollowing, setIsFollowing] = useIsFollowing(
+ id,
+ displayName,
+ notificationType,
+ notificationsClient,
+ );
+
+ return (
+
+ ) : (
+
+ )
+ }
+ iconBackground={isFollowing ? '--follow-icon-fill' : undefined}
+ iconBorder="--follow-icon-fill"
+ iconFill={
+ isFollowing ? '--follow-icon-background' : '--follow-icon-fill'
+ }
+ onClick={toggleNotifications(
+ id,
+ displayName,
+ notificationType,
+ notificationsClient,
+ isFollowing,
+ setIsFollowing,
+ )}
+ >
+ Notifications {isFollowing ? 'on' : 'off'}
+
+ );
+};
+
+/**
+ * Retrieves information about whether the user is following a particular topic,
+ * via Bridget, and stores it in a state variable for use in the rest of the
+ * component. Also supplies a setter to update that state, similar to
+ * `useState`.
+ */
+const useIsFollowing = (
+ id: string,
+ displayName: string,
+ notificationType: Props['notificationType'],
+ notificationsClient: Props['notificationsClient'],
+): [
+ boolean | undefined,
+ React.Dispatch>,
+] => {
+ const [isFollowing, setIsFollowing] = useState(
+ undefined,
+ );
+
+ useEffect(() => {
+ const topic = new Topic({
+ id,
+ displayName,
+ type: notificationType,
+ });
+
+ void notificationsClient
+ .isFollowing(topic)
+ .then(setIsFollowing)
+ .catch(handleError('isFollowing'));
+ }, [id, displayName, notificationType, notificationsClient]);
+
+ return [isFollowing, setIsFollowing];
+};
+
+const toggleNotifications = (
+ id: string,
+ displayName: string,
+ notificationType: Props['notificationType'],
+ notificationsClient: Props['notificationsClient'],
+ isFollowing: boolean | undefined,
+ setIsFollowing: (a: boolean) => void,
+): (() => void) =>
+ isFollowing === undefined
+ ? () => undefined
+ : () => {
+ const topic = new Topic({
+ id,
+ displayName,
+ type: notificationType,
+ });
+
+ if (isFollowing) {
+ void notificationsClient
+ .unfollow(topic)
+ .then((success) => {
+ if (success) {
+ setIsFollowing(false);
+ }
+ })
+ .catch(handleError('unfollow'));
+ } else {
+ void notificationsClient
+ .follow(topic)
+ .then((success) => {
+ if (success) {
+ setIsFollowing(true);
+ }
+ })
+ .catch(handleError('follow'));
+ }
+ };
+
+const handleError =
+ (methodName: 'follow' | 'unfollow' | 'isFollowing') =>
+ (error: any): void => {
+ window.guardian.modules.sentry.reportError(
+ error,
+ `bridget-getNotificationsClient-${methodName}-error`,
+ );
+ log(
+ 'dotcom',
+ `Bridget getNotificationsClient.${methodName} Error:`,
+ error,
+ );
+ };
diff --git a/dotcom-rendering/src/components/ToggleButton.tsx b/dotcom-rendering/src/components/ToggleButton.tsx
new file mode 100644
index 00000000000..c0b2c255a4a
--- /dev/null
+++ b/dotcom-rendering/src/components/ToggleButton.tsx
@@ -0,0 +1,86 @@
+import { space, textSans15Object } from '@guardian/source/foundations';
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+import { palette } from '../palette';
+import type { ColourName } from '../paletteDeclarations';
+
+type Props = {
+ onClick: () => void;
+ children: ReactNode;
+ colour: ColourName;
+ icon: ReactNode;
+ iconFill: ColourName;
+ iconBackground: ColourName | undefined;
+ iconBorder: ColourName;
+};
+
+export const ToggleButton = (props: Props) => (
+
+);
+
+const Button = (props: {
+ onClick: ButtonHTMLAttributes['onClick'];
+ children: ReactNode;
+ colour: ColourName;
+}) => (
+
+);
+
+const Icon = (props: {
+ fill: ColourName;
+ background: ColourName | undefined;
+ border: ColourName;
+ children: ReactNode;
+}) => (
+
+ {props.children}
+
+);
diff --git a/dotcom-rendering/src/lib/bridgetApi.ts b/dotcom-rendering/src/lib/bridgetApi.ts
index 644fb63e608..a20811faff6 100644
--- a/dotcom-rendering/src/lib/bridgetApi.ts
+++ b/dotcom-rendering/src/lib/bridgetApi.ts
@@ -1,3 +1,4 @@
+import type { ThriftClient } from '@creditkarma/thrift-server-core';
import * as AbTesting from '@guardian/bridget/AbTesting';
import * as Acquisitions from '@guardian/bridget/Acquisitions';
import * as Analytics from '@guardian/bridget/Analytics';
@@ -18,6 +19,15 @@ import * as Video from '@guardian/bridget/Videos';
import { isUndefined } from '@guardian/libs';
import { createAppClient } from './thrift/nativeConnection';
+type BridgetClient = Omit<
+ Client,
+ | '_serviceName'
+ | '_annotations'
+ | '_methodAnnotations'
+ | '_methodNames'
+ | '_methodParameters'
+>;
+
let environmentClient: Environment.Client | undefined = undefined;
export const getEnvironmentClient = (): Environment.Client => {
if (isUndefined(environmentClient)) {
@@ -54,8 +64,9 @@ export const getAcquisitionsClient = (): Acquisitions.Client => {
return acquisitionsClient;
};
-let notificationsClient: Notifications.Client | undefined = undefined;
-export const getNotificationsClient = (): Notifications.Client => {
+export type NotificationsClient = BridgetClient;
+let notificationsClient: NotificationsClient | undefined = undefined;
+export const getNotificationsClient = (): NotificationsClient => {
if (!notificationsClient) {
notificationsClient = createAppClient>(
Notifications.Client,