diff --git a/fixtures/donate-heart.png b/images/donate-heart.png similarity index 100% rename from fixtures/donate-heart.png rename to images/donate-heart.png diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 70bcbf4bbe..6c3d0b0bb1 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -631,6 +631,7 @@ export default { action('generateDonationReceiptBlob')(); return new Blob(); }, + addVisibleMegaphone: async () => Promise.resolve(), internalDeleteAllMegaphones: async () => { return Promise.resolve(0); }, diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 367832128c..f7166dd341 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -101,6 +101,7 @@ import { AxoButton } from '../axo/AxoButton.dom.js'; import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js'; import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js'; import { isDonationsPage } from './PreferencesDonations.dom.js'; +import type { VisibleRemoteMegaphoneType } from '../types/Megaphone.std.js'; const { isNumber, noop, partition } = lodash; @@ -289,6 +290,7 @@ type PropsFunctionType = { receipt: DonationReceipt, i18n: LocalizerType ) => Promise; + addVisibleMegaphone: (megaphone: VisibleRemoteMegaphoneType) => void; // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; @@ -541,6 +543,7 @@ export function Preferences({ internalAddDonationReceipt, saveAttachmentToDisk, generateDonationReceiptBlob, + addVisibleMegaphone, internalDeleteAllMegaphones, __dangerouslyRunAbitraryReadOnlySqlQuery, cqsTestMode, @@ -2327,6 +2330,7 @@ export function Preferences({ internalAddDonationReceipt={internalAddDonationReceipt} saveAttachmentToDisk={saveAttachmentToDisk} generateDonationReceiptBlob={generateDonationReceiptBlob} + addVisibleMegaphone={addVisibleMegaphone} internalDeleteAllMegaphones={internalDeleteAllMegaphones} __dangerouslyRunAbitraryReadOnlySqlQuery={ __dangerouslyRunAbitraryReadOnlySqlQuery diff --git a/ts/components/PreferencesInternal.dom.tsx b/ts/components/PreferencesInternal.dom.tsx index 062ddf8a02..bab02a7e6b 100644 --- a/ts/components/PreferencesInternal.dom.tsx +++ b/ts/components/PreferencesInternal.dom.tsx @@ -21,6 +21,8 @@ import { getHumanDonationAmount } from '../util/currency.dom.js'; import { AutoSizeTextArea } from './AutoSizeTextArea.dom.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; import { AxoSwitch } from '../axo/AxoSwitch.dom.js'; +import type { VisibleRemoteMegaphoneType } from '../types/Megaphone.std.js'; +import { internalGetTestMegaphone } from '../util/getTestMegaphone.std.js'; const log = createLogger('PreferencesInternal'); @@ -33,6 +35,7 @@ export function PreferencesInternal({ internalAddDonationReceipt, saveAttachmentToDisk, generateDonationReceiptBlob, + addVisibleMegaphone, internalDeleteAllMegaphones, __dangerouslyRunAbitraryReadOnlySqlQuery, cqsTestMode, @@ -55,6 +58,7 @@ export function PreferencesInternal({ receipt: DonationReceipt, i18n: LocalizerType ) => Promise; + addVisibleMegaphone: (megaphone: VisibleRemoteMegaphoneType) => void; internalDeleteAllMegaphones: () => Promise; __dangerouslyRunAbitraryReadOnlySqlQuery: ( readonlySqlQuery: string @@ -73,6 +77,9 @@ export function PreferencesInternal({ BackupValidationResultType | undefined >(); + const [showMegaphoneResult, setShowMegaphoneResult] = useState< + string | undefined + >(); const [deleteAllMegaphonesResult, setDeleteAllMegaphonesResult] = useState< number | undefined >(); @@ -482,6 +489,42 @@ export function PreferencesInternal({ + +
+ Show a test megaphone in memory. Disappears on restart. +
+
+ { + const megaphone = internalGetTestMegaphone(); + addVisibleMegaphone(megaphone); + setShowMegaphoneResult( + `Megaphone shown. Go to Chats tab to view.\n${JSON.stringify(megaphone, null, 2)}` + ); + }} + > + Show megaphone + +
+ {showMegaphoneResult != null && ( + null} + readOnly + placeholder="" + moduleClassName="Preferences__ReadonlySqlPlayground__Textarea" + /> + )} +
Delete local records of remote megaphones diff --git a/ts/components/RemoteMegaphone.dom.stories.tsx b/ts/components/RemoteMegaphone.dom.stories.tsx index aed7562479..2df4080e5c 100644 --- a/ts/components/RemoteMegaphone.dom.stories.tsx +++ b/ts/components/RemoteMegaphone.dom.stories.tsx @@ -26,7 +26,7 @@ export default { secondaryCtaText: 'Not now', title: 'Donate to Signal', body: 'Signal is powered by people like you. Show your support today!', - imagePath: '/fixtures/donate-heart.png', + imagePath: 'images/donate-heart.png', isFullSize: true, onClickNarrowMegaphone: action('onClickNarrowMegaphone'), onInteractWithMegaphone: action('onInteractWithMegaphone'), diff --git a/ts/state/ducks/megaphones.preload.ts b/ts/state/ducks/megaphones.preload.ts index a6a6a6d606..d897cca532 100644 --- a/ts/state/ducks/megaphones.preload.ts +++ b/ts/state/ducks/megaphones.preload.ts @@ -17,6 +17,7 @@ import type { import type { ChangeLocationAction } from './nav.std.js'; import { actions as navActions } from './nav.std.js'; import { NavTab, SettingsPage } from '../../types/Nav.std.js'; +import { isTestMegaphoneId } from '../../util/getTestMegaphone.std.js'; const log = createLogger('megaphones'); @@ -88,10 +89,14 @@ function interactWithMegaphone( RemoveVisibleMegaphoneAction | ChangeLocationAction > { return async dispatch => { + const isTest = isTestMegaphoneId(megaphoneId); + if (ctaId === 'donate' || ctaId === 'finish') { try { log.info(`Finishing megaphone ${megaphoneId}, ctaId=${ctaId}`); - await DataWriter.finishMegaphone(megaphoneId); + if (!isTest) { + await DataWriter.finishMegaphone(megaphoneId); + } } catch (error) { log.error( `Failed to finish megaphone ${megaphoneId}`, @@ -112,7 +117,9 @@ function interactWithMegaphone( } else if (ctaId === 'snooze') { try { log.info(`Snoozing megaphone ${megaphoneId}`); - await DataWriter.snoozeMegaphone(megaphoneId); + if (!isTest) { + await DataWriter.snoozeMegaphone(megaphoneId); + } } catch (error) { log.error( `Failed to snooze megaphone ${megaphoneId}`, diff --git a/ts/state/selectors/megaphones.preload.ts b/ts/state/selectors/megaphones.preload.ts index 5cd8bc8f85..cb123c8564 100644 --- a/ts/state/selectors/megaphones.preload.ts +++ b/ts/state/selectors/megaphones.preload.ts @@ -11,6 +11,10 @@ import { type VisibleRemoteMegaphoneType, } from '../../types/Megaphone.std.js'; import type { StateSelector } from '../types.std.js'; +import { + isTestMegaphone, + TEST_MEGAPHONE_IMAGE, +} from '../../util/getTestMegaphone.std.js'; export function getMegaphonesState( state: Readonly @@ -36,6 +40,8 @@ export const getVisibleMegaphonesForDisplay: StateSelector< secondaryCtaText: megaphone.secondaryCtaText, title: megaphone.title, body: megaphone.body, - imagePath: getAbsoluteMegaphoneImageFilePath(megaphone.imagePath), + imagePath: isTestMegaphone(megaphone) + ? TEST_MEGAPHONE_IMAGE + : getAbsoluteMegaphoneImageFilePath(megaphone.imagePath), })) ); diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index 1e40081cb0..af991b790d 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -120,6 +120,7 @@ import { DonationsErrorBoundary } from '../../components/DonationsErrorBoundary. import type { SmartPreferencesChatFoldersPageProps } from './PreferencesChatFoldersPage.preload.js'; import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditChatFolderPage.preload.js'; import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.preload.js'; +import { useMegaphonesActions } from '../ducks/megaphones.preload.js'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -225,6 +226,7 @@ export function SmartPreferences(): React.JSX.Element | null { const { showToast } = useToastActions(); const { internalAddDonationReceipt } = useDonationsActions(); const { startPlaintextExport, startLocalBackupExport } = useBackupActions(); + const { addVisibleMegaphone } = useMegaphonesActions(); // Selectors @@ -986,6 +988,7 @@ export function SmartPreferences(): React.JSX.Element | null { internalAddDonationReceipt={internalAddDonationReceipt} saveAttachmentToDisk={saveAttachmentToDisk} generateDonationReceiptBlob={generateDonationReceiptBlob} + addVisibleMegaphone={addVisibleMegaphone} internalDeleteAllMegaphones={internalDeleteAllMegaphones} __dangerouslyRunAbitraryReadOnlySqlQuery={ __dangerouslyRunAbitraryReadOnlySqlQuery diff --git a/ts/test-node/services/megaphone_test.preload.ts b/ts/test-node/services/megaphone_test.preload.ts index 5ceda95326..c84e3b850a 100644 --- a/ts/test-node/services/megaphone_test.preload.ts +++ b/ts/test-node/services/megaphone_test.preload.ts @@ -35,7 +35,7 @@ const FAKE_MEGAPHONE: RemoteMegaphoneType = { localeFetched: 'en', title: 'megaphone', body: 'cats', - imagePath: '../../../fixtures/donate-heart.png', + imagePath: '../../../images/donate-heart.png', primaryCtaText: 'donate', secondaryCtaText: 'snooze', snoozeCount: 0, diff --git a/ts/util/getTestMegaphone.std.ts b/ts/util/getTestMegaphone.std.ts new file mode 100644 index 0000000000..2332d41c4c --- /dev/null +++ b/ts/util/getTestMegaphone.std.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + RemoteMegaphoneId, + VisibleRemoteMegaphoneType, +} from '../types/Megaphone.std.js'; +import { MegaphoneCtaId } from '../types/Megaphone.std.js'; +import { DAY } from './durations/index.std.js'; + +const INTERNAL_TEST_ID = 'INTERNAL_TEST' as RemoteMegaphoneId; +export const TEST_MEGAPHONE_IMAGE = 'images/donate-heart.png'; + +export function internalGetTestMegaphone( + props?: Partial +): VisibleRemoteMegaphoneType { + return { + priority: 100, + desktopMinVersion: '1.0.0', + dontShowBeforeEpochMs: Date.now() - 1 * DAY, + dontShowAfterEpochMs: Date.now() + 14 * DAY, + showForNumberOfDays: 30, + conditionalId: 'internal', + primaryCtaId: MegaphoneCtaId.Donate, + primaryCtaData: null, + secondaryCtaId: MegaphoneCtaId.Snooze, + secondaryCtaData: { snoozeDurationDays: [5, 7, 100] }, + localeFetched: 'en', + title: 'Donate Today', + body: 'As a nonprofit, Signal needs your support.', + imagePath: TEST_MEGAPHONE_IMAGE, + primaryCtaText: 'Donate', + secondaryCtaText: 'Snooze', + snoozeCount: 0, + snoozedAt: null, + shownAt: null, + isFinished: false, + ...props, + id: INTERNAL_TEST_ID, + }; +} + +export function isTestMegaphone( + megaphone: VisibleRemoteMegaphoneType +): boolean { + return megaphone.id === INTERNAL_TEST_ID; +} + +export function isTestMegaphoneId(id: RemoteMegaphoneId): boolean { + return id === INTERNAL_TEST_ID; +}