diff --git a/fixtures/donate-heart.png b/fixtures/donate-heart.png new file mode 100644 index 0000000000..1befcd52d6 Binary files /dev/null and b/fixtures/donate-heart.png differ diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index e88c67410d..7fcaf82fc9 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -1031,6 +1031,10 @@ $rtl-icon-map: ( content: ''; border-style: solid; border-width: 7px; + border-color: light-dark( + variables.$color-gray-04, + variables.$color-gray-80 + ); } &[data-placement='bottom'] .module-tooltip-arrow::before { @@ -1049,16 +1053,6 @@ $rtl-icon-map: ( } } - &[data-placement='bottom'] .module-tooltip-arrow::after { - @include light-theme { - border-bottom-color: variables.$color-gray-04; - } - - @include dark-theme { - border-bottom-color: variables.$color-gray-80; - } - } - &[data-placement='top'] .module-tooltip-arrow::before { @include light-theme { border-color: variables.$color-gray-20 transparent transparent transparent; @@ -1075,13 +1069,35 @@ $rtl-icon-map: ( } } - &[data-placement='top'] .module-tooltip-arrow::after { + &[data-placement='left'] .module-tooltip-arrow::before { @include light-theme { - border-top-color: variables.$color-gray-04; + border-color: transparent transparent transparent variables.$color-gray-20; } @include dark-theme { - border-top-color: variables.$color-gray-80; + border-color: transparent transparent transparent variables.$color-gray-62; + } + + & { + margin-top: -7px; + /* stylelint-disable-next-line liberty/use-logical-spec */ + margin-left: 0px; + } + } + + &[data-placement='right'] .module-tooltip-arrow::before { + @include light-theme { + border-color: transparent variables.$color-gray-20 transparent transparent; + } + + @include dark-theme { + border-color: transparent variables.$color-gray-62 transparent transparent; + } + + & { + margin-top: -7px; + /* stylelint-disable-next-line liberty/use-logical-spec */ + margin-left: -14px; } } } diff --git a/stylesheets/components/RemoteMegaphoneTooltip.scss b/stylesheets/components/RemoteMegaphoneTooltip.scss new file mode 100644 index 0000000000..2b08693d40 --- /dev/null +++ b/stylesheets/components/RemoteMegaphoneTooltip.scss @@ -0,0 +1,8 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; + +.RemoteMegaphoneTooltip { + @include mixins.tooltip; +} diff --git a/stylesheets/components/ToastManager.scss b/stylesheets/components/ToastManager.scss index 057995ddc5..1ff534793b 100644 --- a/stylesheets/components/ToastManager.scss +++ b/stylesheets/components/ToastManager.scss @@ -49,3 +49,11 @@ .ToastManager--full-screen-call { bottom: calc(variables.$CallControls__height + 32px); } + +.ToastManager--megaphones { + padding: 12px; +} + +.ToastManager--megaphones.ToastManager--narrow-sidebar { + padding: 10px; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index d8066a39ad..d920d9fdcb 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -160,6 +160,7 @@ @use 'components/Quote.scss'; @use 'components/ReactionPickerPicker.scss'; @use 'components/RecordingComposer.scss'; +@use 'components/RemoteMegaphoneTooltip.scss'; @use 'components/SafetyNumberChangeDialog.scss'; @use 'components/SafetyNumberOnboarding.scss'; @use 'components/SafetyNumberViewer.scss'; diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 1664105474..09da91ace8 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -261,6 +261,7 @@ import { import { checkFirstEnvelope } from './util/checkFirstEnvelope.dom.js'; import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked.std.js'; import { ReleaseNoteAndMegaphoneFetcher } from './services/releaseNoteAndMegaphoneFetcher.preload.js'; +import { initMegaphoneCheckService } from './services/megaphone.preload.js'; import { BuildExpirationService } from './services/buildExpiration.preload.js'; import { maybeQueueDeviceNameFetch, @@ -2071,6 +2072,7 @@ export async function startApp(): Promise { ); drop(initializeDonationService()); + initMegaphoneCheckService(); if (isFromMessageReceiver) { drop( diff --git a/ts/components/RemoteMegaphone.dom.stories.tsx b/ts/components/RemoteMegaphone.dom.stories.tsx new file mode 100644 index 0000000000..4abb3ead64 --- /dev/null +++ b/ts/components/RemoteMegaphone.dom.stories.tsx @@ -0,0 +1,65 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { PropsType } from './RemoteMegaphone.dom.js'; +import { RemoteMegaphone } from './RemoteMegaphone.dom.js'; +import { type ComponentMeta } from '../storybook/types.std.js'; +import type { + MegaphoneCtaId, + RemoteMegaphoneId, +} from '../types/Megaphone.std.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/RemoteMegaphone', + component: RemoteMegaphone, + argTypes: {}, + args: { + i18n, + remoteMegaphoneId: 'a' as RemoteMegaphoneId, + primaryCtaId: 'donate' as MegaphoneCtaId, + secondaryCtaId: 'snooze' as MegaphoneCtaId, + primaryCtaText: 'Donate', + secondaryCtaText: 'Not now', + title: 'Donate to Signal', + body: 'Signal is powered by people like you. Show your support today!', + imagePath: '/fixtures/donate-heart.png', + isFullSize: true, + onInteractWithMegaphone: action('onInteractWithMegaphone'), + }, +} satisfies ComponentMeta; + +export function Defaults(args: PropsType): React.JSX.Element { + return ; +} + +export function AlternateText(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ShortText(args: PropsType): React.JSX.Element { + return ; +} + +export function LongText(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function NarrowSidebar(args: PropsType): React.JSX.Element { + return ; +} diff --git a/ts/components/RemoteMegaphone.dom.tsx b/ts/components/RemoteMegaphone.dom.tsx new file mode 100644 index 0000000000..adb7f2ab5f --- /dev/null +++ b/ts/components/RemoteMegaphone.dom.tsx @@ -0,0 +1,118 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { RemoteActionableMegaphoneType } from '../types/Megaphone.std.js'; +import { tw } from '../axo/tw.dom.js'; +import { AxoButton } from '../axo/AxoButton.dom.js'; +import { Tooltip, TooltipPlacement } from './Tooltip.dom.js'; +import type { LocalizerType } from '../types/Util.std.js'; +import { offsetDistanceModifier } from '../util/popperUtil.std.js'; + +export type PropsType = Omit & { + isFullSize: boolean; + i18n: LocalizerType; +}; + +export function RemoteMegaphone({ + i18n, + title, + body, + imagePath, + primaryCtaId, + secondaryCtaId, + primaryCtaText, + secondaryCtaText, + remoteMegaphoneId, + isFullSize, + onInteractWithMegaphone, +}: PropsType): React.JSX.Element { + const isRTL = i18n.getLocaleDirection() === 'rtl'; + + // We need to provide this to to render correctly + const wrapperClassName = tw( + '@container flex flex-col', + 'max-w-[500px] rounded-lg border-1 border-border-primary p-3', + isFullSize ? 'pe-2 pb-1.5' : 'size-[76px]', + 'bg-elevated-background-primary dark:bg-elevated-background-tertiary' + ); + const image: React.JSX.Element = ( +
+ +
+ ); + + if (isFullSize) { + return ( +
+
+ {image} +
+

+ {title} +

+

+ {body} +

+
+
+
+ {secondaryCtaId && ( + + onInteractWithMegaphone(remoteMegaphoneId, secondaryCtaId) + } + > + {secondaryCtaText} + + )} + {primaryCtaId && ( + + onInteractWithMegaphone(remoteMegaphoneId, primaryCtaId) + } + > + {primaryCtaText} + + )} +
+
+ ); + } + + // Narrow collapsed sidebar + // TODO: DESKTOP-9540 + const tooltipContent: React.JSX.Element = ( +
+

