From a509f72c40647a2a46ace63ec5f5f8b35a3c2c2c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 17:35:04 -0500 Subject: [PATCH 01/18] Create Ad component --- app/soapbox/features/ads/components/ad.tsx | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 app/soapbox/features/ads/components/ad.tsx diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx new file mode 100644 index 000000000..aee9c374a --- /dev/null +++ b/app/soapbox/features/ads/components/ad.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import type { Card as CardEntity } from 'soapbox/types/entities'; + +interface IAd { + card: CardEntity, +} + +/** Displays an ad in sponsored post format. */ +const Ad: React.FC = ({ card }) => { + const instance = useAppSelector(state => state.instance); + + return ( + + + + + + + + + {instance.title} + + + + + + + + + + + + + + + + {card.image && ( + + + + )} + + + ); +}; + +export default Ad; From 21ac46bada9ab19c7749a74a5cce99a095485f80 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 17:55:13 -0500 Subject: [PATCH 02/18] Ad: fetch impression URL --- app/soapbox/features/ads/components/ad.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index aee9c374a..95037a61a 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; @@ -7,13 +7,24 @@ import { useAppSelector } from 'soapbox/hooks'; import type { Card as CardEntity } from 'soapbox/types/entities'; interface IAd { + /** Embedded ad data in Card format (almost like OEmbed). */ card: CardEntity, + /** Impression URL to fetch upon display. */ + impression?: string, } /** Displays an ad in sponsored post format. */ -const Ad: React.FC = ({ card }) => { +const Ad: React.FC = ({ card, impression }) => { const instance = useAppSelector(state => state.instance); + // Fetch the impression URL (if any) upon displaying the ad. + // It's common for ad providers to provide this. + useEffect(() => { + if (impression) { + fetch(impression); + } + }, [impression]); + return ( From d5fd3af9036ad03e6ec4dfe3ad95fa23ffe6ed1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 18:02:23 -0500 Subject: [PATCH 03/18] Ad: use card.width, card.height --- app/soapbox/features/ads/components/ad.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 95037a61a..2515241cd 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -55,7 +55,13 @@ const Ad: React.FC = ({ card, impression }) => { {card.image && ( - + )} From f112dd980ba1923279d76f50945aad5633ef5bc7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 18:50:46 -0500 Subject: [PATCH 04/18] AdProvider boilerplate --- app/soapbox/features/ads/providers/index.ts | 17 +++++++++++++++++ .../features/ads/providers/soapbox-config.ts | 14 ++++++++++++++ app/soapbox/normalizers/index.ts | 1 + app/soapbox/normalizers/soapbox/ad.ts | 19 +++++++++++++++++++ .../normalizers/soapbox/soapbox_config.ts | 10 ++++++++++ app/soapbox/types/soapbox.ts | 3 +++ 6 files changed, 64 insertions(+) create mode 100644 app/soapbox/features/ads/providers/index.ts create mode 100644 app/soapbox/features/ads/providers/soapbox-config.ts create mode 100644 app/soapbox/normalizers/soapbox/ad.ts diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts new file mode 100644 index 000000000..3adf323c7 --- /dev/null +++ b/app/soapbox/features/ads/providers/index.ts @@ -0,0 +1,17 @@ +import type { RootState } from 'soapbox/store'; +import type { Card } from 'soapbox/types/entities'; + +/** Ad server implementation. */ +interface AdProvider { + getAds(getState: () => RootState): Promise, +} + +/** Entity representing an advertisement. */ +interface Ad { + /** Ad data in Card (OEmbed-ish) format. */ + card: Card, + /** Impression URL to fetch when displaying the ad. */ + impression?: string, +} + +export type { Ad, AdProvider }; diff --git a/app/soapbox/features/ads/providers/soapbox-config.ts b/app/soapbox/features/ads/providers/soapbox-config.ts new file mode 100644 index 000000000..21163729c --- /dev/null +++ b/app/soapbox/features/ads/providers/soapbox-config.ts @@ -0,0 +1,14 @@ +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; + +import type { AdProvider } from '.'; + +/** Provides ads from Soapbox Config. */ +const SoapboxConfigAdProvider: AdProvider = { + getAds: async(getState) => { + const state = getState(); + const soapboxConfig = getSoapboxConfig(state); + return soapboxConfig.ads.toArray(); + }, +}; + +export default SoapboxConfigAdProvider; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index d25cbd014..9fdc04f68 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -20,4 +20,5 @@ export { StatusRecord, normalizeStatus } from './status'; export { StatusEditRecord, normalizeStatusEdit } from './status_edit'; export { TagRecord, normalizeTag } from './tag'; +export { AdRecord, normalizeAd } from './soapbox/ad'; export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config'; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts new file mode 100644 index 000000000..c29ee9a3e --- /dev/null +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -0,0 +1,19 @@ +import { + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import { CardRecord, normalizeCard } from '../card'; + +export const AdRecord = ImmutableRecord({ + card: CardRecord(), + impression: undefined as string | undefined, +}); + +/** Normalizes an ad from Soapbox Config. */ +export const normalizeAd = (ad: Record) => { + const map = ImmutableMap(fromJS(ad)); + const card = normalizeCard(map.get('card')); + return AdRecord(map.set('card', card)); +}; diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index d90112810..0314771fe 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -9,7 +9,10 @@ import trimStart from 'lodash/trimStart'; import { toTailwind } from 'soapbox/utils/tailwind'; import { generateAccent } from 'soapbox/utils/theme'; +import { normalizeAd } from './ad'; + import type { + Ad, PromoPanelItem, FooterItem, CryptoAddress, @@ -78,6 +81,7 @@ export const CryptoAddressRecord = ImmutableRecord({ }); export const SoapboxConfigRecord = ImmutableRecord({ + ads: ImmutableList(), appleAppId: null, logo: '', logoDarkMode: null, @@ -122,6 +126,11 @@ export const SoapboxConfigRecord = ImmutableRecord({ type SoapboxConfigMap = ImmutableMap; +const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { + const ads = ImmutableList>(soapboxConfig.get('ads')); + return soapboxConfig.set('ads', ads.map(normalizeAd)); +}; + const normalizeCryptoAddress = (address: unknown): CryptoAddress => { return CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker => { return trimStart(ticker, '$').toLowerCase(); @@ -186,6 +195,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record) => { normalizeFooterLinks(soapboxConfig); maybeAddMissingColors(soapboxConfig); normalizeCryptoAddresses(soapboxConfig); + normalizeAds(soapboxConfig); }), ); }; diff --git a/app/soapbox/types/soapbox.ts b/app/soapbox/types/soapbox.ts index 32c2f681c..1a37d1a88 100644 --- a/app/soapbox/types/soapbox.ts +++ b/app/soapbox/types/soapbox.ts @@ -1,3 +1,4 @@ +import { AdRecord } from 'soapbox/normalizers/soapbox/ad'; import { PromoPanelItemRecord, FooterItemRecord, @@ -7,6 +8,7 @@ import { type Me = string | null | false | undefined; +type Ad = ReturnType; type PromoPanelItem = ReturnType; type FooterItem = ReturnType; type CryptoAddress = ReturnType; @@ -14,6 +16,7 @@ type SoapboxConfig = ReturnType; export { Me, + Ad, PromoPanelItem, FooterItem, CryptoAddress, From 92a5893f831f54729d23336abdc54da82e7cf623 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 20:13:02 -0500 Subject: [PATCH 05/18] Add react-query --- app/soapbox/containers/soapbox.tsx | 14 +++++++++----- app/soapbox/queries/client.ts | 13 +++++++++++++ package.json | 1 + yarn.lock | 24 ++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 app/soapbox/queries/client.ts diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index 603663aa7..dd8bfc519 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -1,5 +1,6 @@ 'use strict'; +import { QueryClientProvider } from '@tanstack/react-query'; import classNames from 'classnames'; import React, { useState, useEffect } from 'react'; import { IntlProvider } from 'react-intl'; @@ -37,6 +38,7 @@ import { useLocale, } from 'soapbox/hooks'; import MESSAGES from 'soapbox/locales/messages'; +import { queryClient } from 'soapbox/queries/client'; import { useCachedLocationHandler } from 'soapbox/utils/redirect'; import { generateThemeCss } from 'soapbox/utils/theme'; @@ -281,11 +283,13 @@ const SoapboxHead: React.FC = ({ children }) => { const Soapbox: React.FC = () => { return ( - - - - - + + + + + + + ); }; diff --git a/app/soapbox/queries/client.ts b/app/soapbox/queries/client.ts new file mode 100644 index 000000000..d772e9288 --- /dev/null +++ b/app/soapbox/queries/client.ts @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 60000, // 1 minute + cacheTime: Infinity, + }, + }, +}); + +export { queryClient }; diff --git a/package.json b/package.json index a34f1b539..3c8ccb13f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@tabler/icons": "^1.73.0", "@tailwindcss/forms": "^0.4.0", "@tailwindcss/typography": "^0.5.1", + "@tanstack/react-query": "^4.0.10", "@testing-library/react": "^12.1.4", "@types/escape-html": "^1.0.1", "@types/http-link-header": "^1.0.3", diff --git a/yarn.lock b/yarn.lock index 4469a177a..6677785fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2296,6 +2296,20 @@ lodash.isplainobject "^4.0.6" lodash.merge "^4.6.2" +"@tanstack/query-core@^4.0.0-beta.1": + version "4.0.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.0.10.tgz#cae6f818006616dc72c95c863592f5f68b47548a" + integrity sha512-9LsABpZXkWZHi4P1ozRETEDXQocLAxVzQaIhganxbNuz/uA3PsCAJxJTiQrknG5htLMzOF5MqM9G10e6DCxV1A== + +"@tanstack/react-query@^4.0.10": + version "4.0.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.0.10.tgz#92c71a2632c06450d848d4964959bd216cde03c0" + integrity sha512-Wn5QhZUE5wvr6rGClV7KeQIUsdTmYR9mgmMZen7DSRWauHW2UTynFg3Kkf6pw+XlxxOLsyLWwz/Q6q1lSpM3TQ== + dependencies: + "@tanstack/query-core" "^4.0.0-beta.1" + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.2.0" + "@testing-library/dom@^8.0.0": version "8.12.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.12.0.tgz#fef5e545533fb084175dda6509ee71d7d2f72e23" @@ -2876,6 +2890,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/uuid@^8.3.4": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" @@ -11645,6 +11664,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From 6d1539cf9c63f75dc0aca1413725650bcee26e42 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 22:42:30 -0500 Subject: [PATCH 06/18] eslint: don't care about consistent-return in typescript --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index 0ff74336c..7fa666e36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -278,6 +278,7 @@ module.exports = { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 + 'consistent-return': 'off', }, parser: '@typescript-eslint/parser', }, From b02141874e27c1eef9ca0537c91309d3fc1ec6e5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 22:43:28 -0500 Subject: [PATCH 07/18] Show ads in feed --- app/soapbox/components/status_list.tsx | 41 +++++++++++++++++--- app/soapbox/features/ads/providers/index.ts | 20 ++++++++++ app/soapbox/features/home_timeline/index.tsx | 1 + app/soapbox/queries/ads.ts | 23 +++++++++++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 app/soapbox/queries/ads.ts diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 408506997..7cf13fb86 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -6,13 +6,17 @@ import { FormattedMessage } from 'react-intl'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; import StatusContainer from 'soapbox/containers/status_container'; +import Ad from 'soapbox/features/ads/components/ad'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; +import { useSoapboxConfig } from 'soapbox/hooks'; +import useAds from 'soapbox/queries/ads'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { IScrollableList } from 'soapbox/components/scrollable_list'; +import type { Ad as AdEntity } from 'soapbox/features/ads/providers'; interface IStatusList extends Omit { /** Unique key to preserve the scroll position when navigating back. */ @@ -37,6 +41,8 @@ interface IStatusList extends Omit { timelineId?: string, /** Whether to display a gap or border between statuses in the list. */ divideType?: 'space' | 'border', + /** Whether to display ads. */ + showAds?: boolean, } /** Feed of statuses, built atop ScrollableList. */ @@ -49,8 +55,12 @@ const StatusList: React.FC = ({ timelineId, isLoading, isPartial, + showAds = false, ...other }) => { + const { data: ads } = useAds(); + const soapboxConfig = useSoapboxConfig(); + const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0; const node = useRef(null); const getFeaturedStatusCount = () => { @@ -123,6 +133,15 @@ const StatusList: React.FC = ({ ); }; + const renderAd = (ad: AdEntity) => { + return ( + + ); + }; + const renderPendingStatus = (statusId: string) => { const idempotencyKey = statusId.replace(/^末pending-/, ''); @@ -156,17 +175,27 @@ const StatusList: React.FC = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { - return statusIds.toArray().map((statusId, index) => { + return statusIds.toList().reduce((acc, statusId, index) => { + const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0; + const ad = ads ? ads[adIndex] : undefined; + const showAd = (index + 1) % adsInterval === 0; + if (statusId === null) { - return renderLoadGap(index); + acc.push(renderLoadGap(index)); } else if (statusId.startsWith('末suggestions-')) { - return renderFeedSuggestions(); + acc.push(renderFeedSuggestions()); } else if (statusId.startsWith('末pending-')) { - return renderPendingStatus(statusId); + acc.push(renderPendingStatus(statusId)); } else { - return renderStatus(statusId); + acc.push(renderStatus(statusId)); } - }); + + if (showAds && ad && showAd) { + acc.push(renderAd(ad)); + } + + return acc; + }, [] as React.ReactNode[]); } else { return []; } diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 3adf323c7..69b8800a4 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -1,6 +1,13 @@ +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; + import type { RootState } from 'soapbox/store'; import type { Card } from 'soapbox/types/entities'; +/** Map of available provider modules. */ +const PROVIDERS: Record Promise> = { + soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, +}; + /** Ad server implementation. */ interface AdProvider { getAds(getState: () => RootState): Promise, @@ -14,4 +21,17 @@ interface Ad { impression?: string, } +/** Gets the current provider based on config. */ +const getProvider = async(getState: () => RootState): Promise => { + const state = getState(); + const soapboxConfig = getSoapboxConfig(state); + const isEnabled = soapboxConfig.extensions.getIn(['ads', 'enabled'], false) === true; + const providerName = soapboxConfig.extensions.getIn(['ads', 'provider'], 'soapbox') as string; + + if (isEnabled && PROVIDERS[providerName]) { + return PROVIDERS[providerName](); + } +}; + +export { getProvider }; export type { Ad, AdProvider }; diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index dd83f723b..8c5522c66 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -90,6 +90,7 @@ const HomeTimeline: React.FC = () => { onLoadMore={handleLoadMore} timelineId='home' divideType='space' + showAds emptyMessage={ diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts new file mode 100644 index 000000000..7ad594a94 --- /dev/null +++ b/app/soapbox/queries/ads.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Ad, getProvider } from 'soapbox/features/ads/providers'; +import { useAppDispatch } from 'soapbox/hooks'; + +export default function useAds() { + const dispatch = useAppDispatch(); + + const getAds = async() => { + return dispatch(async(_, getState) => { + const provider = await getProvider(getState); + if (provider) { + return provider.getAds(getState); + } else { + return []; + } + }); + }; + + return useQuery(['ads'], getAds, { + placeholderData: [], + }); +} From 0eeca2be5c1d97005c4d194a04c9727f2046075f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 23:03:16 -0500 Subject: [PATCH 08/18] Add Rumble AdProvider --- app/soapbox/features/ads/providers/index.ts | 1 + app/soapbox/features/ads/providers/rumble.ts | 45 ++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/soapbox/features/ads/providers/rumble.ts diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 69b8800a4..65e593985 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -6,6 +6,7 @@ import type { Card } from 'soapbox/types/entities'; /** Map of available provider modules. */ const PROVIDERS: Record Promise> = { soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default, + rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default, }; /** Ad server implementation. */ diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts new file mode 100644 index 000000000..17c4abea8 --- /dev/null +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -0,0 +1,45 @@ +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; +import { normalizeCard } from 'soapbox/normalizers'; + +import type { AdProvider } from '.'; + +/** Rumble ad API entity. */ +interface RumbleAd { + type: number, + impression: string, + click: string, + asset: string, + expires: number, +} + +/** Response from Rumble ad server. */ +interface RumbleApiResponse { + count: number, + ads: RumbleAd[], +} + +/** Provides ads from Soapbox Config. */ +const RumbleAdProvider: AdProvider = { + getAds: async(getState) => { + const state = getState(); + const soapboxConfig = getSoapboxConfig(state); + const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined; + + if (endpoint) { + const response = await fetch(endpoint); + const data = await response.json() as RumbleApiResponse; + return data.ads.map(item => ({ + impression: item.impression, + card: normalizeCard({ + type: item.type === 1 ? 'Link' : 'Rich', + image: item.asset, + url: item.click, + }), + })); + } else { + return []; + } + }, +}; + +export default RumbleAdProvider; From f6e5df2278765b072250f38c5ef7605d723ffcf6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Aug 2022 23:42:13 -0500 Subject: [PATCH 09/18] Jest: include QueryClientProvider in tests --- .../__tests__/feed-carousel.test.tsx | 4 +--- app/soapbox/jest/test-helpers.tsx | 17 +++++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index e01a32e9e..350cf385f 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -137,9 +137,7 @@ describe('', () => { expect(screen.queryAllByTestId('prev-page')).toHaveLength(0); }); - await waitFor(() => { - user.click(screen.getByTestId('next-page')); - }); + await user.click(screen.getByTestId('next-page')); await waitFor(() => { expect(screen.getByTestId('prev-page')).toBeInTheDocument(); diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 657919b5a..b7223caca 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -1,4 +1,5 @@ import { configureMockStore } from '@jedmao/redux-mock-store'; +import { QueryClientProvider } from '@tanstack/react-query'; import { render, RenderOptions } from '@testing-library/react'; import { merge } from 'immutable'; import React, { FC, ReactElement } from 'react'; @@ -9,6 +10,8 @@ import { Action, applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; import '@testing-library/jest-dom'; +import { queryClient } from 'soapbox/queries/client'; + import NotificationsContainer from '../features/ui/containers/notifications_container'; import { default as rootReducer } from '../reducers'; @@ -45,13 +48,15 @@ const TestApp: FC = ({ children, storeProps, routerProps = {} }) => { return ( - - - {children} + + + + {children} - - - + + + + ); }; From ad7ec8b1a68a796fe96d82fc5b51d0eec9d0836d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 Aug 2022 20:14:28 -0500 Subject: [PATCH 10/18] Make ads horizontal cards --- app/soapbox/features/ads/components/ad.tsx | 13 ++----------- app/soapbox/features/ads/providers/rumble.ts | 3 ++- app/soapbox/features/status/components/card.tsx | 4 +++- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 2515241cd..6fe75efb9 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; +import StatusCard from 'soapbox/features/status/components/card'; import { useAppSelector } from 'soapbox/hooks'; import type { Card as CardEntity } from 'soapbox/types/entities'; @@ -53,17 +54,7 @@ const Ad: React.FC = ({ card, impression }) => { - {card.image && ( - - - - )} + {}} horizontal /> ); diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts index 17c4abea8..ac9beb569 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -31,7 +31,8 @@ const RumbleAdProvider: AdProvider = { return data.ads.map(item => ({ impression: item.impression, card: normalizeCard({ - type: item.type === 1 ? 'Link' : 'Rich', + type: item.type === 1 ? 'link' : 'rich', + title: 'Sponsored post', image: item.asset, url: item.click, }), diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index 90ddc27e3..f3b18d663 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -51,6 +51,7 @@ interface ICard { compact?: boolean, defaultWidth?: number, cacheWidth?: (width: number) => void, + horizontal?: boolean, } const Card: React.FC = ({ @@ -61,6 +62,7 @@ const Card: React.FC = ({ compact = false, cacheWidth, onOpenMedia, + horizontal, }): JSX.Element => { const [width, setWidth] = useState(defaultWidth); const [embedded, setEmbedded] = useState(false); @@ -132,7 +134,7 @@ const Card: React.FC = ({ }; const interactive = card.type !== 'link'; - const horizontal = interactive || embedded; + horizontal = typeof horizontal === 'boolean' ? horizontal : interactive || embedded; const className = classnames('status-card', { horizontal, compact, interactive }, `status-card--${card.type}`); const ratio = getRatio(card); const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); From c74721f1e1b4270ddd17cab1ed51bece969e5d67 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 2 Aug 2022 20:52:27 -0500 Subject: [PATCH 11/18] Ads: display tooltip --- app/soapbox/components/ui/stack/stack.tsx | 5 ++++- app/soapbox/features/ads/components/ad.tsx | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 9ecb4a104..984bae782 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -32,11 +32,13 @@ interface IStack extends React.HTMLAttributes { justifyContent?: 'center', /** Extra class names on the
element. */ className?: string, + /** Whether to let the flexbox grow. */ + grow?: boolean, } /** Vertical stack of child elements. */ const Stack: React.FC = (props) => { - const { space, alignItems, justifyContent, className, ...filteredProps } = props; + const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; return (
= (props) => { [alignItemsOptions[alignItems]]: typeof alignItems !== 'undefined', // @ts-ignore [justifyContentOptions[justifyContent]]: typeof justifyContent !== 'undefined', + 'flex-grow': grow, }, className)} /> ); diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 6fe75efb9..7eb5ac892 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,12 +1,17 @@ import React, { useEffect } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; +import { Stack, HStack, Card, Tooltip, Avatar, Text, Icon } from 'soapbox/components/ui'; +import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import StatusCard from 'soapbox/features/status/components/card'; import { useAppSelector } from 'soapbox/hooks'; import type { Card as CardEntity } from 'soapbox/types/entities'; +const messages = defineMessages({ + tooltip: { id: 'sponsored.tooltip', defaultMessage: '{siteTitle} displays ads to help fund our service.' }, +}); + interface IAd { /** Embedded ad data in Card format (almost like OEmbed). */ card: CardEntity, @@ -16,6 +21,7 @@ interface IAd { /** Displays an ad in sponsored post format. */ const Ad: React.FC = ({ card, impression }) => { + const intl = useIntl(); const instance = useAppSelector(state => state.instance); // Fetch the impression URL (if any) upon displaying the ad. @@ -32,7 +38,7 @@ const Ad: React.FC = ({ card, impression }) => { - + {instance.title} @@ -52,6 +58,15 @@ const Ad: React.FC = ({ card, impression }) => { + + + + + + {}} horizontal /> From 77ad89bc483b836b6a736e51c9ac5b5d26a7e6ee Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 10:33:35 -0500 Subject: [PATCH 12/18] Fix height of horizontal link cards --- app/soapbox/features/status/components/card.tsx | 10 +++++++++- app/styles/components/status.scss | 5 +---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index f3b18d663..f2e73f117 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -236,7 +236,15 @@ const Card: React.FC = ({ ); } else if (card.image) { embed = ( -
+
{canvas} {thumbnail}
diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 3b05c9fdd..dfdf9537e 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -340,6 +340,7 @@ a.status-card { flex: 0 0 40%; background: var(--brand-color--med); position: relative; + overflow: hidden; & > .svg-icon { width: 40px; @@ -380,10 +381,6 @@ a.status-card { @apply flex flex-col md:flex-row; } -.status-card--link .status-card__image { - @apply w-full rounded-l md:w-auto h-[200px] md:h-auto flex-none md:flex-auto; -} - .material-status { padding-bottom: 10px; From 1c78a2ea46a6a4a9c9152302c387f3b62000e097 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 10:48:50 -0500 Subject: [PATCH 13/18] Refactor status Card description --- .../features/status/components/card.tsx | 21 ++++++++++++------- app/styles/components/status.scss | 12 ----------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index f2e73f117..0bf71b30a 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; -import { HStack } from 'soapbox/components/ui'; +import { HStack, Stack } from 'soapbox/components/ui'; import { normalizeAttachment } from 'soapbox/normalizers'; import type { Card as CardEntity, Attachment } from 'soapbox/types/entities'; @@ -142,7 +142,6 @@ const Card: React.FC = ({ const title = interactive ? ( e.stopPropagation()} - className='status-card__title' href={card.url} title={trimmedTitle} rel='noopener' @@ -151,15 +150,21 @@ const Card: React.FC = ({ {trimmedTitle} ) : ( - {trimmedTitle} + {trimmedTitle} ); const description = ( -
- {title} -

{trimmedDescription}

- {card.provider_name} -
+ + {trimmedTitle && ( + {title} + )} + {trimmedDescription && ( +

{trimmedDescription}

+ )} + + {card.provider_name} + +
); let embed: React.ReactNode = ''; diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index dfdf9537e..e4c06eb45 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -308,18 +308,6 @@ a.status-card { } } -.status-card__title { - @apply block font-medium mb-2 text-gray-800 dark:text-gray-200 no-underline; -} - -.status-card__content { - @apply flex-1 overflow-hidden p-4; -} - -.status-card__description { - @apply text-gray-500 dark:text-gray-400; -} - .status-card__host { display: flex; margin-top: 10px; From 0bffe4a227e734024aa07361d5dc9d7b6a69a7ca Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 11:05:57 -0500 Subject: [PATCH 14/18] Refactor status Card description with UI components --- .../features/status/components/card.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index 4633ef743..470446b99 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; -import { HStack, Stack } from 'soapbox/components/ui'; +import { HStack, Stack, Text } from 'soapbox/components/ui'; import { normalizeAttachment } from 'soapbox/normalizers'; import type { Card as CardEntity, Attachment } from 'soapbox/types/entities'; @@ -147,22 +147,27 @@ const Card: React.FC = ({ rel='noopener' target='_blank' > - {trimmedTitle} + {trimmedTitle} ) : ( - {trimmedTitle} + {trimmedTitle} ); const description = ( {trimmedTitle && ( - {title} + {title} )} {trimmedDescription && ( -

{trimmedDescription}

+ {trimmedDescription} )} - - {card.provider_name} + + + + + + {card.provider_name} +
); From 3a010fa60d983cc36154aae67636b7b152c182ca Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 11:12:22 -0500 Subject: [PATCH 15/18] Don't add title to Rumble ads provider --- app/soapbox/features/ads/providers/rumble.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts index ac9beb569..b39ee4cc9 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -32,7 +32,6 @@ const RumbleAdProvider: AdProvider = { impression: item.impression, card: normalizeCard({ type: item.type === 1 ? 'link' : 'rich', - title: 'Sponsored post', image: item.asset, url: item.click, }), From 3971d724d373f9b6931772b3a52accd5e82bbd22 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 11:15:30 -0500 Subject: [PATCH 16/18] Status Card: shrink link text size --- app/soapbox/features/status/components/card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/status/components/card.tsx b/app/soapbox/features/status/components/card.tsx index 470446b99..fbbe0648d 100644 --- a/app/soapbox/features/status/components/card.tsx +++ b/app/soapbox/features/status/components/card.tsx @@ -165,7 +165,7 @@ const Card: React.FC = ({ - + {card.provider_name} From c0f4130edf7359247b9c805bad5adfaf556cc395 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 11:42:54 -0500 Subject: [PATCH 17/18] Display popover in ad --- app/soapbox/features/ads/components/ad.tsx | 101 +++++++++++++-------- 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index 7eb5ac892..fd7209eed 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,17 +1,13 @@ -import React, { useEffect } from 'react'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; -import { Stack, HStack, Card, Tooltip, Avatar, Text, Icon } from 'soapbox/components/ui'; +import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; import IconButton from 'soapbox/components/ui/icon-button/icon-button'; import StatusCard from 'soapbox/features/status/components/card'; import { useAppSelector } from 'soapbox/hooks'; import type { Card as CardEntity } from 'soapbox/types/entities'; -const messages = defineMessages({ - tooltip: { id: 'sponsored.tooltip', defaultMessage: '{siteTitle} displays ads to help fund our service.' }, -}); - interface IAd { /** Embedded ad data in Card format (almost like OEmbed). */ card: CardEntity, @@ -21,9 +17,10 @@ interface IAd { /** Displays an ad in sponsored post format. */ const Ad: React.FC = ({ card, impression }) => { - const intl = useIntl(); const instance = useAppSelector(state => state.instance); + const [showInfo, setShowInfo] = useState(false); + // Fetch the impression URL (if any) upon displaying the ad. // It's common for ad providers to provide this. useEffect(() => { @@ -32,46 +29,72 @@ const Ad: React.FC = ({ card, impression }) => { } }, [impression]); + /** Toggle the info box on click. */ + const handleInfoButtonClick: React.MouseEventHandler = () => { + setShowInfo(!showInfo); + }; + return ( - - - - +
+ + + + - - - - {instance.title} - - - - - - - - - + + + + {instance.title} - - - - - + + + + + + + + + + + + + - - - + + - {}} horizontal /> - - + {}} horizontal /> + + + + {showInfo && ( +
+ + + + + + + + + + + +
+ )} +
); }; From 01bddbce4d6b97707568b8fffb1196f1473bc535 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 3 Aug 2022 11:53:17 -0500 Subject: [PATCH 18/18] Ad: dismiss the infobox when clicked outside --- app/soapbox/features/ads/components/ad.tsx | 31 +++++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index fd7209eed..1556d56e5 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui'; @@ -19,8 +19,30 @@ interface IAd { const Ad: React.FC = ({ card, impression }) => { const instance = useAppSelector(state => state.instance); + const infobox = useRef(null); const [showInfo, setShowInfo] = useState(false); + /** Toggle the info box on click. */ + const handleInfoButtonClick: React.MouseEventHandler = () => { + setShowInfo(!showInfo); + }; + + /** Hide the info box when clicked outside. */ + const handleClickOutside = (event: MouseEvent) => { + if (event.target && infobox.current && !infobox.current.contains(event.target as any)) { + setShowInfo(false); + } + }; + + // Hide the info box when clicked outside. + // https://stackoverflow.com/a/42234988 + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [infobox]); + // Fetch the impression URL (if any) upon displaying the ad. // It's common for ad providers to provide this. useEffect(() => { @@ -29,11 +51,6 @@ const Ad: React.FC = ({ card, impression }) => { } }, [impression]); - /** Toggle the info box on click. */ - const handleInfoButtonClick: React.MouseEventHandler = () => { - setShowInfo(!showInfo); - }; - return (
@@ -76,7 +93,7 @@ const Ad: React.FC = ({ card, impression }) => { {showInfo && ( -
+