diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json
index ebb5eb32140..5137f8568b7 100644
--- a/dotcom-rendering/package.json
+++ b/dotcom-rendering/package.json
@@ -63,6 +63,7 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@braze/web-sdk-core": "3.5.1",
+ "@creditkarma/thrift-server-core": "^1.0.4",
"@cypress/skip-test": "^2.6.0",
"@emotion/babel-plugin": "^11.3.0",
"@emotion/cache": "^11.4.0",
@@ -71,6 +72,7 @@
"@guardian/ab-core": "^2.0.0",
"@guardian/atoms-rendering": "^24.0.0",
"@guardian/braze-components": "^8.1.3",
+ "@guardian/bridget": "^2.0.0",
"@guardian/browserslist-config": "^2.0.3",
"@guardian/commercial-core": "^5.0.0",
"@guardian/consent-management-platform": "11.0.0",
@@ -151,6 +153,7 @@
"babel-plugin-polyfill-corejs3": "^0.6.0",
"babel-plugin-px-to-rem": "https://github.com/guardian/babel-plugin-px-to-rem#v0.1.0",
"babel-plugin-transform-runtime": "^6.23.0",
+ "buffer": "^6.0.3",
"browserslist": "^4.21.4",
"bundlesize": "^0.18.1",
"chalk": "^4.1.0",
diff --git a/dotcom-rendering/scripts/webpack/webpack.config.browser.js b/dotcom-rendering/scripts/webpack/webpack.config.browser.js
index d8184d8cb9e..9def61efa56 100644
--- a/dotcom-rendering/scripts/webpack/webpack.config.browser.js
+++ b/dotcom-rendering/scripts/webpack/webpack.config.browser.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const swcConfig = require('./.swcrc.json');
const { getBrowserTargets } = require('./browser-targets');
@@ -15,7 +16,7 @@ const generateName = (bundle) => {
};
/**
- * @param {'legacy' | 'modern' | 'variant'} bundle
+ * @param {'legacy' | 'modern' | 'variant' | 'apps'} bundle
* @returns {string}
*/
const getLoaders = (bundle) => {
@@ -48,6 +49,32 @@ const getLoaders = (bundle) => {
},
},
];
+ case 'apps':
+ return [
+ {
+ loader: 'babel-loader',
+ options: {
+ presets: [
+ '@babel/preset-react',
+ [
+ '@babel/preset-env',
+ {
+ bugfixes: true,
+ targets: ['android >= 5', 'ios >= 12'],
+ },
+ ],
+ ],
+ compact: true,
+ },
+ },
+ {
+ loader: 'ts-loader',
+ options: {
+ configFile: 'tsconfig.build.json',
+ transpileOnly: true,
+ },
+ },
+ ];
case 'variant':
return [
{
@@ -93,25 +120,28 @@ const getLoaders = (bundle) => {
};
/**
- * @param {{ bundle: 'legacy' | 'modern' | 'variant', sessionId: string }} options
+ * @param {{ bundle: 'legacy' | 'modern' | 'apps' | 'variant', sessionId: string }} options
* @returns {import('webpack').Configuration}
*/
module.exports = ({ bundle, sessionId }) => ({
- entry: {
- sentryLoader: './src/web/browser/sentryLoader/init.ts',
- bootCmp: './src/web/browser/bootCmp/init.ts',
- ga: './src/web/browser/ga/init.ts',
- ophan: './src/web/browser/ophan/init.ts',
- islands: './src/web/browser/islands/init.ts',
- dynamicImport: './src/web/browser/dynamicImport/init.ts',
- atomIframe: './src/web/browser/atomIframe/init.ts',
- embedIframe: './src/web/browser/embedIframe/init.ts',
- newsletterEmbedIframe:
- './src/web/browser/newsletterEmbedIframe/init.ts',
- relativeTime: './src/web/browser/relativeTime/init.ts',
- initDiscussion: './src/web/browser/initDiscussion/init.ts',
- debug: './src/web/browser/debug/init.ts',
- },
+ entry:
+ bundle === 'apps'
+ ? './src/app/client/init.ts'
+ : {
+ sentryLoader: './src/web/browser/sentryLoader/init.ts',
+ bootCmp: './src/web/browser/bootCmp/init.ts',
+ ga: './src/web/browser/ga/init.ts',
+ ophan: './src/web/browser/ophan/init.ts',
+ islands: './src/web/browser/islands/init.ts',
+ dynamicImport: './src/web/browser/dynamicImport/init.ts',
+ atomIframe: './src/web/browser/atomIframe/init.ts',
+ embedIframe: './src/web/browser/embedIframe/init.ts',
+ newsletterEmbedIframe:
+ './src/web/browser/newsletterEmbedIframe/init.ts',
+ relativeTime: './src/web/browser/relativeTime/init.ts',
+ initDiscussion: './src/web/browser/initDiscussion/init.ts',
+ debug: './src/web/browser/debug/init.ts',
+ },
output: {
filename: (data) => {
// We don't want to hash the debug script so it can be used in bookmarklets
@@ -121,10 +151,28 @@ module.exports = ({ bundle, sessionId }) => ({
chunkFilename: generateName(bundle),
publicPath: '',
},
+ resolve:
+ bundle === 'apps'
+ ? {
+ fallback: {
+ buffer: require.resolve('buffer/'),
+ },
+ }
+ : undefined,
plugins: [
new WebpackManifestPlugin({
fileName: `manifest.${bundle}.json`,
}),
+ ...(bundle === 'apps'
+ ? [
+ new webpack.optimize.LimitChunkCountPlugin({
+ maxChunks: 1,
+ }),
+ new webpack.ProvidePlugin({
+ Buffer: ['buffer', 'Buffer'],
+ }),
+ ]
+ : []),
...(DEV
? [
new GuStatsReportPlugin({
diff --git a/dotcom-rendering/scripts/webpack/webpack.config.js b/dotcom-rendering/scripts/webpack/webpack.config.js
index 782369b91a7..d901e288b81 100644
--- a/dotcom-rendering/scripts/webpack/webpack.config.js
+++ b/dotcom-rendering/scripts/webpack/webpack.config.js
@@ -19,7 +19,7 @@ const sessionId = uuidv4();
let builds = 0;
/**
- * @param {{ platform: 'server' | 'browser.legacy' | 'browser.modern' | 'browser.variant'}} options
+ * @param {{ platform: 'server' | 'browser.legacy' | 'browser.modern' | 'browser.apps' | 'browser.variant'}} options
* @returns {import('webpack').Configuration}
*/
const commonConfigs = ({ platform }) => ({
@@ -131,6 +131,15 @@ module.exports = [
sessionId,
}),
),
+ merge(
+ commonConfigs({
+ platform: 'browser.apps',
+ }),
+ require(`./webpack.config.browser`)({
+ bundle: 'apps',
+ sessionId,
+ }),
+ ),
...(BUILD_VARIANT
? [
merge(
diff --git a/dotcom-rendering/src/app/client/init.ts b/dotcom-rendering/src/app/client/init.ts
new file mode 100644
index 00000000000..54ecf18b598
--- /dev/null
+++ b/dotcom-rendering/src/app/client/init.ts
@@ -0,0 +1,7 @@
+import '../../web/browser/dynamicImport/init.ts';
+import '../../web/browser/islands/init.ts';
+import '../../web/browser/atomIframe/init.ts';
+import '../../web/browser/embedIframe/init.ts';
+import '../../web/browser/newsletterEmbedIframe/init.ts';
+import '../../web/browser/relativeTime/init.ts';
+// import '../../web/browser/initDiscussion/init.ts';
diff --git a/dotcom-rendering/src/app/components/ArticlePage.tsx b/dotcom-rendering/src/app/components/ArticlePage.tsx
new file mode 100644
index 00000000000..bf74b06d176
--- /dev/null
+++ b/dotcom-rendering/src/app/components/ArticlePage.tsx
@@ -0,0 +1,70 @@
+import { css, Global } from '@emotion/react';
+import { ArticleDesign } from '@guardian/libs';
+import { brandAlt, focusHalo, neutral } from '@guardian/source-foundations';
+import { StrictMode } from 'react';
+// import { filterABTestSwitches } from '../../model/enhance-switches';
+import type { FEArticleType } from '../../types/frontend';
+import { FetchCommentCounts } from '../../web/components/FetchCommentCounts.importable';
+import { FocusStyles } from '../../web/components/FocusStyles.importable';
+import { Island } from '../../web/components/Island';
+// import { SetABTests } from './SetABTests.importable';
+import { SkipTo } from '../../web/components/SkipTo';
+import { DecideLayout } from '../layouts/DecideLayout';
+
+type Props = {
+ CAPIArticle: FEArticleType;
+ format: ArticleFormat;
+};
+
+/**
+ * @description
+ * Article is a high level wrapper for article pages on Dotcom. Sets strict mode and some globals
+ *
+ * @param {Props} props
+ * @param {FEArticleType} props.CAPIArticle - The article JSON data
+ * @param {NAVType} props.NAV - The article JSON data
+ * @param {ArticleFormat} props.format - The format model for the article
+ * */
+export const ArticlePage = ({ CAPIArticle, format }: Props) => {
+ return (
+
+
+
+ {(format.design === ArticleDesign.LiveBlog ||
+ format.design === ArticleDesign.DeadBlog) && (
+
+ )}
+
+
+
+ {/*
+
+ */}
+
+
+
+ {/*
+
+ */}
+
+
+ );
+};
diff --git a/dotcom-rendering/src/app/layouts/DecideLayout.tsx b/dotcom-rendering/src/app/layouts/DecideLayout.tsx
new file mode 100644
index 00000000000..28aae16abd3
--- /dev/null
+++ b/dotcom-rendering/src/app/layouts/DecideLayout.tsx
@@ -0,0 +1,64 @@
+import { ArticleDesign, ArticleDisplay } from '@guardian/libs';
+import type { ArticleFormat } from '@guardian/libs';
+import type { FEArticleType } from '../../types/frontend';
+import { StandardLayout } from './StandardLayout';
+
+type Props = {
+ CAPIArticle: FEArticleType;
+ format: ArticleFormat;
+};
+
+export const DecideLayout = ({ CAPIArticle, format }: Props) => {
+ switch (format.display) {
+ case ArticleDisplay.Immersive: {
+ switch (format.design) {
+ case ArticleDesign.Interactive: {
+ return
Not Supported
;
+ }
+ default: {
+ return Not Supported
;
+ }
+ }
+ }
+ case ArticleDisplay.NumberedList:
+ case ArticleDisplay.Showcase: {
+ switch (format.design) {
+ case ArticleDesign.LiveBlog:
+ case ArticleDesign.DeadBlog:
+ return Not Supported
;
+ case ArticleDesign.Comment:
+ case ArticleDesign.Editorial:
+ case ArticleDesign.Letter:
+ return Not Supported
;
+ default:
+ return Not Supported
;
+ }
+ }
+ case ArticleDisplay.Standard:
+ default: {
+ switch (format.design) {
+ case ArticleDesign.Interactive:
+ return Not Supported
;
+ case ArticleDesign.FullPageInteractive: {
+ return Not Supported
;
+ }
+ case ArticleDesign.LiveBlog:
+ case ArticleDesign.DeadBlog:
+ return Not Supported
;
+ case ArticleDesign.Comment:
+ case ArticleDesign.Editorial:
+ case ArticleDesign.Letter:
+ return Not Supported
;
+ case ArticleDesign.NewsletterSignup:
+ return Not Supported
;
+ default:
+ return (
+
+ );
+ }
+ }
+ }
+};
diff --git a/dotcom-rendering/src/app/layouts/StandardLayout.tsx b/dotcom-rendering/src/app/layouts/StandardLayout.tsx
new file mode 100644
index 00000000000..929ca652de4
--- /dev/null
+++ b/dotcom-rendering/src/app/layouts/StandardLayout.tsx
@@ -0,0 +1,795 @@
+import { css } from '@emotion/react';
+import { ArticleDesign, ArticleSpecial } from '@guardian/libs';
+import type { ArticleFormat } from '@guardian/libs';
+import {
+ border,
+ brandAltBackground,
+ from,
+ labs,
+ until,
+} from '@guardian/source-foundations';
+import { StraightLines } from '@guardian/source-react-components-development-kitchen';
+import type { FEArticleType } from '../../types/frontend';
+import { Platform } from '../../types/platform';
+import { AppsArticleBody } from '../../web/components/ArticleBody';
+import { ArticleContainer } from '../../web/components/ArticleContainer';
+import { ArticleHeadline } from '../../web/components/ArticleHeadline';
+import { ArticleMeta } from '../../web/components/ArticleMeta';
+import { ArticleTitle } from '../../web/components/ArticleTitle';
+import { Border } from '../../web/components/Border';
+import { Carousel } from '../../web/components/Carousel.importable';
+import { DecideLines } from '../../web/components/DecideLines';
+import { DecideOnwards } from '../../web/components/DecideOnwards';
+import { GetMatchNav } from '../../web/components/GetMatchNav.importable';
+import { GetMatchStats } from '../../web/components/GetMatchStats.importable';
+import { GetMatchTabs } from '../../web/components/GetMatchTabs.importable';
+import { GridItem } from '../../web/components/GridItem';
+import { GuardianLabsLines } from '../../web/components/GuardianLabsLines';
+import { Island } from '../../web/components/Island';
+import { LabsHeader } from '../../web/components/LabsHeader.importable';
+import { MainMedia } from '../../web/components/MainMedia';
+import { MostViewedFooterData } from '../../web/components/MostViewedFooterData.importable';
+import { MostViewedFooterLayout } from '../../web/components/MostViewedFooterLayout';
+import { MostViewedRightWrapper } from '../../web/components/MostViewedRightWrapper.importable';
+import { OnwardsUpper } from '../../web/components/OnwardsUpper.importable';
+import { RightColumn } from '../../web/components/RightColumn';
+import { Section } from '../../web/components/Section';
+import { Standfirst } from '../../web/components/Standfirst';
+import { StarRating } from '../../web/components/StarRating/StarRating';
+import { SubMeta } from '../../web/components/SubMeta';
+import { TableOfContents } from '../../web/components/TableOfContents';
+import { Stuck } from '../../web/layouts/lib/stickiness';
+import { decidePalette } from '../../web/lib/decidePalette';
+import { decideTrail } from '../../web/lib/decideTrail';
+
+const StandardGrid = ({
+ children,
+ isMatchReport,
+}: {
+ children: React.ReactNode;
+ isMatchReport: boolean;
+}) => (
+
+ {children}
+
+);
+
+const maxWidth = css`
+ ${from.desktop} {
+ max-width: 620px;
+ }
+`;
+
+const stretchLines = css`
+ ${until.phablet} {
+ margin-left: -20px;
+ margin-right: -20px;
+ }
+ ${until.mobileLandscape} {
+ margin-left: -10px;
+ margin-right: -10px;
+ }
+`;
+
+const starWrapper = css`
+ margin-bottom: 18px;
+ margin-top: 6px;
+ background-color: ${brandAltBackground.primary};
+ display: inline-block;
+
+ ${until.phablet} {
+ padding-left: 20px;
+ margin-left: -20px;
+ }
+ ${until.leftCol} {
+ padding-left: 0px;
+ margin-left: -0px;
+ }
+
+ padding-left: 10px;
+ margin-left: -10px;
+`;
+
+interface Props {
+ CAPIArticle: FEArticleType;
+ format: ArticleFormat;
+}
+
+export const StandardLayout = ({ CAPIArticle, format }: Props) => {
+ const {
+ config: { isPaidContent, host },
+ } = CAPIArticle;
+
+ const footballMatchUrl =
+ CAPIArticle.matchType === 'FootballMatchType' && CAPIArticle.matchUrl;
+
+ const isMatchReport =
+ format.design === ArticleDesign.MatchReport && !!footballMatchUrl;
+
+ // const showComments = CAPIArticle.isCommentable;
+
+ // const { branding } =
+ // CAPIArticle.commercialProperties[CAPIArticle.editionId];
+
+ const palette = decidePalette(format);
+
+ // const contributionsServiceUrl = getContributionsServiceUrl(CAPIArticle);
+
+ /**
+ * This property currently only applies to the header and merchandising slots
+ */
+ // const renderAds = !CAPIArticle.isAdFreeUser && !CAPIArticle.shouldHideAds;
+
+ return (
+ <>
+ {/* TODO: Review this because AR didn't previously support labs articles */}
+ {format.theme === ArticleSpecial.Labs && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {format.theme === ArticleSpecial.Labs ? (
+ <>>
+ ) : (
+
+ )}
+
+
+
+ {isMatchReport && (
+
+
+
+ )}
+
+
+
+
+ {isMatchReport && (
+
+
+
+ )}
+
+
+
+
+ {CAPIArticle.starRating ||
+ CAPIArticle.starRating === 0 ? (
+
+
+
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {format.theme === ArticleSpecial.Labs ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {CAPIArticle.tableOfContents && (
+
+ )}
+
+ {format.design === ArticleDesign.MatchReport &&
+ !!footballMatchUrl && (
+
+
+
+ )}
+
+ {/* We know we need something like this but we don't know what yet */}
+ {/* {showBodyEndSlot && (
+
+
+
+ )} */}
+
+
+
+
+
+
+
+ {/* Todo: make this an 'app ad' */}
+ {/* {!CAPIArticle.shouldHideAds && (
+
+ )} */}
+ {!isPaidContent ? (
+
+
+
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+
+ {/* Todo: do we want this? */}
+ {/* {renderAds && !isLabs && (
+
+ )} */}
+
+ {CAPIArticle.onwards ? (
+
+ ) : (
+ <>
+ {CAPIArticle.storyPackage && (
+
+ )}
+
+
+
+
+ >
+ )}
+
+ {/* TODO: Review if we can get discussion working */}
+ {/* {!isPaidContent && showComments && (
+
+ )} */}
+
+ {!isPaidContent && (
+
+ )}
+
+ {/* Todo: review if we need this */}
+ {/* {renderAds && !isLabs && (
+
+ )} */}
+
+ {/*(*/}
+ >
+ );
+};
diff --git a/dotcom-rendering/src/app/native/nativeApi.ts b/dotcom-rendering/src/app/native/nativeApi.ts
new file mode 100644
index 00000000000..80f9e15e94b
--- /dev/null
+++ b/dotcom-rendering/src/app/native/nativeApi.ts
@@ -0,0 +1,72 @@
+import * as Acquisitions from '@guardian/bridget/Acquisitions';
+import * as Analytics from '@guardian/bridget/Analytics';
+import * as Commercial from '@guardian/bridget/Commercial';
+import * as Discussion from '@guardian/bridget/Discussion';
+import * as Environment from '@guardian/bridget/Environment';
+import * as Gallery from '@guardian/bridget/Gallery';
+import * as Metrics from '@guardian/bridget/Metrics';
+import * as Navigation from '@guardian/bridget/Navigation';
+import * as Newsletters from '@guardian/bridget/Newsletters';
+import * as Notifications from '@guardian/bridget/Notifications';
+import * as User from '@guardian/bridget/User';
+import * as Video from '@guardian/bridget/Videos';
+import { createAppClient } from './thrift/nativeConnection';
+
+const environmentClient: Environment.Client = createAppClient<
+ Environment.Client
+>(Environment.Client, 'buffered', 'compact');
+const commercialClient: Commercial.Client = createAppClient<
+ Commercial.Client
+>(Commercial.Client, 'buffered', 'compact');
+const acquisitionsClient: Acquisitions.Client = createAppClient<
+ Acquisitions.Client
+>(Acquisitions.Client, 'buffered', 'compact');
+const notificationsClient: Notifications.Client = createAppClient<
+ Notifications.Client
+>(Notifications.Client, 'buffered', 'compact');
+const userClient: User.Client = createAppClient>(
+ User.Client,
+ 'buffered',
+ 'compact',
+);
+const galleryClient: Gallery.Client = createAppClient<
+ Gallery.Client
+>(Gallery.Client, 'buffered', 'compact');
+const videoClient: Video.Client = createAppClient>(
+ Video.Client,
+ 'buffered',
+ 'compact',
+);
+const metricsClient: Metrics.Client = createAppClient<
+ Metrics.Client
+>(Metrics.Client, 'buffered', 'compact');
+const discussionClient: Discussion.Client = createAppClient<
+ Discussion.Client
+>(Discussion.Client, 'buffered', 'compact');
+
+const analyticsClient: Analytics.Client = createAppClient<
+ Analytics.Client
+>(Analytics.Client, 'buffered', 'compact');
+
+const navigationClient: Navigation.Client = createAppClient<
+ Navigation.Client
+>(Navigation.Client, 'buffered', 'compact');
+
+const newslettersClient: Newsletters.Client = createAppClient<
+ Newsletters.Client
+>(Newsletters.Client, 'buffered', 'compact');
+
+export {
+ environmentClient,
+ commercialClient,
+ acquisitionsClient,
+ notificationsClient,
+ userClient,
+ galleryClient,
+ videoClient,
+ metricsClient,
+ discussionClient,
+ analyticsClient,
+ navigationClient,
+ newslettersClient,
+};
diff --git a/dotcom-rendering/src/app/native/thrift/nativeConnection.ts b/dotcom-rendering/src/app/native/thrift/nativeConnection.ts
new file mode 100644
index 00000000000..a3b9b12f709
--- /dev/null
+++ b/dotcom-rendering/src/app/native/thrift/nativeConnection.ts
@@ -0,0 +1,159 @@
+import type {
+ IClientConstructor,
+ IProtocolConstructor,
+ ITransportConstructor,
+ ProtocolType,
+ ThriftClient,
+ TransportType,
+ TTransport,
+} from '@creditkarma/thrift-server-core';
+import {
+ getProtocol,
+ getTransport,
+ TApplicationException,
+ TApplicationExceptionType,
+ ThriftConnection,
+} from '@creditkarma/thrift-server-core';
+import * as uuid from 'uuid';
+import { TMultiplexedProtocol } from './protocols';
+
+declare global {
+ interface Window {
+ nativeConnections?: Record;
+ android?: {
+ postMessage: (data: string, connectionId: string) => void;
+ };
+ webkit?: {
+ messageHandlers: {
+ iOSWebViewMessage: {
+ postMessage: (nativeMessage: NativeMessage) => void;
+ };
+ };
+ };
+ }
+}
+
+export interface NativeMessage {
+ data: string;
+ connectionId: string;
+}
+
+interface PromiseResponse {
+ resolve: (response: Buffer) => void;
+ reject: (error: Error) => void;
+ timeoutId: NodeJS.Timeout;
+}
+
+const ACTION_TIMEOUT_MS = 30000;
+
+function sendNativeMessage(nativeMessage: NativeMessage): void {
+ if (window.android) {
+ window.android.postMessage(
+ nativeMessage.data,
+ nativeMessage.connectionId,
+ );
+ } else if (window.webkit) {
+ window.webkit.messageHandlers.iOSWebViewMessage.postMessage(
+ nativeMessage,
+ );
+ } else {
+ console.warn('No native APIs available');
+ }
+}
+
+export class NativeConnection extends ThriftConnection {
+ connectionId = uuid.v4();
+ promises: PromiseResponse[] = [];
+ outBuffer: NativeMessage[] = [];
+
+ constructor(
+ Transport: ITransportConstructor,
+ Protocol: IProtocolConstructor,
+ ) {
+ super(Transport, Protocol);
+ if (typeof window !== 'undefined') {
+ window.nativeConnections = window.nativeConnections ?? {};
+ window.nativeConnections[this.connectionId] = this;
+ }
+ }
+
+ reset(oldConnectionId: string): void {
+ if (oldConnectionId === this.connectionId && window.nativeConnections) {
+ console.warn('Reseting connection ' + oldConnectionId);
+ delete window.nativeConnections[this.connectionId];
+ this.promises.forEach((promise) => {
+ promise.reject(
+ new TApplicationException(
+ TApplicationExceptionType.UNKNOWN,
+ 'Timeout error',
+ ),
+ );
+ });
+ this.promises = [];
+ this.connectionId = uuid.v4();
+ window.nativeConnections[this.connectionId] = this;
+ }
+ }
+
+ receive(message: NativeMessage): void {
+ const resolver = this.promises.shift();
+ if (resolver) {
+ clearTimeout(resolver.timeoutId);
+ const data = Buffer.from(message.data, 'base64');
+ resolver.resolve(data);
+ }
+ this.sendNextMessage();
+ }
+
+ private sendNextMessage(): void {
+ const message = this.outBuffer.shift();
+ if (message) {
+ console.log('Sending next message');
+ sendNativeMessage(message);
+ }
+ }
+
+ send(dataToSend: Buffer, context?: void | undefined): Promise {
+ const id = this.connectionId;
+ // eslint-disable-next-line @typescript-eslint/no-this-alias -- Reassign this
+ const connection = this;
+ return new Promise(function (res, rej): void {
+ connection.promises.push({
+ resolve: res,
+ reject: rej,
+ timeoutId: setTimeout(function () {
+ connection.reset(id);
+ }, ACTION_TIMEOUT_MS),
+ });
+ const message: NativeMessage = {
+ data: dataToSend.toString('base64'),
+ connectionId: id,
+ };
+ if (connection.promises.length === 1) {
+ console.log('Sending message immediately');
+ sendNativeMessage(message);
+ } else {
+ console.log('Queing message because others in flight');
+ connection.outBuffer.push(message);
+ }
+ });
+ }
+}
+
+export function createAppClient>(
+ ServiceClient: IClientConstructor,
+ transport: TransportType = 'buffered',
+ protocol: ProtocolType = 'compact',
+): TClient {
+ class NamedMultiplexedProtocol extends TMultiplexedProtocol {
+ constructor(transport: TTransport) {
+ const Protocol = getProtocol(protocol);
+ super(new Protocol(transport), ServiceClient.serviceName ?? '');
+ }
+ }
+ const connection = new NativeConnection(
+ getTransport(transport),
+ NamedMultiplexedProtocol,
+ );
+ return new ServiceClient(connection);
+}
diff --git a/dotcom-rendering/src/app/native/thrift/protocols.ts b/dotcom-rendering/src/app/native/thrift/protocols.ts
new file mode 100644
index 00000000000..8878b7f4458
--- /dev/null
+++ b/dotcom-rendering/src/app/native/thrift/protocols.ts
@@ -0,0 +1,176 @@
+import type {
+ Int64,
+ IThriftField,
+ IThriftList,
+ IThriftMap,
+ IThriftMessage,
+ IThriftSet,
+ IThriftStruct,
+ TTransport,
+ TType,
+} from '@creditkarma/thrift-server-core';
+import { MessageType, TProtocol } from '@creditkarma/thrift-server-core';
+
+export abstract class TProtocolDecorator extends TProtocol {
+ private concreteProtocol: TProtocol;
+
+ constructor(protocol: TProtocol) {
+ super(protocol.getTransport());
+ this.concreteProtocol = protocol;
+ }
+
+ getTransport(): TTransport {
+ return this.concreteProtocol.getTransport();
+ }
+ flush(): Buffer {
+ return this.concreteProtocol.flush();
+ }
+ writeMessageBegin(name: string, type: MessageType, seqid: number): void {
+ return this.concreteProtocol.writeMessageBegin(name, type, seqid);
+ }
+ writeMessageEnd(): void {
+ return this.concreteProtocol.writeMessageEnd();
+ }
+ writeStructBegin(name: string): void {
+ return this.concreteProtocol.writeStructBegin(name);
+ }
+ writeStructEnd(): void {
+ return this.concreteProtocol.writeStructEnd();
+ }
+ writeFieldBegin(name: string, type: TType, id: number): void {
+ return this.concreteProtocol.writeFieldBegin(name, type, id);
+ }
+ writeFieldEnd(): void {
+ return this.concreteProtocol.writeFieldEnd();
+ }
+ writeFieldStop(): void {
+ return this.concreteProtocol.writeFieldStop();
+ }
+ writeMapBegin(keyType: TType, valueType: TType, size: number): void {
+ return this.concreteProtocol.writeMapBegin(keyType, valueType, size);
+ }
+ writeMapEnd(): void {
+ return this.concreteProtocol.writeMapEnd();
+ }
+ writeListBegin(elementType: TType, size: number): void {
+ return this.concreteProtocol.writeListBegin(elementType, size);
+ }
+ writeListEnd(): void {
+ return this.concreteProtocol.writeListEnd();
+ }
+ writeSetBegin(elementType: TType, size: number): void {
+ return this.concreteProtocol.writeSetBegin(elementType, size);
+ }
+ writeSetEnd(): void {
+ return this.concreteProtocol.writeSetEnd();
+ }
+ writeBool(bool: boolean): void {
+ return this.concreteProtocol.writeBool(bool);
+ }
+ writeByte(b: number): void {
+ return this.concreteProtocol.writeByte(b);
+ }
+ writeI16(i16: number): void {
+ return this.concreteProtocol.writeI16(i16);
+ }
+ writeI32(i32: number): void {
+ return this.concreteProtocol.writeI32(i32);
+ }
+ writeI64(i64: number | Int64): void {
+ return this.concreteProtocol.writeI64(i64);
+ }
+ writeDouble(dbl: number): void {
+ return this.concreteProtocol.writeDouble(dbl);
+ }
+ writeString(arg: string): void {
+ return this.concreteProtocol.writeString(arg);
+ }
+ writeBinary(arg: string | Buffer): void {
+ return this.concreteProtocol.writeBinary(arg);
+ }
+ readMessageBegin(): IThriftMessage {
+ return this.concreteProtocol.readMessageBegin();
+ }
+ readMessageEnd(): void {
+ return this.concreteProtocol.readMessageEnd();
+ }
+ readStructBegin(): IThriftStruct {
+ return this.concreteProtocol.readStructBegin();
+ }
+ readStructEnd(): void {
+ return this.concreteProtocol.readStructEnd();
+ }
+ readFieldBegin(): IThriftField {
+ return this.concreteProtocol.readFieldBegin();
+ }
+ readFieldEnd(): void {
+ return this.concreteProtocol.readFieldEnd();
+ }
+ readMapBegin(): IThriftMap {
+ return this.concreteProtocol.readMapBegin();
+ }
+ readMapEnd(): void {
+ return this.concreteProtocol.readMapEnd();
+ }
+ readListBegin(): IThriftList {
+ return this.concreteProtocol.readListBegin();
+ }
+ readListEnd(): void {
+ return this.concreteProtocol.readListEnd();
+ }
+ readSetBegin(): IThriftSet {
+ return this.concreteProtocol.readSetBegin();
+ }
+ readSetEnd(): void {
+ return this.concreteProtocol.readSetEnd();
+ }
+ readBool(): boolean {
+ return this.concreteProtocol.readBool();
+ }
+ readByte(): number {
+ return this.concreteProtocol.readByte();
+ }
+ readI16(): number {
+ return this.concreteProtocol.readI16();
+ }
+ readI32(): number {
+ return this.concreteProtocol.readI32();
+ }
+ readI64(): Int64 {
+ return this.concreteProtocol.readI64();
+ }
+ readDouble(): number {
+ return this.concreteProtocol.readDouble();
+ }
+ readBinary(): Buffer {
+ return this.concreteProtocol.readBinary();
+ }
+ readString(): string {
+ return this.concreteProtocol.readString();
+ }
+ skip(type: TType): void {
+ return this.concreteProtocol.skip(type);
+ }
+}
+
+export class TMultiplexedProtocol extends TProtocolDecorator {
+ static readonly separator = ':';
+ readonly serviceName: string;
+
+ constructor(protocol: TProtocol, serviceName: string) {
+ super(protocol);
+ this.serviceName = serviceName;
+ }
+
+ writeMessageBegin(name: string, type: MessageType, seqid: number): void {
+ if (type === MessageType.CALL || type === MessageType.ONEWAY) {
+ super.writeMessageBegin(
+ this.serviceName + TMultiplexedProtocol.separator + name,
+ type,
+ seqid,
+ );
+ } else {
+ super.writeMessageBegin(name, type, seqid);
+ }
+ }
+}
diff --git a/dotcom-rendering/src/app/server/articleToHtml.tsx b/dotcom-rendering/src/app/server/articleToHtml.tsx
new file mode 100644
index 00000000000..05d33dfb200
--- /dev/null
+++ b/dotcom-rendering/src/app/server/articleToHtml.tsx
@@ -0,0 +1,195 @@
+import createCache from '@emotion/cache';
+import { CacheProvider } from '@emotion/react';
+import createEmotionServer from '@emotion/server/create-instance';
+import { ArticleDesign, ArticlePillar } from '@guardian/libs';
+import { renderToString } from 'react-dom/server';
+import { generateScriptTags, getAppScript } from '../../lib/assets';
+import { escapeData } from '../../lib/escapeData';
+// import { makeWindowGuardian } from '../../model/window-guardian';
+import type { CAPIElement } from '../../types/content';
+import type { FEArticleType } from '../../types/frontend';
+import { decideFormat } from '../../web/lib/decideFormat';
+import { decideTheme } from '../../web/lib/decideTheme';
+import { getHttp3Url } from '../../web/lib/getHttp3Url';
+import { ArticlePage } from '../components/ArticlePage';
+import { pageTemplate } from './pageTemplate';
+
+interface Props {
+ article: FEArticleType;
+}
+
+const decideTitle = (article: FEArticleType): string => {
+ if (
+ decideTheme(article.format) === ArticlePillar.Opinion &&
+ article.byline
+ ) {
+ return `${article.headline} | ${article.byline}`;
+ }
+ return `${article.headline} | ${article.sectionLabel}`;
+};
+
+export const articleToHtml = ({
+ article,
+}: Props): { html: string; clientScript: string } => {
+ const title = decideTitle(article);
+ const key = 'ar';
+ const cache = createCache({ key });
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const { extractCriticalToChunks, constructStyleTagsFromChunks } =
+ createEmotionServer(cache);
+
+ const format: ArticleFormat = decideFormat(article.format);
+
+ const html = renderToString(
+
+
+ ,
+ );
+
+ const chunks = extractCriticalToChunks(html);
+ const extractedCss = constructStyleTagsFromChunks(chunks);
+
+ // We want to only insert script tags for the elements or main media elements on this page view
+ // so we need to check what elements we have and use the mapping to the the chunk name
+ const CAPIElements: CAPIElement[] = article.blocks
+ .map((block) => block.elements)
+ .flat();
+
+ // Evaluating the performance of HTTP3 over HTTP2
+ // See: https://github.com/guardian/dotcom-rendering/pull/5394
+ const { offerHttp3 = false } = article.config.switches;
+
+ // const pageHasNonBootInteractiveElements = CAPIElements.some(
+ // (element) =>
+ // element._type ===
+ // 'model.dotcomrendering.pageElements.InteractiveBlockElement' &&
+ // element.scriptUrl !==
+ // 'https://interactive.guim.co.uk/embed/iframe-wrapper/0.1/boot.js', // We have rewritten this standard behaviour into Dotcom Rendering
+ // );
+
+ const pageHasTweetElements = CAPIElements.some(
+ (element) =>
+ element._type ===
+ 'model.dotcomrendering.pageElements.TweetBlockElement',
+ );
+
+ // const shouldServeVariantBundle: boolean = [
+ // BUILD_VARIANT,
+ // article.config.abTests[dcrJavascriptBundle('Variant')] === 'variant',
+ // ].every(Boolean);
+
+ /**
+ * This function returns an array of files found in the manifests
+ * defined by `manifestPaths`.
+ *
+ * @see getScriptsFromManifest
+ */
+ // const getScriptArrayFromFile = getScriptsFromManifest(
+ // shouldServeVariantBundle,
+ // );
+
+ /**
+ * The highest priority scripts.
+ * These scripts have a considerable impact on site performance.
+ * Only scripts critical to application execution may go in here.
+ * Please talk to the dotcom platform team before adding more.
+ * Scripts will be executed in the order they appear in this array
+ */
+ const clientScript = getAppScript();
+ const priorityScriptTags = generateScriptTags(
+ [clientScript].map((script) =>
+ offerHttp3 && script ? getHttp3Url(script) : script,
+ ),
+ );
+
+ const lowPriorityScriptTags: string[] = [];
+
+ /**
+ * We escape windowGuardian here to prevent errors when the data
+ * is placed in a script tag on the page
+ */
+ const windowGuardian = escapeData(
+ JSON.stringify({
+ config: {
+ frontendAssetsFullURL: article.config.frontendAssetsFullURL,
+ page: {
+ ajaxUrl: article.config.ajaxUrl,
+ },
+ },
+ }),
+ );
+ // const windowGuardian = escapeData(
+ // JSON.stringify(
+ // makeWindowGuardian({
+ // editionId: article.editionId,
+ // stage: article.config.stage,
+ // frontendAssetsFullURL: article.config.frontendAssetsFullURL,
+ // revisionNumber: article.config.revisionNumber,
+ // sentryPublicApiKey: article.config.sentryPublicApiKey,
+ // sentryHost: article.config.sentryHost,
+ // keywordIds: article.config.keywordIds,
+ // dfpAccountId: article.config.dfpAccountId,
+ // adUnit: article.config.adUnit,
+ // ajaxUrl: article.config.ajaxUrl,
+ // googletagUrl: article.config.googletagUrl,
+ // switches: article.config.switches,
+ // abTests: article.config.abTests,
+ // brazeApiKey: article.config.brazeApiKey,
+ // isPaidContent: article.pageType.isPaidContent,
+ // contentType: article.contentType,
+ // shouldHideReaderRevenue: article.shouldHideReaderRevenue,
+ // GAData: extractGA({
+ // webTitle: article.webTitle,
+ // format: article.format,
+ // sectionName: article.sectionName,
+ // contentType: article.contentType,
+ // tags: article.tags,
+ // pageId: article.pageId,
+ // editionId: article.editionId,
+ // beaconURL: article.beaconURL,
+ // }),
+ // unknownConfig: article.config,
+ // }),
+ // ),
+ // );
+
+ const initTwitter = `
+`;
+
+ return {
+ html: pageTemplate({
+ priorityScriptTags,
+ lowPriorityScriptTags,
+ css: extractedCss,
+ html,
+ title,
+ description: article.trailText,
+ windowGuardian,
+ initTwitter:
+ pageHasTweetElements || format.design === ArticleDesign.LiveBlog
+ ? initTwitter
+ : undefined,
+ offerHttp3,
+ }),
+ clientScript,
+ };
+};
diff --git a/dotcom-rendering/src/app/server/index.ts b/dotcom-rendering/src/app/server/index.ts
new file mode 100644
index 00000000000..87982b3c339
--- /dev/null
+++ b/dotcom-rendering/src/app/server/index.ts
@@ -0,0 +1,139 @@
+import type { RequestHandler } from 'express';
+import { Standard as ExampleArticle } from '../../../fixtures/generated/articles/Standard';
+import { isRecipe } from '../../model/enhance-recipes';
+import { enhanceBlocks } from '../../model/enhanceBlocks';
+import { enhanceCommercialProperties } from '../../model/enhanceCommercialProperties';
+import { enhanceStandfirst } from '../../model/enhanceStandfirst';
+import { enhanceTableOfContents } from '../../model/enhanceTableOfContents';
+import { validateAsCAPIType } from '../../model/validate';
+import type { FEArticleType } from '../../types/frontend';
+import { articleToHtml } from './articleToHtml';
+
+function enhancePinnedPost(format: CAPIFormat, block?: Block) {
+ return block ? enhanceBlocks([block], format)[0] : block;
+}
+
+const enhanceCAPIType = (body: unknown): FEArticleType => {
+ const data = validateAsCAPIType(body);
+
+ const enhancedBlocks = enhanceBlocks(data.blocks, data.format, {
+ promotedNewsletter: data.promotedNewsletter,
+ isRecipe: isRecipe(data.tags),
+ });
+
+ const CAPIArticle: FEArticleType = {
+ ...data,
+ blocks: enhancedBlocks,
+ pinnedPost: enhancePinnedPost(data.format, data.pinnedPost),
+ standfirst: enhanceStandfirst(data.standfirst),
+ commercialProperties: enhanceCommercialProperties(
+ data.commercialProperties,
+ ),
+ tableOfContents: data.config.switches.tableOfContents
+ ? enhanceTableOfContents(data.format, enhancedBlocks)
+ : undefined,
+ };
+ return CAPIArticle;
+};
+
+const getStack = (e: unknown): string =>
+ e instanceof Error ? e.stack ?? 'No error stack' : 'Unknown error';
+
+const getPrefetchHeader = (script: string): string => {
+ return `<${script}>; rel=prefetch`;
+};
+
+export const handleArticle: RequestHandler = ({ body }, res) => {
+ try {
+ const article = enhanceCAPIType(body);
+ const { html, clientScript } = articleToHtml({
+ article,
+ });
+
+ res.set('Link', getPrefetchHeader(clientScript));
+ res.status(200).send(html);
+ } catch (e) {
+ res.status(500).send(`${getStack(e)}`);
+ }
+};
+
+export const handleArticleJson: RequestHandler = ({ body }, res) => {
+ try {
+ const CAPIArticle = enhanceCAPIType(body);
+ const resp = {
+ data: {
+ CAPIArticle,
+ },
+ };
+
+ res.status(200).send(resp);
+ } catch (e) {
+ res.status(500).send(`${getStack(e)}`);
+ }
+};
+
+export const handlePerfTest: RequestHandler = (req, res, next) => {
+ req.body = ExampleArticle;
+ handleArticle(req, res, next);
+};
+
+export const handleInteractive: RequestHandler = ({ body }, res) => {
+ try {
+ const article = enhanceCAPIType(body);
+ const resp = articleToHtml({
+ article,
+ });
+
+ res.status(200).send(resp.html);
+ } catch (e) {
+ res.status(500).send(`${getStack(e)}`);
+ }
+};
+
+// We might need this for liveblogs
+// export const handleBlocks: RequestHandler = ({ body }, res) => {
+// try {
+// const {
+// blocks,
+// format,
+// host,
+// pageId,
+// webTitle,
+// ajaxUrl,
+// isAdFreeUser,
+// isSensitive,
+// videoDuration,
+// edition,
+// section,
+// sharedAdTargeting,
+// adUnit,
+// switches,
+// keywordIds,
+// } =
+// // The content if body is not checked
+// body as BlocksRequest;
+
+// const enhancedBlocks = enhanceBlocks(blocks, format);
+// const html = blocksToHtml({
+// blocks: enhancedBlocks,
+// format,
+// host,
+// pageId,
+// webTitle,
+// ajaxUrl,
+// isAdFreeUser,
+// isSensitive,
+// videoDuration,
+// edition,
+// section,
+// sharedAdTargeting,
+// adUnit,
+// switches,
+// keywordIds,
+// });
+
+// res.status(200).send(html);
+// } catch (e) {
+// res.status(500).send(`${getStack(e)}`);
+// }
+// };
diff --git a/dotcom-rendering/src/app/server/pageTemplate.ts b/dotcom-rendering/src/app/server/pageTemplate.ts
new file mode 100644
index 00000000000..9acf125cec7
--- /dev/null
+++ b/dotcom-rendering/src/app/server/pageTemplate.ts
@@ -0,0 +1,207 @@
+import { brandBackground, resets } from '@guardian/source-foundations';
+import he from 'he';
+import { ASSET_ORIGIN } from '../../lib/assets';
+import { getFontsCss } from '../../lib/fonts-css';
+import { getHttp3Url } from '../../web/lib/getHttp3Url';
+
+export const pageTemplate = ({
+ css,
+ html,
+ windowGuardian,
+ priorityScriptTags,
+ lowPriorityScriptTags,
+ offerHttp3,
+ title = 'The Guardian',
+ description = 'Latest news, sport, business, comment, analysis and reviews from the Guardian, the world's leading liberal voice',
+ initTwitter,
+}: {
+ css: string;
+ html: string;
+ windowGuardian: string;
+ priorityScriptTags: string[];
+ lowPriorityScriptTags: string[];
+ offerHttp3: boolean;
+ title?: string;
+ description?: string;
+ initTwitter?: string;
+}): string => {
+ const favicon =
+ process.env.NODE_ENV === 'production'
+ ? 'favicon-32x32.ico'
+ : 'favicon-32x32-dev-yellow.ico';
+
+ /**
+ * Preload the following woff2 font files
+ * TODO: Identify critical fonts to preload
+ */
+ const fontFiles = [
+ // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Light.woff2',
+ // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-LightItalic.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Medium.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-MediumItalic.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Bold.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-Regular.woff2',
+ // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-RegularItalic.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-Bold.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-Regular.woff2',
+ // 'http://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-RegularItalic.woff2',
+ 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-Bold.woff2',
+ ].map((font) => (offerHttp3 ? getHttp3Url(font) : font));
+
+ const fontPreloadTags = fontFiles.map(
+ (fontFile) =>
+ ``,
+ );
+
+ // Opt out of having information from our website used for personalization of content and suggestions for Twitter users, including ads
+ // See https://developer.twitter.com/en/docs/twitter-for-websites/webpage-properties/overview
+ const twitterSecAndPrivacyMetaTags = ``;
+
+ // Duplicated prefetch and preconnect tags from DCP:
+ // Documented here: https://github.com/guardian/frontend/pull/12935
+ // Preconnect should be used for the most crucial third party domains
+ // "use preconnect when you know for sure that you’re going to be accessing a resource"
+ // - https://www.smashingmagazine.com/2019/04/optimization-performance-resource-hints/
+ // DNS-prefetch should be used for other third party domains that we are likely to connect to but not sure (ads)
+ // Preconnecting to too many URLs can reduce page performance
+ // DNS-prefetch can also be used as a fallback for IE11
+ // More information on preconnecting:
+ // https://css-tricks.com/using-relpreconnect-to-establish-network-connections-early-and-increase-performance/
+ // More information on prefetching:
+ // https://developer.mozilla.org/en-US/docs/Web/Performance/dns-prefetch
+ const staticPreconnectUrls = [
+ `${ASSET_ORIGIN}`,
+ `https://i.guim.co.uk`,
+ `https://j.ophan.co.uk`,
+ `https://ophan.theguardian.com`,
+ ];
+
+ const staticPrefetchUrls = [
+ ...staticPreconnectUrls,
+ `https://api.nextgen.guardianapps.co.uk`,
+ `https://hits-secure.theguardian.com`,
+ `https://interactive.guim.co.uk`,
+ `https://phar.gu-web.net`,
+ `https://static.theguardian.com`,
+ `https://support.theguardian.com`,
+ ];
+
+ const allStaticPreconnectUrls =
+ process.env.NODE_ENV === 'production'
+ ? [...staticPreconnectUrls, 'https://sourcepoint.theguardian.com']
+ : staticPreconnectUrls;
+
+ const preconnectTags = allStaticPreconnectUrls.map(
+ (src) => ``,
+ );
+
+ const prefetchTags = staticPrefetchUrls.map(
+ (src) => ``,
+ );
+
+ const weAreHiringMessage = `
+`;
+
+ return `
+
+
+ ${weAreHiringMessage}
+ ${title}
+
+
+
+
+
+
+
+ ${preconnectTags.join('\n')}
+ ${prefetchTags.join('\n')}
+
+
+ ${fontPreloadTags.join('\n')}
+
+ ${twitterSecAndPrivacyMetaTags}
+
+
+
+
+
+
+
+
+
+
+ ${initTwitter ?? ''}
+
+ ${priorityScriptTags.join('\n')}
+
+
+
+ ${css}
+
+
+
+
+ ${html}
+ ${[...lowPriorityScriptTags].join('\n')}
+
+ `;
+};
diff --git a/dotcom-rendering/src/lib/assets.ts b/dotcom-rendering/src/lib/assets.ts
index 3a55a922666..c53c62ec698 100644
--- a/dotcom-rendering/src/lib/assets.ts
+++ b/dotcom-rendering/src/lib/assets.ts
@@ -15,6 +15,7 @@ interface AssetHash {
export const decideAssetOrigin = (
stage: string | undefined,
isDev: boolean,
+ isApp?: boolean,
): string => {
switch (stage?.toUpperCase()) {
case 'PROD':
@@ -22,7 +23,10 @@ export const decideAssetOrigin = (
case 'CODE':
return 'https://assets-code.guim.co.uk/';
default: {
- if (isDev) {
+ // We shouldn't assume localhost for apps testing
+ // The app emulator does not connect on localhost, so
+ // assuming localhost causes asset resolution to fail
+ if (isDev && !isApp) {
// Use absolute asset paths in development mode
// This is so paths are correct when treated as relative to Frontend
return 'http://localhost:3030/';
@@ -94,6 +98,20 @@ const getScripts = (
});
};
+export const getAppScript = (): string => {
+ const appAssetOrigin = decideAssetOrigin(process.env.GU_STAGE, isDev, true);
+ if (isDev) {
+ return `${appAssetOrigin}assets/main.apps.js`;
+ }
+
+ const manifest = getManifest('./manifest.apps.json');
+ const mainJs = manifest['main.js'];
+ if (!mainJs) {
+ throw new Error(`main.js not in manifest`);
+ }
+ return `${appAssetOrigin}assets/${mainJs}`;
+};
+
/**
* A curried function that takes an array of manifests.
*
diff --git a/dotcom-rendering/src/server/dev-server.ts b/dotcom-rendering/src/server/dev-server.ts
index 7bd55544a53..53375f21a7a 100644
--- a/dotcom-rendering/src/server/dev-server.ts
+++ b/dotcom-rendering/src/server/dev-server.ts
@@ -1,5 +1,6 @@
import type { Handler } from 'express';
import { handleAMPArticle } from '../amp/server';
+import { handleArticle as handleAppArticle } from '../app/server';
import {
handleArticle,
handleArticleJson,
@@ -38,6 +39,8 @@ export const devServer = (): Handler => {
return handleFront(req, res, next);
case '/FrontJSON':
return handleFrontJson(req, res, next);
+ case '/mobile-apps/Article':
+ return handleAppArticle(req, res, next);
default: {
if (req.url.match(ARTICLE_URL)) {
const url = new URL(
diff --git a/dotcom-rendering/src/server/prod-server.ts b/dotcom-rendering/src/server/prod-server.ts
index a87183935fc..fd229259250 100644
--- a/dotcom-rendering/src/server/prod-server.ts
+++ b/dotcom-rendering/src/server/prod-server.ts
@@ -17,6 +17,7 @@ import {
handleInteractive,
handleKeyEvents,
} from '../web/server';
+import { handleArticle as handleAppArticle } from '../app/server';
import { recordBaselineCloudWatchMetrics } from './lib/aws/metrics-baseline';
import { getContentFromURLMiddleware } from './lib/get-content-from-url';
import { logger } from './lib/logging';
@@ -53,6 +54,7 @@ export const prodServer = (): void => {
}
app.post('/Article', logRenderTime, handleArticle);
+ app.post('/mobile-apps/Article', logRenderTime, handleAppArticle);
app.post('/AMPArticle', logRenderTime, handleAMPArticle);
app.post('/Interactive', logRenderTime, handleInteractive);
app.post('/AMPInteractive', logRenderTime, handleAMPArticle);
@@ -70,6 +72,13 @@ export const prodServer = (): void => {
);
app.use('/ArticleJson', handleArticleJson);
+ app.get(
+ '/mobile-apps/Article',
+ logRenderTime,
+ getContentFromURLMiddleware,
+ handleAppArticle,
+ );
+
app.get(
'/AMPArticle',
logRenderTime,
diff --git a/dotcom-rendering/src/types/palette.ts b/dotcom-rendering/src/types/palette.ts
index e98c063d15b..6ff4bcc4f2b 100644
--- a/dotcom-rendering/src/types/palette.ts
+++ b/dotcom-rendering/src/types/palette.ts
@@ -8,6 +8,7 @@ export type Palette = {
sectionTitle: Colour;
seriesTitleWhenMatch: Colour;
byline: Colour;
+ follow: Colour;
twitterHandle: Colour;
twitterHandleBelowDesktop: Colour;
caption: Colour;
diff --git a/dotcom-rendering/src/types/platform.ts b/dotcom-rendering/src/types/platform.ts
new file mode 100644
index 00000000000..cb2899d4e83
--- /dev/null
+++ b/dotcom-rendering/src/types/platform.ts
@@ -0,0 +1,6 @@
+export enum Platform {
+ Web,
+ AMP,
+ Apps,
+ Editions,
+}
diff --git a/dotcom-rendering/src/types/props.ts b/dotcom-rendering/src/types/props.ts
new file mode 100644
index 00000000000..95f1c6e51d8
--- /dev/null
+++ b/dotcom-rendering/src/types/props.ts
@@ -0,0 +1,12 @@
+import type { Platform } from './platform';
+
+type AppsProps = Props extends void
+ ? { platform: Platform.Apps }
+ : Props & { platform: Platform.Apps };
+type WebProps = Props extends void
+ ? { platform: Platform.Web }
+ : Props & { platform: Platform.Web };
+
+type AppsOrWeb = AppsProps | WebProps;
+
+export type CombinedProps = Common & AppsOrWeb;
diff --git a/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts b/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts
new file mode 100644
index 00000000000..a678c6da8f0
--- /dev/null
+++ b/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts
@@ -0,0 +1,8 @@
+export const getIslandsByName = (name: string): HTMLElement[] => {
+ const rawElements = document.querySelectorAll(`gu-island[name="${name}"]`);
+ const elements: HTMLElement[] = [];
+ rawElements.forEach((element) =>
+ element instanceof HTMLElement ? elements.push(element) : null,
+ );
+ return elements;
+};
diff --git a/dotcom-rendering/src/web/browser/islands/init.ts b/dotcom-rendering/src/web/browser/islands/init.ts
index 9baa2895471..1b27223857d 100644
--- a/dotcom-rendering/src/web/browser/islands/init.ts
+++ b/dotcom-rendering/src/web/browser/islands/init.ts
@@ -4,10 +4,14 @@ import { startup } from '../startup';
import { initHydration } from './initHydration';
const init = () => {
+ console.log('hello world2');
+
const elements = document.querySelectorAll('gu-island');
initHydration(elements);
return Promise.resolve();
};
+console.log('hello world');
+
startup('islands', null, init);
diff --git a/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx b/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx
new file mode 100644
index 00000000000..49b0ef98762
--- /dev/null
+++ b/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx
@@ -0,0 +1,60 @@
+import { css } from '@emotion/react';
+import { Image } from '@guardian/bridget/Image';
+import { galleryClient } from '../../app/native/nativeApi';
+import { getIslandsByName } from '../browser/islands/getIsandsByName';
+import { getProps } from '../browser/islands/getProps';
+
+type Props = {
+ master: string;
+ width: string;
+ height: string;
+ caption?: string;
+ credit?: string;
+};
+
+const buttonStyles = css`
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+`;
+
+const getLightboxElements = (): Props[] => {
+ const elements = getIslandsByName('AppsLightbox');
+ return elements.map((element) => getProps(element) as Props);
+};
+
+export const AppsLightbox = (props: Props) => {
+ const launchLightbox = () => {
+ const elements = getLightboxElements();
+
+ const bridgetElements = elements.map(
+ ({ master, width, height, caption, credit }) =>
+ new Image({
+ url: master,
+ caption,
+ credit,
+ width: parseInt(width),
+ height: parseInt(height),
+ }),
+ );
+
+ const selectedIndex = bridgetElements.findIndex(
+ (element) => element.url === props.master,
+ );
+
+ void galleryClient.launchSlideshow(
+ bridgetElements,
+ selectedIndex,
+ document.title,
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/dotcom-rendering/src/web/components/ArticleBody.tsx b/dotcom-rendering/src/web/components/ArticleBody.tsx
index 6117b3f4fac..25964b9d667 100644
--- a/dotcom-rendering/src/web/components/ArticleBody.tsx
+++ b/dotcom-rendering/src/web/components/ArticleBody.tsx
@@ -5,19 +5,24 @@ import { between, body, headline, space } from '@guardian/source-foundations';
import { isRecipe } from '../../model/enhance-recipes';
import type { Switches } from '../../types/config';
import type { Palette } from '../../types/palette';
+import { Platform } from '../../types/platform';
+import type { CombinedProps } from '../../types/props';
import type { TagType } from '../../types/tag';
import { ArticleRenderer } from '../lib/ArticleRenderer';
import { decidePalette } from '../lib/decidePalette';
-import { LiveBlogRenderer } from '../lib/LiveBlogRenderer';
+import {
+ AppsLiveBlogRenderer,
+ WebLiveBlogRenderer,
+} from '../lib/LiveBlogRenderer';
import { revealStyles } from '../lib/revealStyles';
import { Island } from './Island';
import { RecipeMultiplier } from './RecipeMultiplier.importable';
-type Props = {
+type CommonProps = {
format: ArticleFormat;
blocks: Block[];
pinnedPost?: Block;
- adTargeting: AdTargeting;
+ adTargeting?: AdTargeting;
host?: string;
pageId: string;
webTitle: string;
@@ -28,7 +33,6 @@ type Props = {
shouldHideReaderRevenue: boolean;
tags: TagType[];
isPaidContent: boolean;
- contributionsServiceUrl: string;
contentType: string;
sectionName: string;
keywordIds: string;
@@ -41,6 +45,12 @@ type Props = {
filterKeyEvents?: boolean;
availableTopics?: Topic[];
selectedTopics?: Topic[];
+};
+
+type AppsProps = void;
+
+type WebProps = {
+ contributionsServiceUrl: string;
isInLiveblogAdSlotTest?: boolean;
};
@@ -108,36 +118,38 @@ const globalLinkStyles = (palette: Palette) => css`
}
`;
-export const ArticleBody = ({
- format,
- blocks,
- pinnedPost,
- adTargeting,
- host,
- pageId,
- webTitle,
- ajaxUrl,
- switches,
- isAdFreeUser,
- section,
- shouldHideReaderRevenue,
- tags,
- isPaidContent,
- contributionsServiceUrl,
- contentType,
- sectionName,
- isPreview,
- idUrl,
- isSensitive,
- isDev,
- onFirstPage,
- keyEvents,
- filterKeyEvents,
- availableTopics,
- selectedTopics,
- keywordIds,
- isInLiveblogAdSlotTest = false,
-}: Props) => {
+const ArticleBody = (
+ props: CombinedProps,
+) => {
+ const {
+ format,
+ blocks,
+ pinnedPost,
+ adTargeting,
+ host,
+ pageId,
+ webTitle,
+ ajaxUrl,
+ switches,
+ isAdFreeUser,
+ section,
+ shouldHideReaderRevenue,
+ tags,
+ isPaidContent,
+ contentType,
+ sectionName,
+ isPreview,
+ idUrl,
+ isSensitive,
+ isDev,
+ onFirstPage,
+ keyEvents,
+ filterKeyEvents,
+ availableTopics,
+ selectedTopics,
+ keywordIds,
+ platform,
+ } = props;
const isInteractive = format.design === ArticleDesign.Interactive;
const palette = decidePalette(format);
@@ -161,32 +173,59 @@ export const ArticleBody = ({
revealStyles,
]}
>
-
+ {platform === Platform.Web ? (
+
+ ) : (
+
+ )}
);
}
@@ -225,7 +264,16 @@ export const ArticleBody = ({
isDev={isDev}
isAdFreeUser={isAdFreeUser}
isSensitive={isSensitive}
+ platform={platform}
/>
);
};
+
+export const WebArticleBody = (props: CommonProps & WebProps) => (
+
+);
+
+export const AppsArticleBody = (props: CommonProps) => (
+
+);
diff --git a/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx b/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx
index 860614cf5aa..434a07d5e88 100644
--- a/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx
+++ b/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx
@@ -6,6 +6,7 @@ import {
ArticleSpecial,
} from '@guardian/libs';
import { news } from '@guardian/source-foundations';
+import { Platform } from '../../types/platform';
import { ArticleContainer } from './ArticleContainer';
import { ArticleHeadline } from './ArticleHeadline';
import { mainMediaElements } from './ArticleHeadline.mocks';
@@ -108,6 +109,7 @@ export const ShowcaseInterview = () => {
isAdFreeUser={false}
isSensitive={false}
switches={{}}
+ platform={Platform.Web}
/>
@@ -152,6 +154,7 @@ export const ShowcaseInterviewNobyline = () => {
isAdFreeUser={false}
isSensitive={false}
switches={{}}
+ platform={Platform.Web}
/>
@@ -196,6 +199,7 @@ export const Interview = () => {
isAdFreeUser={false}
isSensitive={false}
switches={{}}
+ platform={Platform.Web}
/>
@@ -238,6 +242,7 @@ export const InterviewSpecialReport = () => {
isAdFreeUser={false}
isSensitive={false}
switches={{}}
+ platform={Platform.Web}
/>
@@ -282,6 +287,7 @@ export const InterviewNoByline = () => {
isAdFreeUser={false}
isSensitive={false}
switches={{}}
+ platform={Platform.Web}
/>
diff --git a/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx b/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx
index dd488b50364..3ce6c16e822 100644
--- a/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx
+++ b/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx
@@ -5,6 +5,7 @@ import {
ArticlePillar,
ArticleSpecial,
} from '@guardian/libs';
+import { Platform } from '../../types/platform';
import { getAllThemes, getThemeNameAsString } from '../lib/format';
import { ArticleMeta } from './ArticleMeta';
@@ -69,6 +70,7 @@ export const ArticleStory = () => {
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
return (
{
{getThemeNameAsString(format)}
{
return (
{
const { container } = render(
{
const { container } = render(
+ {platform === Platform.Apps && (
+
+
+
+ )}
>
diff --git a/dotcom-rendering/src/web/components/Follow.importable.tsx b/dotcom-rendering/src/web/components/Follow.importable.tsx
new file mode 100644
index 00000000000..e38fc2a278c
--- /dev/null
+++ b/dotcom-rendering/src/web/components/Follow.importable.tsx
@@ -0,0 +1,144 @@
+import { css } from '@emotion/react';
+import { Topic } from '@guardian/bridget/Topic';
+import { ArticleSpecial } from '@guardian/libs';
+import { space, textSans } from '@guardian/source-foundations';
+import { useCallback, useEffect, useState } from 'react';
+import { notificationsClient } from '../../app/native/nativeApi';
+import type { Palette } from '../../types/palette';
+import type { TagType } from '../../types/tag';
+import { decidePalette } from '../lib/decidePalette';
+
+type Props = {
+ tags: TagType[];
+ format: ArticleFormat;
+};
+
+const getContributorTags = (tags: TagType[]): TagType[] =>
+ tags.filter((tag) => tag.type === 'Contributor');
+
+const FollowIcon = ({ isFollowing }: { isFollowing: boolean }) => {
+ const check =
+ 'M16.171 10.64L15.411 11.34L16.206 12.09L16.947 12.806L20 9.924L19.259 9.208L16.947 11.391L16.171 10.641L16.171 10.64Z';
+ const plus =
+ 'M17.667 13.5h-1v-2.167H14.5v-1h2.167V8.167h1v2.166h2.166v1h-2.166V13.5z';
+
+ return (
+
+ );
+};
+
+const buttonStyles = (palette: Palette) => css`
+ ${textSans.small()}
+ color: ${palette.text.follow};
+ display: block;
+ padding: 0;
+ border: none;
+ background: none;
+ margin-left: 0;
+ margin-top: ${space[1]}px;
+ min-height: ${space[6]}px;
+
+ svg {
+ width: ${space[6]}px;
+ height: ${space[6]}px;
+ fill: currentColor;
+ }
+
+ ${
+ /*darkModeCss`
+ color: ${text.followDark(format)};
+ `*/ ''
+ }
+`;
+
+const spanStyles = css`
+ display: flex;
+ align-items: center;
+ column-gap: 0.2em;
+`;
+
+export const Follow = ({ tags, format }: Props) => {
+ const contributors = getContributorTags(tags);
+ if (contributors.length !== 1 || format.theme == ArticleSpecial.Labs)
+ return null;
+
+ const contributor = contributors[0];
+ if (!contributor || !contributor.id.startsWith('profile/')) return null;
+
+ return ;
+};
+
+export const FollowButton = ({
+ contributor,
+ format,
+}: {
+ contributor: TagType;
+ format: ArticleFormat;
+}) => {
+ const palette = decidePalette(format);
+
+ const [isFollowing, setIsFollowing] = useState(false);
+
+ useEffect(() => {
+ const topic = new Topic({
+ id: contributor.id,
+ displayName: contributor.title,
+ type: 'tag-contributor',
+ });
+
+ notificationsClient
+ .isFollowing(topic)
+ .then((value) => {
+ setIsFollowing(value);
+ })
+ .catch(() => {});
+ }, [contributor]);
+
+ const onFollowingClick = useCallback(() => {
+ const topic = new Topic({
+ id: contributor.id,
+ displayName: contributor.title,
+ type: 'tag-contributor',
+ });
+
+ if (isFollowing) {
+ notificationsClient
+ .unfollow(topic)
+ .then(() => setIsFollowing(false))
+ .catch(() => {});
+ } else {
+ notificationsClient
+ .follow(topic)
+ .then(() => setIsFollowing(true))
+ .catch(() => {});
+ }
+ }, [contributor, isFollowing]);
+
+ return (
+
+ );
+};
diff --git a/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx b/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx
index 310e710b6d0..9f7a6c61a88 100644
--- a/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx
+++ b/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx
@@ -1,5 +1,6 @@
import { css } from '@emotion/react';
import { ArticleDesign, ArticleDisplay, ArticlePillar } from '@guardian/libs';
+import { Platform } from '../../types/platform';
import { Figure } from './Figure';
import { Flex } from './Flex';
import { ImageBlockComponent } from './ImageBlockComponent';
@@ -61,6 +62,7 @@ export const StandardArticle = () => {
role="inline"
>
{
role="immersive"
>
{
role="showcase"
>
{
role="thumbnail"
>
{
role="supporting"
>
{
role="inline"
>
{
role="inline"
>
{
role="inline"
>
{
role="immersive"
>
{
role="showcase"
>
{
role="halfWidth"
>
{
role="halfWidth"
>
{
role="halfWidth"
>
(
);
export const ImageComponent = ({
+ platform,
element,
format,
hideCaption,
@@ -319,6 +324,19 @@ export const ImageComponent = ({
isLazy={!isMainMedia}
isMainMedia={isMainMedia}
/>
+
+
+
{!!title && (
)}
@@ -350,6 +368,19 @@ export const ImageComponent = ({
isLazy={!isMainMedia}
isMainMedia={isMainMedia}
/>
+
+
+
{typeof starRating === 'number' && (
)}
@@ -384,6 +415,19 @@ export const ImageComponent = ({
isLazy={!isMainMedia}
isMainMedia={isMainMedia}
/>
+
+
+
{isMainMedia && (
// Below tablet, main media images show an info toggle at the bottom right of
// the image which, when clicked, toggles the caption as an overlay
diff --git a/dotcom-rendering/src/web/components/LiveBlock.stories.tsx b/dotcom-rendering/src/web/components/LiveBlock.stories.tsx
index 74a724509e7..5280a33fd45 100644
--- a/dotcom-rendering/src/web/components/LiveBlock.stories.tsx
+++ b/dotcom-rendering/src/web/components/LiveBlock.stories.tsx
@@ -4,6 +4,7 @@ import { breakpoints, from } from '@guardian/source-foundations';
import { liveBlock } from '../../../fixtures/manual/liveBlock';
import { images } from '../../../fixtures/generated/images';
import { LiveBlock } from './LiveBlock';
+import { Platform } from '../../types/platform';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
@@ -81,6 +82,7 @@ export const VideoAsSecond = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -130,6 +132,7 @@ export const Title = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -200,6 +203,7 @@ export const Video = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -245,6 +249,7 @@ export const RichLink = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -281,6 +286,7 @@ export const FirstImage = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -341,6 +347,7 @@ export const ImageRoles = () => {
ajaxUrl=""
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
isAdFreeUser={false}
isSensitive={false}
/>
@@ -392,6 +399,7 @@ export const Thumbnail = () => {
ajaxUrl=""
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
isAdFreeUser={false}
isSensitive={false}
/>
@@ -431,6 +439,7 @@ export const ImageAndTitle = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -464,6 +473,7 @@ export const Updated = () => {
isSensitive={false}
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
/>
);
@@ -501,6 +511,7 @@ export const Contributor = () => {
ajaxUrl=""
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
isAdFreeUser={false}
isSensitive={false}
/>
@@ -536,6 +547,7 @@ export const NoAvatar = () => {
ajaxUrl=""
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
isAdFreeUser={false}
isSensitive={false}
/>
@@ -574,6 +586,7 @@ export const TitleAndContributor = () => {
ajaxUrl=""
switches={{}}
isPinnedPost={false}
+ platform={Platform.Web}
isAdFreeUser={false}
isSensitive={false}
/>
diff --git a/dotcom-rendering/src/web/components/LiveBlock.tsx b/dotcom-rendering/src/web/components/LiveBlock.tsx
index 71c152c7b7b..117df7bfa76 100644
--- a/dotcom-rendering/src/web/components/LiveBlock.tsx
+++ b/dotcom-rendering/src/web/components/LiveBlock.tsx
@@ -1,4 +1,5 @@
import { css } from '@emotion/react';
+import { Platform } from '../../types/platform';
import type { Switches } from '../../types/config';
import { RenderArticleElement } from '../lib/renderElement';
import { LastUpdated } from './LastUpdated';
@@ -10,7 +11,7 @@ type Props = {
block: Block;
pageId: string;
webTitle: string;
- adTargeting: AdTargeting;
+ adTargeting?: AdTargeting;
host?: string;
ajaxUrl: string;
isAdFreeUser: boolean;
@@ -19,6 +20,7 @@ type Props = {
isLiveUpdate?: boolean;
isPinnedPost: boolean;
pinnedPostId?: string;
+ platform: Platform;
};
export const LiveBlock = ({
@@ -35,6 +37,7 @@ export const LiveBlock = ({
isLiveUpdate,
isPinnedPost,
pinnedPostId,
+ platform,
}: Props) => {
if (block.elements.length === 0) return null;
@@ -79,6 +82,7 @@ export const LiveBlock = ({
isSensitive={isSensitive}
switches={switches}
isPinnedPost={isPinnedPost}
+ platform={platform}
/>
))}