verification notice sheet

This commit is contained in:
kate-signal 2026-05-20 12:16:01 -04:00 committed by GitHub
parent ddb0f79fc1
commit 7635902bb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 302 additions and 40 deletions

View File

@ -63,6 +63,7 @@
045B408E2EC6897B002D3F9A /* PinnedMessageManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B408D2EC68974002D3F9A /* PinnedMessageManagerTest.swift */; };
045B40922ECE406A002D3F9A /* ConversationViewController+PinnedMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */; };
045B40952ECF98C1002D3F9A /* PinnedMessagesDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */; };
046092262FBCD2DA00A8765F /* SafetyTipsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */; };
046926092E8EBAAE00B1FC74 /* TSInfoMessage+Polls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */; };
0477BE322FA4FC41002F9B47 /* TSReleaseNotesThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */; };
047A6DD02E00B5720048EDF4 /* BackupKeyReminderMegaphoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */; };
@ -4208,6 +4209,7 @@
045B408D2EC68974002D3F9A /* PinnedMessageManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessageManagerTest.swift; sourceTree = "<group>"; };
045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+PinnedMessages.swift"; sourceTree = "<group>"; };
045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessagesDetailsViewController.swift; sourceTree = "<group>"; };
046092232FBCC7E700A8765F /* SafetyTipsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyTipsManager.swift; sourceTree = "<group>"; };
046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Polls.swift"; sourceTree = "<group>"; };
0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = "<group>"; };
047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = "<group>"; };
@ -8486,6 +8488,14 @@
path = PinnedMessages;
sourceTree = "<group>";
};
046092252FBCD28300A8765F /* SafetyTips */ = {
isa = PBXGroup;
children = (
046092232FBCC7E700A8765F /* SafetyTipsManager.swift */,
);
path = SafetyTips;
sourceTree = "<group>";
};
0484CECC2F44B7B4009AB2CB /* AdminDelete */ = {
isa = PBXGroup;
children = (
@ -14640,6 +14650,7 @@
E19C3D922CF6B34200F2C496 /* QRCodes */,
6600F352298C8FBB00B1EDB7 /* Registration */,
F9B0DC3B28948656004E07B7 /* Resources */,
046092252FBCD28300A8765F /* SafetyTips */,
50B791552E8B39230063E71E /* Search */,
6673FF6A2978B5B900F96CFD /* SecureValueRecovery */,
F9C5CB98289453B200548EEE /* Security */,
@ -19775,6 +19786,7 @@
F9C5CDF8289453B400548EEE /* ReverseDispatchQueue.swift in Sources */,
F945FE4A2984796D00C835C7 /* RingrtcFieldTrials.swift in Sources */,
557238D32F2D53FD0033BC9A /* RingrtcVp9Config.swift in Sources */,
046092262FBCD2DA00A8765F /* SafetyTipsManager.swift in Sources */,
668A01332C2B6088007B8808 /* Scheduler.swift in Sources */,
72C905912B9ACA3D00E586B8 /* ScreenLock.swift in Sources */,
7255A4D22B98E2B700E95368 /* ScrubbingLogFormatter.swift in Sources */,

View File

@ -1408,17 +1408,15 @@ extension ConversationViewController: CVComponentDelegate {
}
public func didTapSafetyTips() {
let viewController = SafetyTipsViewController()
viewController.delegate = self
present(viewController, animated: true)
}
}
// MARK: - SafetyTipsViewControllerDelegate
extension ConversationViewController: SafetyTipsViewControllerDelegate {
public func didTapViewMoreSafetyTips() {
let viewController = MoreSafetyTipsViewController()
let viewController = SafetyTipsViewController(
primaryButton: SafetyTipsViewController.Button(
title: CommonStrings.viewMoreButton,
action: { [weak self] in
let viewController = MoreSafetyTipsViewController()
self?.present(viewController, animated: true)
},
),
)
present(viewController, animated: true)
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "verificationcode_alert_96.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -73,10 +73,10 @@ class OutgoingDeviceRestorePresenter: OutgoingDeviceRestoreInitialPresenter {
"OUTGOING_DEVICE_RESTORE_CONTINUE_ON_OTHER_DEVICE_TITLE",
comment: "Title of prompt notifying that action is necessary on the other device.",
),
body: HeroSheetViewController.Body(text: OWSLocalizedString(
body: HeroSheetViewController.Body(textContent: .plain(OWSLocalizedString(
"OUTGOING_DEVICE_RESTORE_CONTINUE_ON_OTHER_DEVICE_BODY",
comment: "Body of prompt notifying that action is necessary on the other device.",
)),
))),
primary: .hero(.animation(named: "circular_indeterminate", height: 60)),
secondary: nil,
)

View File

@ -12,10 +12,10 @@ final class DonationReadMoreSheetViewController: HeroSheetViewController {
hero: .image(.sustainerHeart),
title: nil,
body: HeroSheetViewController.Body(
text: OWSLocalizedString(
textContent: .plain(OWSLocalizedString(
"DONATION_READ_MORE_SHEET_BODY",
comment: "Body text for a sheet discussing donating to Signal.",
),
)),
textAlignment: .left,
textColor: .Signal.label,
bulletPoints: [

View File

@ -40,12 +40,17 @@ class ChatListFYISheetCoordinator {
struct KeyTransparencySelfCheckFailed {}
struct SMSVerificationCodeSent {
let timestampMs: UInt64
}
case badgeThanks(BadgeThanks)
case badgeIssue(BadgeIssue)
case badgeExpiration(BadgeExpiration)
case backupSubscriptionExpired(BackupSubscriptionExpired)
case backupSubscriptionFailedToRenew(BackupSubscriptionFailedToRenew)
case keyTransparencySelfCheckFailed(KeyTransparencySelfCheckFailed)
case smsVerificationCodeSent(SMSVerificationCodeSent)
}
private let backupExportJobRunner: BackupExportJobRunner
@ -93,7 +98,9 @@ class ChatListFYISheetCoordinator {
// MARK: -
private func nextSheetToPresent(tx: DBReadTransaction) -> FYISheet? {
if let sheet = shouldShowBadgeThanksSheet(successMode: .oneTimeBoost, tx: tx) {
if let sheet = shouldShowSMSVerificationCodeSentSheet(tx: tx) {
return sheet
} else if let sheet = shouldShowBadgeThanksSheet(successMode: .oneTimeBoost, tx: tx) {
return sheet
} else if let sheet = shouldShowBadgeThanksSheet(successMode: .recurringSubscriptionInitiation, tx: tx) {
return sheet
@ -126,6 +133,23 @@ class ChatListFYISheetCoordinator {
}
}
/// Checks for `.smsVerificationCodeSent` FYI sheets.
///
/// When another device tries to register and receives an SMS code, notify
/// the primary device by showing an FYI sheet.
///
private func shouldShowSMSVerificationCodeSentSheet(
tx: DBReadTransaction,
) -> FYISheet? {
let safetyTipsKVStore = SafetyTipsManager()
guard let timestamp = safetyTipsKVStore.lastVerificationCodeTimestampMsWithinExpiryTime(transaction: tx) else {
return nil
}
return .smsVerificationCodeSent(FYISheet.SMSVerificationCodeSent(timestampMs: timestamp))
}
/// Checks for `.badgeThanks` FYI sheets.
///
/// When creating a new donation we show a `BadgeThankSheet` inline in the
@ -219,6 +243,8 @@ class ChatListFYISheetCoordinator {
from chatListViewController: ChatListViewController,
) async {
switch fyiSheet {
case .smsVerificationCodeSent(let smsVerificationCodeSent):
await _present(smsVerificationCodeSent: smsVerificationCodeSent, from: chatListViewController)
case .badgeThanks(let badgeThanks):
await _present(badgeThanks: badgeThanks, from: chatListViewController)
case .badgeIssue(let badgeIssue):
@ -460,6 +486,22 @@ class ChatListFYISheetCoordinator {
}
}
}
private func _present(
smsVerificationCodeSent: FYISheet.SMSVerificationCodeSent,
from chatListViewController: ChatListViewController,
) async {
let sheet = SMSVerificationCodeSentHeroSheet(
timestamp: smsVerificationCodeSent.timestampMs,
presentingFrom: chatListViewController,
)
chatListViewController.present(sheet, animated: true, completion: { [self] in
let kvStore = SafetyTipsManager()
db.write { tx in
kvStore.removeVerificationCodeRequestedTimestampMs(transaction: tx)
}
})
}
}
// MARK: - ChatListViewController: BadgeIssueSheetDelegate
@ -595,3 +637,70 @@ private final class KeyTransparencySelfCheckFailedHeroSheet: HeroSheetViewContro
)
}
}
// MARK: -
private final class SMSVerificationCodeSentHeroSheet: HeroSheetViewController {
init(timestamp: UInt64, presentingFrom fromViewController: UIViewController) {
let timestampString = DateUtil.formatMessageTimestampForCVC(timestamp, shouldUseLongFormat: true)
let bodyPartOne = OWSLocalizedString(
"VERIFICATION_CODE_REQUESTED_HERO_BODY_FIRST",
comment: "First part of body for a hero sheet informing the user a verification code was requested. {{ Embeds time the code was requested }}",
)
let bodyPartTwo = OWSLocalizedString(
"VERIFICATION_CODE_REQUESTED_HERO_BODY_SECOND",
comment: "Second part of body for a hero sheet informing the user a verification code was requested.",
)
let body: NSAttributedString = .composed(of: [
bodyPartOne.styled(
with: .font(.dynamicTypeHeadline),
.paragraphSpacingAfter(4.0),
),
"\n",
timestampString.styled(with: .font(.dynamicTypeBody)),
"\n",
bodyPartTwo.styled(
with: .font(.dynamicTypeBody),
.paragraphSpacingBefore(12.0),
),
])
super.init(
hero: .image(.verificationcodeAlert96),
title: nil,
body: HeroSheetViewController.Body(
textContent: .attributed(body),
textAlignment: .natural,
textColor: UIColor.Signal.label,
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"SAFETY_TIPS_BUTTON_ACTION_TITLE",
comment: "Title for Safety Tips button in thread details.",
),
style: .secondary,
action: .custom({ [fromViewController] sheet in
sheet.dismiss(animated: true)
let safetyTipsVC = SafetyTipsViewController(
primaryButton: SafetyTipsViewController.Button(
title: OWSLocalizedString(
"SETTINGS_ACCOUNT_BUTTON",
comment: "Label for button in Safety Tips to go to 'account' page in settings.",
),
action: {
(fromViewController as? ChatListViewController)?.showAppSettings(mode: .accountSettings)
},
),
)
fromViewController.present(safetyTipsVC, animated: true)
}),
)),
secondary: .button(.dismissing(
title: CommonStrings.okButton,
style: .secondary,
)),
)
}
}

View File

@ -151,6 +151,13 @@ extension ChatListViewController {
name: DonationReceiptCredentialRedemptionJob.didFailNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(smsVerificationCodeRequested),
name: .smsVerificationCodeRequested,
object: nil,
)
SafetyTipsManager.startObservingDarwinNotifications()
SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
@ -221,6 +228,13 @@ extension ChatListViewController {
showFYISheetIfNecessary()
}
@objc
private func smsVerificationCodeRequested(_ notification: NSNotification) {
AssertIsOnMainThread()
showFYISheetIfNecessary()
}
@objc
private func appExpiryDidChange(_ notification: NSNotification) {
AssertIsOnMainThread()

View File

@ -1378,6 +1378,7 @@ extension ChatListViewController {
case donate(donateMode: DonateViewController.DonateMode)
case linkedDevices
case proxy
case accountSettings
}
func showAppSettings(mode: ShowAppSettingsMode? = nil, completion: (() -> Void)? = nil) {
@ -1491,6 +1492,9 @@ extension ChatListViewController {
case .proxy:
viewControllers += [PrivacySettingsViewController(), AdvancedPrivacySettingsViewController(), ProxySettingsViewController()]
case .accountSettings:
viewControllers += [AccountSettingsViewController(appReadiness: appReadiness)]
}
navigationController.setViewControllers(viewControllers, animated: false)

View File

@ -12,12 +12,18 @@ public enum SafetyTipsType {
case group
}
public protocol SafetyTipsViewControllerDelegate: AnyObject {
func didTapViewMoreSafetyTips()
}
public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollViewDelegate {
public struct Button {
let title: String
let action: () -> Void
}
override public var placeOnGlassIfAvailable: Bool { true }
let primaryButton: Button
init(primaryButton: Button) {
self.primaryButton = primaryButton
}
private enum SafetyTips: CaseIterable {
case chatsFromSignal
@ -79,8 +85,6 @@ public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollV
let contentScrollView = UIScrollView()
let stackView = UIStackView()
public weak var delegate: SafetyTipsViewControllerDelegate?
override public func viewDidLoad() {
super.viewDidLoad()
@ -133,26 +137,26 @@ public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollV
var config = UIButton.Configuration.filled()
config.baseBackgroundColor = UIColor.Signal.secondaryFill
config.cornerStyle = .capsule
var attrString = AttributedString(CommonStrings.viewMoreButton)
var attrString = AttributedString(primaryButton.title)
attrString.font = .dynamicTypeBodyClamped.medium()
config.attributedTitle = attrString
config.baseForegroundColor = UIColor.Signal.label
config.contentInsets = .init(margin: 14)
let viewMoreButton = UIButton(
let button = UIButton(
configuration: config,
primaryAction: .init(handler: { [weak self] _ in
self?.dismiss(animated: true)
self?.delegate?.didTapViewMoreSafetyTips()
self?.primaryButton.action()
}),
)
viewMoreButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(viewMoreButton)
button.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(button)
NSLayoutConstraint.activate([
viewMoreButton.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor),
viewMoreButton.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 20),
viewMoreButton.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -20),
viewMoreButton.heightAnchor.constraint(equalToConstant: 52),
button.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor),
button.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 20),
button.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -20),
button.heightAnchor.constraint(equalToConstant: 52),
])
}