{title}

+

{body}

+
+ ); + + return ( + +
{image}
+
+ ); +} diff --git a/ts/components/ToastManager.dom.stories.tsx b/ts/components/ToastManager.dom.stories.tsx index aee098ebad..38af154588 100644 --- a/ts/components/ToastManager.dom.stories.tsx +++ b/ts/components/ToastManager.dom.stories.tsx @@ -8,7 +8,11 @@ import { action } from '@storybook/addon-actions'; import { ToastManager } from './ToastManager.dom.js'; import type { AnyToast } from '../types/Toast.dom.js'; import { ToastType } from '../types/Toast.dom.js'; -import type { AnyActionableMegaphone } from '../types/Megaphone.std.js'; +import type { + AnyActionableMegaphone, + MegaphoneCtaId, + RemoteMegaphoneId, +} from '../types/Megaphone.std.js'; import { MegaphoneType } from '../types/Megaphone.std.js'; import { missingCaseError } from '../util/missingCaseError.std.js'; import type { PropsType } from './ToastManager.dom.js'; @@ -282,6 +286,19 @@ function getMegaphone(megaphoneType: MegaphoneType): AnyActionableMegaphone { onLearnMore: action('onLearnMore'), onDismiss: action('onDismiss'), }; + case MegaphoneType.Remote: + return { + type: megaphoneType, + remoteMegaphoneId: 'a' as RemoteMegaphoneId, + primaryCtaId: 'donate' as MegaphoneCtaId, + secondaryCtaId: 'snooze' as MegaphoneCtaId, + primaryCtaText: 'Donate', + secondaryCtaText: 'Not now', + title: 'Donate Today', + body: 'As a nonprofit, Signal needs your support', + imagePath: '/fixtures/orange-heart.svg', + onInteractWithMegaphone: action('onInteractWithMegaphone'), + }; default: throw missingCaseError(megaphoneType); } diff --git a/ts/components/ToastManager.dom.tsx b/ts/components/ToastManager.dom.tsx index 902a6fba45..3529c01525 100644 --- a/ts/components/ToastManager.dom.tsx +++ b/ts/components/ToastManager.dom.tsx @@ -23,6 +23,7 @@ import type { AnyActionableMegaphone } from '../types/Megaphone.std.js'; import type { Location } from '../types/Nav.std.js'; import { I18n } from './I18n.dom.js'; import { UserText } from './UserText.dom.js'; +import { RemoteMegaphone } from './RemoteMegaphone.dom.js'; export type PropsType = { changeLocation: (newLocation: Location) => unknown; @@ -946,6 +947,7 @@ export function renderToast({ export function renderMegaphone({ i18n, megaphone, + containerWidthBreakpoint, }: PropsType): JSX.Element | null { if (!megaphone) { return null; @@ -955,7 +957,17 @@ export function renderMegaphone({ return ; } - throw missingCaseError(megaphone.type); + if (megaphone.type === MegaphoneType.Remote) { + return ( + + ); + } + + throw missingCaseError(megaphone); } export function ToastManager(props: PropsType): JSX.Element { @@ -964,31 +976,43 @@ export function ToastManager(props: PropsType): JSX.Element { containerWidthBreakpoint, isCompositionAreaVisible, isInFullScreenCall, + megaphone, } = props; const toast = renderToast(props); return ( -
- {centerToast - ? createPortal( -
- {toast} -
, - document.body - ) - : toast} - {renderMegaphone(props)} -
+ <> + {megaphone && ( +
+ {renderMegaphone(props)} +
+ )} +
+ {centerToast + ? createPortal( +
+ {toast} +
, + document.body + ) + : toast} +
+ ); } diff --git a/ts/services/allLoaders.preload.ts b/ts/services/allLoaders.preload.ts index fd0c972727..14e753570c 100644 --- a/ts/services/allLoaders.preload.ts +++ b/ts/services/allLoaders.preload.ts @@ -44,6 +44,7 @@ import { getChatFoldersForRedux, loadChatFolders, } from './chatFoldersLoader.preload.js'; +import { getInitialMegaphonesState } from '../state/ducks/megaphones.preload.js'; export async function loadAll(): Promise { await Promise.all([ @@ -80,6 +81,7 @@ export function getParametersForRedux(): ReduxInitData { gifs: getGifsStateForRedux(), mainWindowStats, menuOptions, + megaphones: getInitialMegaphonesState(), notificationProfiles: getNotificationProfiles(), recentEmoji: getEmojiReducerState(), stickers: getStickersReduxState(), diff --git a/ts/services/megaphone.preload.ts b/ts/services/megaphone.preload.ts new file mode 100644 index 0000000000..3bcff3b99d --- /dev/null +++ b/ts/services/megaphone.preload.ts @@ -0,0 +1,170 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createLogger } from '../logging/log.std.js'; +import * as Errors from '../types/errors.std.js'; +import { + getMegaphoneLastSnoozeDurationMs, + MegaphoneCtaId, + SNOOZE_DEFAULT_DURATION, + type RemoteMegaphoneType, + type VisibleRemoteMegaphoneType, +} from '../types/Megaphone.std.js'; +import { HOUR } from '../util/durations/index.std.js'; +import { DataReader, DataWriter } from '../sql/Client.preload.js'; +import { drop } from '../util/drop.std.js'; +import { + Environment, + getEnvironment, + isMockEnvironment, +} from '../environment.std.js'; +import { isEnabled } from '../RemoteConfig.dom.js'; +import { safeSetTimeout } from '../util/timeout.std.js'; +import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js'; + +const log = createLogger('megaphoneService'); + +const CHECK_INTERVAL = 12 * HOUR; + +let nextCheckTimeout: NodeJS.Timeout | null; + +// Entrypoint + +export function initMegaphoneCheckService(): void { + if (nextCheckTimeout) { + log.warn('initMegaphoneCheckService: already started'); + return; + } + + log.info('initMegaphoneCheckService: starting'); + nextCheckTimeout = safeSetTimeout(() => { + drop(runMegaphoneCheck()); + }, CHECK_INTERVAL); + + drop(runMegaphoneCheck()); +} + +export async function runMegaphoneCheck(): Promise { + try { + if (!isRemoteMegaphoneEnabled()) { + log.info('runMegaphoneCheck: not enabled, skipping'); + return; + } + + const megaphones = await DataReader.getAllMegaphones(); + + log.info( + `runMegaphoneCheck: Checking ${megaphones.length} locally saved megaphones` + ); + for (const megaphone of megaphones) { + try { + // eslint-disable-next-line no-await-in-loop + await processMegaphone(megaphone); + } catch (error) { + log.error( + `runMegaphoneCheck: Error processing ${megaphone.id}`, + Errors.toLogFormat(error) + ); + } + } + } finally { + clearTimeoutIfNecessary(nextCheckTimeout); + nextCheckTimeout = safeSetTimeout(() => { + drop(runMegaphoneCheck()); + }, CHECK_INTERVAL); + } +} + +export function isRemoteMegaphoneEnabled(): boolean { + const env = getEnvironment(); + + if ( + env === Environment.Development || + env === Environment.Test || + env === Environment.Staging || + isMockEnvironment() || + isEnabled('desktop.internalUser') + ) { + return true; + } + + return false; +} + +// Private + +async function processMegaphone(megaphone: RemoteMegaphoneType): Promise { + const { id } = megaphone; + + if (isMegaphoneDeletable(megaphone)) { + log.info(`processMegaphone: Deleting ${id}`); + await DataWriter.deleteMegaphone(id); + window.reduxActions.megaphones.removeVisibleMegaphone(id); + return; + } + + if (isMegaphoneShowable(megaphone)) { + log.info(`processMegaphone: Showing ${id}`); + window.reduxActions.megaphones.addVisibleMegaphone(megaphone); + } +} + +export function isMegaphoneDeletable(megaphone: RemoteMegaphoneType): boolean { + return Date.now() > megaphone.dontShowAfterEpochMs; +} + +export function isMegaphoneCtaIdValid( + ctaId: string | null +): ctaId is MegaphoneCtaId { + return ( + ctaId == null || + Object.values(MegaphoneCtaId).includes(ctaId as MegaphoneCtaId) + ); +} + +export function isMegaphoneShowable( + megaphone: RemoteMegaphoneType +): megaphone is VisibleRemoteMegaphoneType { + const nowMs = Date.now(); + const { + dontShowAfterEpochMs, + isFinished, + snoozedAt, + primaryCtaId, + secondaryCtaId, + } = megaphone; + + if (isFinished || nowMs > dontShowAfterEpochMs) { + return false; + } + + if ( + !isMegaphoneCtaIdValid(primaryCtaId) || + !isMegaphoneCtaIdValid(secondaryCtaId) + ) { + log.warn( + `Skipping megaphone ${megaphone.id} with unknown ctaId:`, + primaryCtaId, + secondaryCtaId + ); + return false; + } + + if (snoozedAt) { + let snoozeDuration; + try { + snoozeDuration = getMegaphoneLastSnoozeDurationMs(megaphone); + } catch (error) { + log.error( + 'isMegaphoneShowable() failed to parse snooze config', + Errors.toLogFormat(error) + ); + snoozeDuration = SNOOZE_DEFAULT_DURATION; + } + if (nowMs < snoozedAt + snoozeDuration) { + return false; + } + } + + return true; +} diff --git a/ts/services/releaseNoteAndMegaphoneFetcher.preload.ts b/ts/services/releaseNoteAndMegaphoneFetcher.preload.ts index 1a839785c6..c656e2c0f2 100644 --- a/ts/services/releaseNoteAndMegaphoneFetcher.preload.ts +++ b/ts/services/releaseNoteAndMegaphoneFetcher.preload.ts @@ -43,12 +43,13 @@ import { stringToMIMEType } from '../types/MIME.std.js'; import { isNotNil } from '../util/isNotNil.std.js'; import { itemStorage } from '../textsecure/Storage.preload.js'; import { DataReader, DataWriter } from '../sql/Client.preload.js'; -import { - isRemoteMegaphoneEnabled, - type RemoteMegaphoneType, -} from '../types/Megaphone.std.js'; +import { type RemoteMegaphoneType } from '../types/Megaphone.std.js'; import { isCountryPpmCsvBucketEnabled } from '../RemoteConfig.dom.js'; import type { AciString } from '../types/ServiceId.std.js'; +import { + isRemoteMegaphoneEnabled, + runMegaphoneCheck, +} from './megaphone.preload.js'; const { last } = lodash; @@ -223,11 +224,12 @@ export class ReleaseNoteAndMegaphoneFetcher { async #processMegaphones( megaphones: ReadonlyArray - ): Promise { + ): Promise { const nowSeconds = Math.round(Date.now() / 1000); const ourE164 = itemStorage.user.getNumber(); const ourAci = itemStorage.user.getAci(); const locales = this.#getLocales(); + let savedCount = 0; for (const megaphone of megaphones) { const { uuid } = megaphone; @@ -254,6 +256,14 @@ export class ReleaseNoteAndMegaphoneFetcher { continue; } + // API allows for empty imagePath, but we require it for desktop + if (localeDetail.imagePath == null) { + log.error( + `processMegaphones: megaphone ${uuid} ${localeDetail.localeFetched} missing imagePath, skipping` + ); + continue; + } + // Create the megaphone log.info(`processMegaphones: saving megaphone ${uuid}`); const hydratedMegaphone: RemoteMegaphoneType = { @@ -281,6 +291,7 @@ export class ReleaseNoteAndMegaphoneFetcher { }; // eslint-disable-next-line no-await-in-loop await DataWriter.createMegaphone(hydratedMegaphone); + savedCount += 1; } catch (error) { // Don't add it, we'll try again later log.warn( @@ -289,6 +300,8 @@ export class ReleaseNoteAndMegaphoneFetcher { ); } } + + return savedCount; } async #getReleaseNote( @@ -543,7 +556,10 @@ export class ReleaseNoteAndMegaphoneFetcher { (megaphone): megaphone is ManifestMegaphoneType => megaphone.desktopMinVersion != null ); - await this.#processMegaphones(validMegaphones); + const savedCount = await this.#processMegaphones(validMegaphones); + if (savedCount > 0) { + drop(runMegaphoneCheck()); + } } const validNotes = manifest.announcements.filter( diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 9140b366e6..faf99ded61 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -1353,6 +1353,8 @@ type WritableInterface = { createMegaphone: (megaphone: RemoteMegaphoneType) => void; updateMegaphone: (megaphone: RemoteMegaphoneType) => void; deleteMegaphone: (megaphoneId: RemoteMegaphoneId) => void; + finishMegaphone: (megaphoneId: RemoteMegaphoneId) => void; + snoozeMegaphone: (megaphoneId: RemoteMegaphoneId) => void; internalDeleteAllMegaphones: () => number; appendPinnedMessage: ( diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 1bfde6f8e1..a940904bf9 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -270,6 +270,8 @@ import { createMegaphone, updateMegaphone, deleteMegaphone, + finishMegaphone, + snoozeMegaphone, internalDeleteAllMegaphones, getAllMegaphoneImageLocalPaths, hasMegaphone, @@ -752,6 +754,8 @@ export const DataWriter: ServerWritableInterface = { createMegaphone, updateMegaphone, deleteMegaphone, + finishMegaphone, + snoozeMegaphone, internalDeleteAllMegaphones, appendPinnedMessage, diff --git a/ts/sql/server/megaphones.std.ts b/ts/sql/server/megaphones.std.ts index ddc7247c48..a6d808082f 100644 --- a/ts/sql/server/megaphones.std.ts +++ b/ts/sql/server/megaphones.std.ts @@ -173,6 +173,40 @@ export function updateMegaphone( db.prepare(query).run(params); } +export function finishMegaphone( + db: WritableDB, + megaphoneId: RemoteMegaphoneId +): void { + const [query, params] = sql` + UPDATE megaphones + SET isFinished = 1 + WHERE id = ${megaphoneId} + `; + const result = db.prepare(query).run(params); + strictAssert( + result.changes === 1, + `finishMegaphone: Expected changes: 1, Actual: ${result.changes}` + ); +} + +export function snoozeMegaphone( + db: WritableDB, + megaphoneId: RemoteMegaphoneId +): void { + const [query, params] = sql` + UPDATE megaphones + SET + snoozedAt = ${Date.now()}, + snoozeCount = snoozeCount + 1 + WHERE id = ${megaphoneId} + `; + const result = db.prepare(query).run(params); + strictAssert( + result.changes === 1, + `snoozeMegaphone: Expected changes: 1, Actual: ${result.changes}` + ); +} + export function deleteMegaphone( db: WritableDB, megaphoneId: RemoteMegaphoneId diff --git a/ts/state/actions.preload.ts b/ts/state/actions.preload.ts index 06bdc46086..3c862e69d5 100644 --- a/ts/state/actions.preload.ts +++ b/ts/state/actions.preload.ts @@ -24,6 +24,7 @@ import { actions as items } from './ducks/items.preload.js'; import { actions as lightbox } from './ducks/lightbox.preload.js'; import { actions as linkPreviews } from './ducks/linkPreviews.preload.js'; import { actions as mediaGallery } from './ducks/mediaGallery.preload.js'; +import { actions as megaphones } from './ducks/megaphones.preload.js'; import { actions as nav } from './ducks/nav.std.js'; import { actions as network } from './ducks/network.dom.js'; import { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js'; @@ -63,6 +64,7 @@ export const actionCreators: ReduxActions = { lightbox, linkPreviews, mediaGallery, + megaphones, nav, network, notificationProfiles, diff --git a/ts/state/ducks/megaphones.preload.ts b/ts/state/ducks/megaphones.preload.ts new file mode 100644 index 0000000000..6c3de1ed23 --- /dev/null +++ b/ts/state/ducks/megaphones.preload.ts @@ -0,0 +1,166 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ReadonlyDeep } from 'type-fest'; +import type { ThunkAction } from 'redux-thunk'; + +import type { StateType as RootStateType } from '../reducer.preload.js'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js'; +import { useBoundActions } from '../../hooks/useBoundActions.std.js'; +import { createLogger } from '../../logging/log.std.js'; +import * as Errors from '../../types/errors.std.js'; +import { DataWriter } from '../../sql/Client.preload.js'; +import type { + MegaphoneCtaId, + RemoteMegaphoneId, + VisibleRemoteMegaphoneType, +} from '../../types/Megaphone.std.js'; +import type { ChangeLocationAction } from './nav.std.js'; +import { actions as navActions } from './nav.std.js'; +import { NavTab, SettingsPage } from '../../types/Nav.std.js'; + +const log = createLogger('megaphones'); + +// State + +export type MegaphonesStateType = ReadonlyDeep<{ + visibleMegaphones: ReadonlyArray; +}>; + +export function getEmptyState(): MegaphonesStateType { + return { + visibleMegaphones: [], + }; +} + +export function getInitialMegaphonesState(): MegaphonesStateType { + // Visible megaphones are loaded by the megaphone service + return { + visibleMegaphones: [], + }; +} + +// Actions + +export const ADD_VISIBLE_MEGAPHONE = 'megaphones/ADD_VISIBLE_MEGAPHONE'; +export const REMOVE_VISIBLE_MEGAPHONE = 'megaphones/REMOVE_VISIBLE_MEGAPHONE'; + +export type AddVisibleMegaphoneAction = ReadonlyDeep<{ + type: typeof ADD_VISIBLE_MEGAPHONE; + payload: { megaphone: VisibleRemoteMegaphoneType }; +}>; + +export type RemoveVisibleMegaphoneAction = ReadonlyDeep<{ + type: typeof REMOVE_VISIBLE_MEGAPHONE; + payload: { megaphoneId: RemoteMegaphoneId }; +}>; + +export type MegaphoneAction = ReadonlyDeep< + AddVisibleMegaphoneAction | RemoveVisibleMegaphoneAction +>; + +// Action Creators + +function addVisibleMegaphone( + megaphone: VisibleRemoteMegaphoneType +): AddVisibleMegaphoneAction { + return { + type: ADD_VISIBLE_MEGAPHONE, + payload: { megaphone }, + }; +} + +function removeVisibleMegaphone( + megaphoneId: RemoteMegaphoneId +): RemoveVisibleMegaphoneAction { + return { + type: REMOVE_VISIBLE_MEGAPHONE, + payload: { megaphoneId }, + }; +} + +function interactWithMegaphone( + megaphoneId: RemoteMegaphoneId, + ctaId: MegaphoneCtaId +): ThunkAction< + void, + RootStateType, + unknown, + RemoveVisibleMegaphoneAction | ChangeLocationAction +> { + return async dispatch => { + if (ctaId === 'donate' || ctaId === 'finish') { + try { + log.info(`Finishing megaphone ${megaphoneId}, ctaId=${ctaId}`); + await DataWriter.finishMegaphone(megaphoneId); + } catch (error) { + log.error( + `Failed to finish megaphone ${megaphoneId}`, + Errors.toLogFormat(error) + ); + } + } + + if (ctaId === 'donate') { + dispatch( + navActions.changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.Donations, + }, + }) + ); + } else if (ctaId === 'snooze') { + try { + log.info(`Snoozing megaphone ${megaphoneId}`); + await DataWriter.snoozeMegaphone(megaphoneId); + } catch (error) { + log.error( + `Failed to snooze megaphone ${megaphoneId}`, + Errors.toLogFormat(error) + ); + } + } + + dispatch({ + type: REMOVE_VISIBLE_MEGAPHONE, + payload: { megaphoneId }, + }); + }; +} + +export const actions = { + addVisibleMegaphone, + removeVisibleMegaphone, + interactWithMegaphone, +}; + +export const useMegaphonesActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +// Reducer + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): MegaphonesStateType { + if (action.type === ADD_VISIBLE_MEGAPHONE) { + return { + ...state, + visibleMegaphones: [...state.visibleMegaphones, action.payload.megaphone], + }; + } + + if (action.type === REMOVE_VISIBLE_MEGAPHONE) { + const { megaphoneId } = action.payload; + const visibleMegaphones = state.visibleMegaphones.filter( + megaphone => megaphone.id !== megaphoneId + ); + return { + ...state, + visibleMegaphones, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.preload.ts b/ts/state/getInitialState.preload.ts index 2eb2c511f2..901526f142 100644 --- a/ts/state/getInitialState.preload.ts +++ b/ts/state/getInitialState.preload.ts @@ -27,6 +27,7 @@ import { getEmptyState as itemsEmptyState } from './ducks/items.preload.js'; import { getEmptyState as lightboxEmptyState } from './ducks/lightbox.preload.js'; import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews.preload.js'; import { getEmptyState as mediaGalleryEmptyState } from './ducks/mediaGallery.preload.js'; +import { getEmptyState as megaphonesEmptyState } from './ducks/megaphones.preload.js'; import { getEmptyState as navEmptyState } from './ducks/nav.std.js'; import { getEmptyState as networkEmptyState } from './ducks/network.dom.js'; import { getEmptyState as notificationProfilesEmptyState } from './ducks/notificationProfiles.preload.js'; @@ -105,6 +106,7 @@ export function getInitialState( emojis: recentEmoji, gifs, items, + megaphones: megaphonesEmptyState(), notificationProfiles: { ...notificationProfilesEmptyState(), override: items.notificationProfileOverride, @@ -170,6 +172,7 @@ function getEmptyState(): StateType { lightbox: lightboxEmptyState(), linkPreviews: linkPreviewsEmptyState(), mediaGallery: mediaGalleryEmptyState(), + megaphones: megaphonesEmptyState(), nav: navEmptyState(), network: networkEmptyState(), notificationProfiles: notificationProfilesEmptyState(), diff --git a/ts/state/initializeRedux.preload.ts b/ts/state/initializeRedux.preload.ts index ab75ae6b89..a89d4c012e 100644 --- a/ts/state/initializeRedux.preload.ts +++ b/ts/state/initializeRedux.preload.ts @@ -20,6 +20,7 @@ import type { StickersStateType } from './ducks/stickers.preload.js'; import type { GifsStateType } from './ducks/gifs.preload.js'; import type { NotificationProfileType } from '../types/NotificationProfile.std.js'; import type { CurrentChatFolder } from '../types/CurrentChatFolders.std.js'; +import type { MegaphonesStateType } from './ducks/megaphones.preload.js'; export type ReduxInitData = { badgesState: BadgesStateType; @@ -30,6 +31,7 @@ export type ReduxInitData = { donations: DonationsStateType; gifs: GifsStateType; mainWindowStats: MainWindowStatsType; + megaphones: MegaphonesStateType; menuOptions: MenuOptionsType; notificationProfiles: ReadonlyArray; recentEmoji: RecentEmojiObjectType; @@ -89,6 +91,7 @@ export function initializeRedux(data: ReduxInitData): void { actionCreators.mediaGallery, store.dispatch ), + megaphones: bindActionCreators(actionCreators.megaphones, store.dispatch), nav: bindActionCreators(actionCreators.nav, store.dispatch), network: bindActionCreators(actionCreators.network, store.dispatch), pinnedMessages: bindActionCreators( diff --git a/ts/state/reducer.preload.ts b/ts/state/reducer.preload.ts index 4c020662ab..ca8ae1877c 100644 --- a/ts/state/reducer.preload.ts +++ b/ts/state/reducer.preload.ts @@ -26,6 +26,7 @@ import { reducer as items } from './ducks/items.preload.js'; import { reducer as lightbox } from './ducks/lightbox.preload.js'; import { reducer as linkPreviews } from './ducks/linkPreviews.preload.js'; import { reducer as mediaGallery } from './ducks/mediaGallery.preload.js'; +import { reducer as megaphones } from './ducks/megaphones.preload.js'; import { reducer as nav } from './ducks/nav.std.js'; import { reducer as network } from './ducks/network.dom.js'; import { reducer as notificationProfiles } from './ducks/notificationProfiles.preload.js'; @@ -65,6 +66,7 @@ export const reducer = combineReducers({ lightbox, linkPreviews, mediaGallery, + megaphones, nav, network, notificationProfiles, diff --git a/ts/state/selectors/megaphones.preload.ts b/ts/state/selectors/megaphones.preload.ts new file mode 100644 index 0000000000..5cd8bc8f85 --- /dev/null +++ b/ts/state/selectors/megaphones.preload.ts @@ -0,0 +1,41 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; +import type { StateType } from '../reducer.preload.js'; +import type { MegaphonesStateType } from '../ducks/megaphones.preload.js'; +import { getAbsoluteMegaphoneImageFilePath } from '../../util/migrations.preload.js'; +import { + MegaphoneType, + type RemoteMegaphoneDisplayType, + type VisibleRemoteMegaphoneType, +} from '../../types/Megaphone.std.js'; +import type { StateSelector } from '../types.std.js'; + +export function getMegaphonesState( + state: Readonly +): MegaphonesStateType { + return state.megaphones; +} + +export const getVisibleMegaphones: StateSelector< + ReadonlyArray +> = createSelector(getMegaphonesState, state => { + return state.visibleMegaphones; +}); + +export const getVisibleMegaphonesForDisplay: StateSelector< + ReadonlyArray +> = createSelector(getVisibleMegaphones, visibleMegaphones => + visibleMegaphones.map(megaphone => ({ + type: MegaphoneType.Remote, + remoteMegaphoneId: megaphone.id, + primaryCtaId: megaphone.primaryCtaId, + secondaryCtaId: megaphone.secondaryCtaId, + primaryCtaText: megaphone.primaryCtaText, + secondaryCtaText: megaphone.secondaryCtaText, + title: megaphone.title, + body: megaphone.body, + imagePath: getAbsoluteMegaphoneImageFilePath(megaphone.imagePath), + })) +); diff --git a/ts/state/smart/ToastManager.preload.tsx b/ts/state/smart/ToastManager.preload.tsx index b8c89a8327..bd09fc5635 100644 --- a/ts/state/smart/ToastManager.preload.tsx +++ b/ts/state/smart/ToastManager.preload.tsx @@ -33,6 +33,8 @@ import type { WidthBreakpoint } from '../../components/_util.std.js'; import { getToast } from '../selectors/toast.std.js'; import { useDonationsActions } from '../ducks/donations.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { getVisibleMegaphonesForDisplay } from '../selectors/megaphones.preload.js'; +import { useMegaphonesActions } from '../ducks/megaphones.preload.js'; export type SmartPropsType = Readonly<{ disableMegaphone?: boolean; @@ -60,6 +62,8 @@ export const SmartToastManager = memo(function SmartToastManager({ const { username } = useSelector(getMe); const selectedNavTab = useSelector(getSelectedNavTab); const selectedConversationId = useSelector(getSelectedConversationId); + const megaphones = useSelector(getVisibleMegaphonesForDisplay); + const { changeLocation } = useNavActions(); const { setDidResume } = useDonationsActions(); @@ -67,6 +71,7 @@ export const SmartToastManager = memo(function SmartToastManager({ const { retryCallQualitySurvey } = useCallingActions(); const { openFileInFolder, hideToast } = useToastActions(); const { toggleUsernameOnboarding } = useGlobalModalActions(); + const { interactWithMegaphone } = useMegaphonesActions(); let megaphone: AnyActionableMegaphone | undefined; @@ -82,6 +87,12 @@ export const SmartToastManager = memo(function SmartToastManager({ drop(itemStorage.put('hasCompletedUsernameOnboarding', true)); }, }; + } else if (megaphones.length > 0) { + megaphone = { + ...megaphones[0], + type: MegaphoneType.Remote, + onInteractWithMegaphone: interactWithMegaphone, + }; } const centerToast = diff --git a/ts/state/types.std.ts b/ts/state/types.std.ts index c3f2cd63e9..389021b421 100644 --- a/ts/state/types.std.ts +++ b/ts/state/types.std.ts @@ -28,6 +28,7 @@ import type { actions as items } from './ducks/items.preload.js'; import type { actions as lightbox } from './ducks/lightbox.preload.js'; import type { actions as linkPreviews } from './ducks/linkPreviews.preload.js'; import type { actions as mediaGallery } from './ducks/mediaGallery.preload.js'; +import type { actions as megaphones } from './ducks/megaphones.preload.js'; import type { actions as nav } from './ducks/nav.std.js'; import type { actions as network } from './ducks/network.dom.js'; import type { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js'; @@ -66,6 +67,7 @@ export type ReduxActions = { lightbox: typeof lightbox; linkPreviews: typeof linkPreviews; mediaGallery: typeof mediaGallery; + megaphones: typeof megaphones; nav: typeof nav; network: typeof network; notificationProfiles: typeof notificationProfiles; diff --git a/ts/test-electron/services/ReleaseNoteAndMegaphoneFetcher_test.preload.ts b/ts/test-electron/services/ReleaseNoteAndMegaphoneFetcher_test.preload.ts index 7569e1a5ed..f5dc4fde98 100644 --- a/ts/test-electron/services/ReleaseNoteAndMegaphoneFetcher_test.preload.ts +++ b/ts/test-electron/services/ReleaseNoteAndMegaphoneFetcher_test.preload.ts @@ -80,7 +80,7 @@ describe('ReleaseNoteAndMegaphoneFetcher', () => { title: string; body: string; image?: string; - primaryCtaTest?: string; + primaryCtaText?: string; secondaryCtaText?: string; }; releaseNote?: { @@ -159,7 +159,7 @@ describe('ReleaseNoteAndMegaphoneFetcher', () => { title: 'megaphone', body: 'cats', image: 'https://signal.org/axolotl.png', - primaryCtaTest: 'donate', + primaryCtaText: 'donate', secondaryCtaText: 'snooze', } ), @@ -469,7 +469,7 @@ describe('ReleaseNoteAndMegaphoneFetcher', () => { title: 'megaphone', body: 'cats', image: 'https://signal.org/axolotl.png', - primaryCtaTest: 'donate', + primaryCtaText: 'donate', secondaryCtaText: 'snooze', }, }); @@ -550,7 +550,8 @@ describe('ReleaseNoteAndMegaphoneFetcher', () => { uuid: megaphoneForMyCountry.uuid, title: 'megaphone', body: 'cats', - primaryCtaTest: 'donate', + image: 'https://signal.org/axolotl.png', + primaryCtaText: 'donate', secondaryCtaText: 'snooze', }, }); diff --git a/ts/test-node/services/megaphone_test.preload.ts b/ts/test-node/services/megaphone_test.preload.ts new file mode 100644 index 0000000000..cf9fd7b29d --- /dev/null +++ b/ts/test-node/services/megaphone_test.preload.ts @@ -0,0 +1,128 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; + +import { + isMegaphoneCtaIdValid, + isMegaphoneDeletable, + isMegaphoneShowable, +} from '../../services/megaphone.preload.js'; +import { DAY } from '../../util/durations/index.std.js'; +import type { + RemoteMegaphoneId, + RemoteMegaphoneType, +} from '../../types/Megaphone.std.js'; + +const FAKE_MEGAPHONE: RemoteMegaphoneType = { + id: uuid() as RemoteMegaphoneId, + priority: 100, + desktopMinVersion: '1.37.0', + dontShowBeforeEpochMs: Date.now() - 1 * DAY, + dontShowAfterEpochMs: Date.now() + 14 * DAY, + showForNumberOfDays: 30, + conditionalId: 'standard_donate', + primaryCtaId: 'donate', + primaryCtaData: null, + secondaryCtaId: 'snooze', + secondaryCtaData: { snoozeDurationDays: [5, 7, 100] }, + localeFetched: 'en', + title: 'megaphone', + body: 'cats', + imagePath: '../../../fixtures/donate-heart.png', + primaryCtaText: 'donate', + secondaryCtaText: 'snooze', + snoozeCount: 0, + snoozedAt: null, + shownAt: null, + isFinished: false, +}; + +describe('megaphone service', () => { + function getMegaphone( + extraProps: Partial + ): RemoteMegaphoneType { + return { + ...FAKE_MEGAPHONE, + ...extraProps, + }; + } + + describe('isMegaphoneDeletable', () => { + it('handles fresh megaphones', () => { + assert.strictEqual(isMegaphoneDeletable(FAKE_MEGAPHONE), false); + }); + + it('handles expired megaphones', () => { + const expiredMegaphone: RemoteMegaphoneType = { + ...FAKE_MEGAPHONE, + dontShowAfterEpochMs: Date.now() - 1 * DAY, + }; + assert.strictEqual(isMegaphoneDeletable(expiredMegaphone), true); + }); + }); + + describe('isMegaphoneCtaIdValid', () => { + it('handles valid ctaIds', () => { + assert.strictEqual(isMegaphoneCtaIdValid('donate'), true); + assert.strictEqual(isMegaphoneCtaIdValid('snooze'), true); + assert.strictEqual(isMegaphoneCtaIdValid('finish'), true); + assert.strictEqual(isMegaphoneCtaIdValid(null), true); + }); + + it('handles invalid ctaIds', () => { + assert.strictEqual(isMegaphoneCtaIdValid('DONATE'), false); + assert.strictEqual(isMegaphoneCtaIdValid('potato'), false); + assert.strictEqual(isMegaphoneCtaIdValid(''), false); + }); + }); + + describe('isMegaphoneShowable', () => { + it('handles fresh megaphone', () => { + assert.strictEqual(isMegaphoneShowable(FAKE_MEGAPHONE), true); + }); + + it('handles finished megaphone', () => { + const megaphone = getMegaphone({ isFinished: true }); + assert.strictEqual(isMegaphoneShowable(megaphone), false); + }); + + it('handles megaphones snoozed recently', () => { + const megaphone = getMegaphone({ + snoozeCount: 1, + snoozedAt: Date.now() - 1000, + }); + assert.strictEqual(isMegaphoneShowable(megaphone), false); + }); + + it('handles megaphones snoozed and now ready to show again', () => { + const megaphone = getMegaphone({ + snoozeCount: 1, + snoozedAt: Date.now() - 7 * DAY, + }); + assert.strictEqual(isMegaphoneShowable(megaphone), true); + }); + + it('handles megaphone expired past dontShowAfterEpochMs', () => { + const megaphone = getMegaphone({ + dontShowAfterEpochMs: Date.now() - 1 * DAY, + }); + assert.strictEqual(isMegaphoneShowable(megaphone), false); + }); + + it('handles megaphone with invalid primaryCtaId', () => { + const megaphone = getMegaphone({ + primaryCtaId: 'potato', + }); + assert.strictEqual(isMegaphoneShowable(megaphone), false); + }); + + it('handles megaphone with invalid secondaryCtaId', () => { + const megaphone = getMegaphone({ + secondaryCtaId: 'potato', + }); + assert.strictEqual(isMegaphoneShowable(megaphone), false); + }); + }); +}); diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index fe849e4580..113006b758 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -114,8 +114,7 @@ import { badgeFromServerSchema } from '../badges/parseBadgesFromServer.std.js'; import { ZERO_DECIMAL_CURRENCIES } from '../util/currency.dom.js'; import type { JobCancelReason } from '../jobs/types.std.js'; import { - RemoteMegaphoneSecondaryCtaDataSchema, - RemoteMegaphoneUnknownCtaDataSchema, + RemoteMegaphoneCtaDataSchema, type RemoteMegaphoneId, } from '../types/Megaphone.std.js'; @@ -1441,9 +1440,9 @@ export const releaseNotesManifestSchema = z.object({ showForNumberOfDays: z.number().nonnegative(), conditionalId: z.string().optional(), primaryCtaId: z.string().optional(), - primaryCtaData: RemoteMegaphoneUnknownCtaDataSchema.optional(), + primaryCtaData: RemoteMegaphoneCtaDataSchema.optional(), secondaryCtaId: z.string().optional(), - secondaryCtaData: RemoteMegaphoneSecondaryCtaDataSchema.optional(), + secondaryCtaData: RemoteMegaphoneCtaDataSchema.optional(), }) .array(), }); diff --git a/ts/types/Megaphone.std.ts b/ts/types/Megaphone.std.ts index 6f4a8df8e9..8b7faba5a8 100644 --- a/ts/types/Megaphone.std.ts +++ b/ts/types/Megaphone.std.ts @@ -1,14 +1,19 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { z } from 'zod'; -import { - Environment, - getEnvironment, - isMockEnvironment, -} from '../environment.std.js'; +import type { Simplify } from 'type-fest'; +import { safeParsePartial } from '../util/schemas.std.js'; +import { DAY } from '../util/durations/index.std.js'; + +const SNOOZE_DEFAULT_DURATION_DAYS = 3; +const SNOOZE_DEFAULT_CTA_DATA: RemoteMegaphoneSnoozeCtaType = { + snoozeDurationDays: [SNOOZE_DEFAULT_DURATION_DAYS], +}; +export const SNOOZE_DEFAULT_DURATION = SNOOZE_DEFAULT_DURATION_DAYS * DAY; export enum MegaphoneType { UsernameOnboarding = 'UsernameOnboarding', + Remote = 'Remote', } export type UsernameOnboardingMegaphoneType = { @@ -21,23 +26,65 @@ export type UsernameOnboardingActionableMegaphoneType = onDismiss: () => void; }; -export type AnyMegaphone = UsernameOnboardingMegaphoneType; +export type VisibleRemoteMegaphoneType = Simplify< + Omit & { + primaryCtaId: MegaphoneCtaId | null; + secondaryCtaId: MegaphoneCtaId | null; + } +>; -export type AnyActionableMegaphone = UsernameOnboardingActionableMegaphoneType; +export type RemoteMegaphoneDisplayType = Simplify< + Readonly< + Pick< + VisibleRemoteMegaphoneType, + | 'title' + | 'body' + | 'imagePath' + | 'primaryCtaId' + | 'secondaryCtaId' + | 'primaryCtaText' + | 'secondaryCtaText' + > & { + type: MegaphoneType.Remote; + remoteMegaphoneId: RemoteMegaphoneId; + } + > +>; + +export type RemoteActionableMegaphoneType = RemoteMegaphoneDisplayType & { + onInteractWithMegaphone: ( + megaphoneId: RemoteMegaphoneId, + ctaId: MegaphoneCtaId + ) => void; +}; + +export type AnyActionableMegaphone = + | UsernameOnboardingActionableMegaphoneType + | RemoteActionableMegaphoneType; export type RemoteMegaphoneId = string & { RemoteMegaphoneId: never }; // uuid -const RemoteMegaphoneSecondaryCtaSnoozeSchema = z.object({ +export enum MegaphoneCtaId { + Donate = 'donate', + Finish = 'finish', + Snooze = 'snooze', +} + +export const RemoteMegaphoneSnoozeCtaSchema = z.object({ snoozeDurationDays: z.array(z.number()), }); +export type RemoteMegaphoneSnoozeCtaType = z.infer< + typeof RemoteMegaphoneSnoozeCtaSchema +>; + export const RemoteMegaphoneUnknownCtaDataSchema = z.record( z.string(), z.any() ); -export const RemoteMegaphoneSecondaryCtaDataSchema = z.union([ - RemoteMegaphoneSecondaryCtaSnoozeSchema, +export const RemoteMegaphoneCtaDataSchema = z.union([ + RemoteMegaphoneSnoozeCtaSchema, RemoteMegaphoneUnknownCtaDataSchema, ]); @@ -51,15 +98,15 @@ export const RemoteMegaphoneSchema = z.object({ showForNumberOfDays: z.number().int().positive(), primaryCtaId: z.string().nullable(), secondaryCtaId: z.string().nullable(), - primaryCtaData: RemoteMegaphoneUnknownCtaDataSchema.nullable(), - secondaryCtaData: RemoteMegaphoneSecondaryCtaDataSchema.nullable(), + primaryCtaData: RemoteMegaphoneCtaDataSchema.nullable(), + secondaryCtaData: RemoteMegaphoneCtaDataSchema.nullable(), conditionalId: z.string().nullable(), // Locale specific remote note title: z.string(), body: z.string(), primaryCtaText: z.string().nullable(), secondaryCtaText: z.string().nullable(), - imagePath: z.string().nullable(), + imagePath: z.string(), // Local state localeFetched: z.string(), shownAt: z.number().int().positive().nullable(), @@ -70,17 +117,31 @@ export const RemoteMegaphoneSchema = z.object({ export type RemoteMegaphoneType = z.infer; -export function isRemoteMegaphoneEnabled(): boolean { - const env = getEnvironment(); +export function getMegaphoneSnoozeConfig( + megaphone: RemoteMegaphoneType +): RemoteMegaphoneSnoozeCtaType { + let parseableCtaData; - if ( - env === Environment.Development || - env === Environment.Test || - env === Environment.Staging || - isMockEnvironment() - ) { - return true; + if (megaphone.primaryCtaId === 'snooze') { + parseableCtaData = megaphone.primaryCtaData; + } else if (megaphone.secondaryCtaId === 'snooze') { + parseableCtaData = megaphone.secondaryCtaData; + } else { + throw new Error('primaryCtaId or secondaryCtaId must be snooze'); } - return false; + const result = safeParsePartial( + RemoteMegaphoneSnoozeCtaSchema, + parseableCtaData ?? {} + ); + return result.success ? result.data : SNOOZE_DEFAULT_CTA_DATA; +} + +export function getMegaphoneLastSnoozeDurationMs( + megaphone: RemoteMegaphoneType +): number { + const { snoozeDurationDays } = getMegaphoneSnoozeConfig(megaphone); + const lastSnoozeCount = Math.max(megaphone.snoozeCount - 1, 0); + const snoozeIndex = Math.min(lastSnoozeCount, snoozeDurationDays.length - 1); + return snoozeDurationDays[snoozeIndex] * DAY; } diff --git a/ts/windows/main/preload_test.preload.ts b/ts/windows/main/preload_test.preload.ts index 04bf430152..776fb88bbd 100644 --- a/ts/windows/main/preload_test.preload.ts +++ b/ts/windows/main/preload_test.preload.ts @@ -112,6 +112,9 @@ window.testUtilities = { isFullScreen: false, isMaximized: false, }, + megaphones: { + visibleMegaphones: [], + }, menuOptions: { development: false, devTools: false,