View File

@ -8257,6 +8257,9 @@
/* Title for the 'account' link in settings. */
"SETTINGS_ACCOUNT" = "Account";
/* Label for button in Safety Tips to go to 'account' page in settings. */
"SETTINGS_ACCOUNT_BUTTON" = "Open Account Settings";
/* Label for button in settings to get your account data report */
"SETTINGS_ACCOUNT_DATA_REPORT_BUTTON" = "Your Account Data";
@ -10144,6 +10147,12 @@
/* An error message indicating that a usernames-related requeset failed because of a network error. */
"USERNAMES_REMOTE_MUTATION_ERROR_DESCRIPTION" = "Usernames can only be updated when connected to the internet.";
/* First part of body for a hero sheet informing the user a verification code was requested. {{ Embeds time the code was requested }} */
"VERIFICATION_CODE_REQUESTED_HERO_BODY_FIRST" = "A Verification Code Was Requested";
/* Second part of body for a hero sheet informing the user a verification code was requested. */
"VERIFICATION_CODE_REQUESTED_HERO_BODY_SECOND" = "Do not give your verification code to anyone. Signal will never message you for it. If you received a message from someone pretending to be Signal, it is a scam.\nYou can safely ignore this message if you requested the code yourself.";
/* Format for info message indicating that the verification state was unverified on this device. Embeds {{user's name or phone number}}. */
"VERIFICATION_STATE_CHANGE_FORMAT_NOT_VERIFIED_LOCAL" = "You marked %@ as not verified.";

View File

@ -95,6 +95,10 @@ class NotificationService: UNNotificationServiceExtension {
}
}
private func verificationCodeRequestTimestampMs(userInfo: [AnyHashable: Any]) -> UInt64? {
return (userInfo["verificationCodeRequested"] as? [String: Any])?["timestamp"] as? UInt64
}
@MainActor
private func _didReceive(_ request: UNNotificationRequest, logger: NSELogger) async -> UNNotificationContent {
globalEnvironment.setUp(logger: logger)
@ -140,6 +144,15 @@ class NotificationService: UNNotificationServiceExtension {
globalEnvironment.setAppIsReady()
}
if let timestamp = verificationCodeRequestTimestampMs(userInfo: request.content.userInfo) {
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
let kvStore = SafetyTipsManager()
kvStore.setLastVerificationCodeRequestedTimestampMs(value: timestamp, transaction: transaction)
}
Logger.info("Skipping fetch for verification code requested push")
return UNNotificationContent()
}
// Mark down that the APNS token is working since we got a push.
// Do this as early as possible but after the app is ready and has run
// GRDB migrations and such.

View File

@ -0,0 +1,75 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
extension NSNotification.Name {
public static let smsVerificationCodeRequested = Notification.Name("SafetyTipsKeyValueStore.smsVerificationCodeRequested")
}
@MainActor
public class SafetyTipsManager {
private static var hasStartedObserving = false
static let expiryTimeSeconds = (TimeInterval.minute * 10)
private enum StoreKeys {
static let smsCodeRequestedTimestampMs: String = "smsCodeRequestedTimestampMsKey"
}
private let kvStore: NewKeyValueStore
public init() {
self.kvStore = NewKeyValueStore(collection: "SafetyTips")
}
public func setLastVerificationCodeRequestedTimestampMs(
value: UInt64,
transaction: DBWriteTransaction,
) {
kvStore.writeValue(
value,
forKey: StoreKeys.smsCodeRequestedTimestampMs,
tx: transaction,
)
transaction.addSyncCompletion {
// Wake up observer in the main app to check KV store
DarwinNotificationCenter.postNotification(name: .smsVerificationCodeRequested)
}
}
public func lastVerificationCodeTimestampMsWithinExpiryTime(
transaction: DBReadTransaction,
) -> UInt64? {
guard
let timestamp = kvStore.fetchValue(
UInt64.self,
forKey: StoreKeys.smsCodeRequestedTimestampMs,
tx: transaction,
)
else {
return nil
}
let timestampDate = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let seconds = Date().timeIntervalSince(timestampDate)
guard seconds <= Self.expiryTimeSeconds else {
return nil
}
return timestamp
}
public func removeVerificationCodeRequestedTimestampMs(
transaction: DBWriteTransaction,
) {
kvStore.removeValue(forKey: StoreKeys.smsCodeRequestedTimestampMs, tx: transaction)
}
public static func startObservingDarwinNotifications() {
guard !hasStartedObserving else { return }
hasStartedObserving = true
_ = DarwinNotificationCenter.addObserver(name: .smsVerificationCodeRequested, queue: .main) { _ in
NotificationCenter.default.post(name: .smsVerificationCodeRequested, object: nil)
}
}
}

View File

@ -7,6 +7,7 @@ import Foundation
public struct DarwinNotificationName: ExpressibleByStringLiteral {
static let primaryDBFolderNameDidChange: DarwinNotificationName = "org.signal.primaryDBFolderNameDidChange"
static let smsVerificationCodeRequested: DarwinNotificationName = "org.signal.safetyTips.smsVerificationCodeRequested"
static func sdsCrossProcess(for type: AppContextType) -> DarwinNotificationName {
DarwinNotificationName("org.signal.sdscrossprocess.\(type)")

View File

@ -22,6 +22,11 @@ open class HeroSheetViewController: StackSheetViewController {
}
public struct Body {
public enum TextContent {
case plain(String)
case attributed(NSAttributedString)
}
public struct BulletPoint {
public let icon: UIImage
public let text: String
@ -48,20 +53,20 @@ open class HeroSheetViewController: StackSheetViewController {
}
}
public let text: String
public let textContent: TextContent
public let textAlignment: NSTextAlignment
public let textColor: UIColor
public let bulletPoints: [BulletPoint]
public let toggle: Toggle?
public init(
text: String,
textContent: TextContent,
textAlignment: NSTextAlignment = .center,
textColor: UIColor = .Signal.secondaryLabel,
bulletPoints: [BulletPoint] = [],
toggle: Toggle? = nil,
) {
self.text = text
self.textContent = textContent
self.textAlignment = textAlignment
self.textColor = textColor
self.bulletPoints = bulletPoints
@ -137,7 +142,7 @@ open class HeroSheetViewController: StackSheetViewController {
) {
self.hero = hero
self.titleText = title
self.body = Body(text: body)
self.body = Body(textContent: .plain(body))
self.primary = primaryButton.map { .button($0) }
self.secondary = secondaryButton.map { .button($0) }
super.init()
@ -193,10 +198,16 @@ open class HeroSheetViewController: StackSheetViewController {
let bodyLabel = UILabel()
self.stackView.addArrangedSubview(bodyLabel)
self.stackView.setCustomSpacing(32, after: bodyLabel)
bodyLabel.text = body.text
switch body.textContent {
case .plain(let text):
bodyLabel.text = text
bodyLabel.font = .dynamicTypeSubheadline
case .attributed(let attributedText):
// attributed strings should set their own font.
bodyLabel.attributedText = attributedText
}
bodyLabel.textColor = body.textColor
bodyLabel.textAlignment = body.textAlignment
bodyLabel.font = .dynamicTypeSubheadline
bodyLabel.numberOfLines = 0
for bodyBullet in body.bulletPoints {
@ -378,7 +389,7 @@ open class HeroSheetViewController: StackSheetViewController {
hero: .image(UIImage(named: "sustainer-heart")!),
title: nil,
body: HeroSheetViewController.Body(
text: "As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal.",
textContent: .plain("As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal."),
textAlignment: .left,
textColor: .Signal.label,
bulletPoints: [
@ -407,7 +418,7 @@ open class HeroSheetViewController: StackSheetViewController {
hero: .image(UIImage(named: "toggle-32")!),
title: nil,
body: HeroSheetViewController.Body(
text: #"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#,
textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#),
toggle: HeroSheetViewController.Body.Toggle(
text: "Extra Food?",
isOn: true,
@ -452,7 +463,7 @@ open class HeroSheetViewController: StackSheetViewController {
SheetPreviewViewController(sheet: HeroSheetViewController(
hero: .image(UIImage(named: "transfer_complete")!),
title: LocalizationNotNeeded("Continue on your other device"),
body: HeroSheetViewController.Body(text: LocalizationNotNeeded("Continue transferring your account on your other device.")),
body: HeroSheetViewController.Body(textContent: .plain(LocalizationNotNeeded("Continue transferring your account on your other device."))),
primary: .hero(.animation(named: "circular_indeterminate", height: 60)),
secondary: nil,
))