From 218b7ffdbc48eafe734f83b67894b7dc220b9815 Mon Sep 17 00:00:00 2001 From: kate-signal Date: Tue, 12 May 2026 13:18:53 -0400 Subject: [PATCH] basic support for release notes chat --- Signal.xcodeproj/project.pbxproj | 4 + .../Components/CVComponentState.swift | 1 + .../Components/CVComponentThreadDetails.swift | 88 ++++++++++++++++++ .../ConversationHeaderView.swift | 7 +- ...ConversationViewController+BottomBar.swift | 11 +++ .../ConversationViewModel.swift | 4 +- .../Contents.json | 12 +++ .../signal-logo-release-notes.pdf | Bin 0 -> 5194 bytes .../ViewControllers/DebugUI/DebugUIMisc.swift | 5 + .../HomeView/Chat List/ChatListCell.swift | 6 +- .../translations/en.lproj/Localizable.strings | 12 +++ SignalServiceKit/Avatars/AvatarBuilder.swift | 47 ++++++++++ .../Chat/BackupArchiveChatArchiver.swift | 3 + .../Contacts/OWSContactsManager.swift | 5 + SignalServiceKit/Contacts/TSThread.swift | 1 + .../Messages/BlockingManager.swift | 2 + .../Storage/Database/SDSRecordType.swift | 1 + .../Threads/TSReleaseNotesThread.swift | 49 ++++++++++ SignalServiceKit/Threads/TSThread+OWS.swift | 4 + SignalUI/Appearance/UIColor+Signal.swift | 14 +++ 20 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 Signal/Images.xcassets/signal-logo-release-notes.imageset/Contents.json create mode 100644 Signal/Images.xcassets/signal-logo-release-notes.imageset/signal-logo-release-notes.pdf create mode 100644 SignalServiceKit/Threads/TSReleaseNotesThread.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 1e1ca38632..3adf2dee7a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ 045B40922ECE406A002D3F9A /* ConversationViewController+PinnedMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */; }; 045B40952ECF98C1002D3F9A /* PinnedMessagesDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.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 */; }; 0480F0002E57C51A006CBB29 /* BackupsEnabledNotificationMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */; }; 0484CECE2F44B7BE009AB2CB /* AdminDeleteRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */; }; @@ -4217,6 +4218,7 @@ 045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+PinnedMessages.swift"; sourceTree = ""; }; 045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessagesDetailsViewController.swift; sourceTree = ""; }; 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Polls.swift"; sourceTree = ""; }; + 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = ""; }; 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = ""; }; 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = ""; }; 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = ""; }; @@ -15165,6 +15167,7 @@ F9C5C9EF289453B100548EEE /* TSGroupThread+OWS.swift */, 880FB40528CD205F00FA1C10 /* TSGroupThread.swift */, F9C5C9E5289453B100548EEE /* TSPrivateStoryThread.swift */, + 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */, F9C5C9E9289453B100548EEE /* TSThread+OWS.swift */, ); path = Threads; @@ -20052,6 +20055,7 @@ F9C5CCCB289453B300548EEE /* TSPrivateStoryThread.swift in Sources */, F9C5CBE4289453B300548EEE /* TSQuotedMessage.m in Sources */, 6615553F2ABA5A7500AA302B /* TSRegistrationState.swift in Sources */, + 0477BE322FA4FC41002F9B47 /* TSReleaseNotesThread.swift in Sources */, 66C2B1312A05D28A008DDE72 /* TSRequest.swift in Sources */, 6691E7EF2996E8FB0032A68A /* TSRequestOWSURLSessionMock.swift in Sources */, F9C5CCCF289453B300548EEE /* TSThread+OWS.swift in Sources */, diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index a8912f8b15..7e76cac8c7 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -494,6 +494,7 @@ public struct CVComponentState: Equatable { let mutualGroupsText: NSAttributedString? let threadType: SafetyTipsType let shouldShowSafetyTipsButton: Bool + let isOfficialChat: Bool } let avatarDataSource: ConversationAvatarDataSource? diff --git a/Signal/ConversationView/Components/CVComponentThreadDetails.swift b/Signal/ConversationView/Components/CVComponentThreadDetails.swift index b2b25183f2..967848a384 100644 --- a/Signal/ConversationView/Components/CVComponentThreadDetails.swift +++ b/Signal/ConversationView/Components/CVComponentThreadDetails.swift @@ -195,6 +195,16 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { componentDelegate.didTapNameEducation(type: safetySection.threadType) } innerViews.append(nameNotVerifiedButton) + } else if safetySection.isOfficialChat { + innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel)) + + let officialLabel = componentView.officialLabel + let officialLabelConfig = officialLabelConfig() + officialLabelConfig.applyForRendering(label: officialLabel) + officialLabel.backgroundColor = UIColor.Signal.officialLabelBackground + officialLabel.layer.cornerRadius = 14 + officialLabel.layer.masksToBounds = true + innerViews.append(officialLabel) } } @@ -405,6 +415,29 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { ) } + private func officialLabelConfig() -> CVLabelConfig { + let symbol = SignalSymbol.checkCircle.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize) + let notVerifiedString = NSAttributedString.composed( + of: [ + symbol, + SignalSymbol.LeadingCharacter.space.rawValue, + OWSLocalizedString("RELEASE_NOTES_CHANNEL_OFFICIAL_LABEL", comment: "Label displayed in thread details of the release notes chat"), + ], + ) + return CVLabelConfig( + text: .attributedText(notVerifiedString), + displayConfig: .forUnstyledText( + font: .dynamicTypeCallout.medium(), + textColor: UIColor.Signal.officialLabel, + ), + font: .dynamicTypeCallout.medium(), + textColor: UIColor.Signal.officialLabel, + numberOfLines: 0, + lineBreakMode: .byWordWrapping, + textAlignment: .center, + ) + } + private var safetyTipsButtonLabelConfig: CVLabelConfig { CVLabelConfig.unstyledText( OWSLocalizedString( @@ -449,6 +482,11 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { transaction: transaction, avatarBuilder: avatarBuilder, ) + } else if let releaseNotesThread = thread as? TSReleaseNotesThread { + return buildComponentState( + releaseNotesThread: releaseNotesThread, + transaction: transaction, + ) } else { owsFailDebug("Invalid thread.") return CVComponentState.ThreadDetails( @@ -564,6 +602,30 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { ) } + private static func buildComponentState( + releaseNotesThread: TSReleaseNotesThread, + transaction: DBReadTransaction, + ) -> CVComponentState.ThreadDetails { + + let titleText = OWSLocalizedString( + "RELEASE_NOTES_CHANNEL_NAME", + comment: "Display name for the release notes channel", + ) + + let safetySection = Self.buildReleaseNotesSafetySection(from: releaseNotesThread, tx: transaction) + + return CVComponentState.ThreadDetails( + avatarDataSource: .asset(avatar: AvatarBuilder.releaseNotesIcon(), badge: nil), + isAvatarBlurred: false, + isAvatarBeingDownloaded: false, + titleText: titleText, + shouldShowVerifiedBadge: true, + shouldShowContactIcon: false, + safetySection: safetySection, + groupDescriptionText: nil, + ) + } + private let vSpacingTitle: CGFloat = 8 private let vSpacingNotVerifiedLabel: CGFloat = 6 private let vSpacingSafetyButton: CGFloat = 16 @@ -622,6 +684,14 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { ) let notVerifiedSizeWithPadding = CGSize(width: notVerifiedSize.width + hPaddingNotVerifiedButton * 2, height: notVerifiedSize.height + vPaddingNotVerifiedButton * 2) innerSubviewInfos.append(notVerifiedSizeWithPadding.asManualSubviewInfo) + } else if safetySection.isOfficialChat { + innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo) + let officialLabelSize = CVText.measureLabel( + config: officialLabelConfig(), + maxWidth: maxContentWidth, + ) + let officialLabelSizeWithPadding = CGSize(width: officialLabelSize.width + hPaddingNotVerifiedButton * 2, height: officialLabelSize.height + vPaddingNotVerifiedButton * 2) + innerSubviewInfos.append(officialLabelSizeWithPadding.asManualSubviewInfo) } } @@ -759,6 +829,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { fileprivate let bioLabel = CVLabel() fileprivate let profileNamesEducationButton = OWSRoundedButton() + fileprivate let officialLabel = CVLabel() fileprivate let reviewCarefullyLabel = CVLabel() fileprivate let detailsButton = CVButton() @@ -817,6 +888,20 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent { } extension CVComponentThreadDetails { + private static func buildReleaseNotesSafetySection( + from releaseNotesThread: TSReleaseNotesThread, + tx: DBReadTransaction, + ) -> CVComponentState.ThreadDetails.SafetySection { + return .init( + shouldShowProfileNamesEducation: false, + detailsText: NSAttributedString(string: OWSLocalizedString("RELEASE_NOTES_DETAILS", comment: "Details text for the thread details view of the release notes channel")), + mutualGroupsText: nil, + threadType: .contact, + shouldShowSafetyTipsButton: false, + isOfficialChat: true, + ) + } + private static func buildGroupsSafetySection( from groupThread: TSGroupThread, threadAssociatedData: ThreadAssociatedData, @@ -945,6 +1030,7 @@ extension CVComponentThreadDetails { mutualGroupsText: nil, threadType: .group, shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx), + isOfficialChat: false, ) } @@ -976,6 +1062,7 @@ extension CVComponentThreadDetails { ), threadType: .contact, shouldShowSafetyTipsButton: false, + isOfficialChat: false, ) } @@ -1079,6 +1166,7 @@ extension CVComponentThreadDetails { ]), threadType: .contact, shouldShowSafetyTipsButton: isMessageRequest, + isOfficialChat: false, ) } } diff --git a/Signal/ConversationView/ConversationHeaderView.swift b/Signal/ConversationView/ConversationHeaderView.swift index 36176cde04..e57d0d6cf6 100644 --- a/Signal/ConversationView/ConversationHeaderView.swift +++ b/Signal/ConversationView/ConversationHeaderView.swift @@ -151,7 +151,12 @@ class ConversationHeaderView: UIView { func configure(threadViewModel: ThreadViewModel) { avatarView.updateWithSneakyTransactionIfNecessary { config in - config.dataSource = .thread(threadViewModel.threadRecord) + if threadViewModel.threadRecord.isReleaseNotesThread { + config.dataSource = .asset(avatar: AvatarBuilder.releaseNotesIcon(), badge: nil) + } else { + config.dataSource = .thread(threadViewModel.threadRecord) + } + config.storyConfiguration = .autoUpdate() config.applyConfigurationSynchronously() } diff --git a/Signal/ConversationView/ConversationViewController+BottomBar.swift b/Signal/ConversationView/ConversationViewController+BottomBar.swift index bd26fb5bb0..7cb20d41e3 100644 --- a/Signal/ConversationView/ConversationViewController+BottomBar.swift +++ b/Signal/ConversationView/ConversationViewController+BottomBar.swift @@ -22,6 +22,7 @@ enum CVCBottomViewType: Equatable { case notRegistered case notLinked case groupEnded + case releaseNotes } protocol ConversationBottomBar: UIView { @@ -78,6 +79,9 @@ public extension ConversationViewController { case .normal: break } + if thread.isReleaseNotesThread { + return .releaseNotes + } if appExpiry.isExpired(now: Date()) { return .appExpired } @@ -207,6 +211,13 @@ public extension ConversationViewController { ) requestView = groupEndedView bottomView = groupEndedView + case .releaseNotes: + let releaseNotesView = BlockingErrorBottomPanelView( + text: NSAttributedString(string: OWSLocalizedString("RELEASE_NOTES_BOTTOM_BAR_LABEL", comment: "Bottom bar label for the release notes thread")), + onTap: {}, + ) + requestView = releaseNotesView + bottomView = releaseNotesView } bottomBarContainer.removeAllSubviews() diff --git a/Signal/ConversationView/ConversationViewModel.swift b/Signal/ConversationView/ConversationViewModel.swift index 072e1bf4e3..a99ab933f2 100644 --- a/Signal/ConversationView/ConversationViewModel.swift +++ b/Signal/ConversationView/ConversationViewModel.swift @@ -54,10 +54,10 @@ class ConversationViewModel { return false } return !identityManager.groupContainsUnverifiedMember(groupThread.uniqueId, tx: tx) - case let contactThread as TSContactThread: return identityManager.verificationState(for: contactThread.contactAddress, tx: tx) == .verified - + case is TSReleaseNotesThread: + return false default: owsFailDebug("Showing conversation for unexpected thread type.") return false diff --git a/Signal/Images.xcassets/signal-logo-release-notes.imageset/Contents.json b/Signal/Images.xcassets/signal-logo-release-notes.imageset/Contents.json new file mode 100644 index 0000000000..e1b803c6c0 --- /dev/null +++ b/Signal/Images.xcassets/signal-logo-release-notes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "signal-logo-release-notes.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/signal-logo-release-notes.imageset/signal-logo-release-notes.pdf b/Signal/Images.xcassets/signal-logo-release-notes.imageset/signal-logo-release-notes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..16b55255c73a64e840f5f563219c40b3e1e2be05 GIT binary patch literal 5194 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 jfFsL0!w7d$3ZE;j!Id_t&V literal 0 HcmV?d00001 diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift index 4d740f0288..a5e6ff7559 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift @@ -39,6 +39,11 @@ class DebugUIMisc: DebugUIPage { let viewController = LineWrappingStackViewTestController() UIApplication.shared.frontmostViewController!.present(viewController, animated: true) }), + OWSTableItem(title: "Create Release Notes Thread", actionBlock: { + let _ = SSKEnvironment.shared.databaseStorageRef.write { tx in + TSReleaseNotesThread.createReleaseNotes(transaction: tx) + } + }), ] return OWSTableSection(title: name, items: items) } diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListCell.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListCell.swift index 1c555deef0..6f69cc0c3d 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListCell.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListCell.swift @@ -360,7 +360,11 @@ class ChatListCell: UITableViewCell, ReusableTableViewCell { owsAssertDebug(avatarView == nil, "ChatListCell.configure without prior reset called") avatarView = ConversationAvatarView(sizeClass: .fiftySix, localUserDisplayMode: .noteToSelf, useAutolayout: true) avatarView?.updateWithSneakyTransactionIfNecessary({ config in - config.dataSource = .thread(configuration.thread) + if configuration.thread.isReleaseNotesThread { + config.dataSource = .asset(avatar: AvatarBuilder.releaseNotesIcon(), badge: nil) + } else { + config.dataSource = .thread(configuration.thread) + } if asyncAvatarLoadingAllowed, cellContentToken.shouldLoadAvatarAsync { config.usePlaceholderImages() } else { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 86e1a8676d..36d1374f1f 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -7738,6 +7738,18 @@ /* Error message when sending a verification code via voice call failed, but resending via sms might succeed. */ "REGISTRATION_VOICE_CODE_FAILED_TRY_SMS_ERROR" = "We couldn't send you a verification code via voice call. Try receiving your code via sms instead."; +/* Bottom bar label for the release notes thread */ +"RELEASE_NOTES_BOTTOM_BAR_LABEL" = "The only official chat from Signal"; + +/* Display name for the release notes channel */ +"RELEASE_NOTES_CHANNEL_NAME" = "Signal"; + +/* Label displayed in thread details of the release notes chat */ +"RELEASE_NOTES_CHANNEL_OFFICIAL_LABEL" = "Official Chat"; + +/* Details text for the thread details view of the release notes channel */ +"RELEASE_NOTES_DETAILS" = "The only official chat from Signal. Keep up to date with news and release notes"; + /* Button below the warning to fix a corrupted username. */ "REMINDER_VIEW_USERNAME_CORRUPTED_FIX_BUTTON" = "Fix now"; diff --git a/SignalServiceKit/Avatars/AvatarBuilder.swift b/SignalServiceKit/Avatars/AvatarBuilder.swift index 42e1d81c62..c1ea51be41 100644 --- a/SignalServiceKit/Avatars/AvatarBuilder.swift +++ b/SignalServiceKit/Avatars/AvatarBuilder.swift @@ -593,6 +593,53 @@ public class AvatarBuilder { return formattedAbbreviation } + public static func releaseNotesIcon() -> UIImage? { + let iconSize = CGSize(square: 74.0) + let embeddedImageSize = CGSize(square: 46.0) + + let image = UIImage(named: "signal-logo-release-notes")!.withTintColor(.white) + + let renderer = UIGraphicsImageRenderer(size: iconSize) + let finalImage = renderer.image { context in + let rect = CGRect(origin: .zero, size: iconSize) + let circlePath = UIBezierPath(ovalIn: rect) + + context.cgContext.addPath(circlePath.cgPath) + context.cgContext.clip() + + let colors = [ + UIColor(red: 0.23, green: 0.27, blue: 0.99, alpha: 1).cgColor, + UIColor(red: 0.12, green: 0.16, blue: 0.99, alpha: 1).cgColor, + + ] as CFArray + + let locations: [CGFloat] = [0.0, 1.0] + + let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: colors, + locations: locations, + )! + + context.cgContext.drawLinearGradient( + gradient, + start: CGPoint(x: rect.midX, y: rect.minY), + end: CGPoint(x: rect.midX, y: rect.maxY), + options: [], + ) + + let centerOffset = iconSize.width / 2 - embeddedImageSize.width / 2 + let imageRect = CGRect( + x: centerOffset, + y: centerOffset, + width: embeddedImageSize.width, + height: embeddedImageSize.height, + ) + image.withRenderingMode(.alwaysTemplate).draw(in: imageRect) + } + return finalImage + } + // MARK: - Content private enum AvatarContentType: Equatable { diff --git a/SignalServiceKit/Backups/Archiving/Archivers/Chat/BackupArchiveChatArchiver.swift b/SignalServiceKit/Backups/Archiving/Archivers/Chat/BackupArchiveChatArchiver.swift index 91aa41253f..58a64c5ae1 100644 --- a/SignalServiceKit/Backups/Archiving/Archivers/Chat/BackupArchiveChatArchiver.swift +++ b/SignalServiceKit/Backups/Archiving/Archivers/Chat/BackupArchiveChatArchiver.swift @@ -83,6 +83,9 @@ public class BackupArchiveChatArchiver: BackupArchiveProtoStreamWriter { context.gv1ThreadIds.insert(thread.uniqueThreadIdentifier) // Skip gv1 threads; count as success. result = .success + } else if thread.isReleaseNotesThread { + // TODO: [KC] implement release notes in backups + result = .success } else { result = .completeFailure(.fatalArchiveError(.unrecognizedThreadType)) } diff --git a/SignalServiceKit/Contacts/OWSContactsManager.swift b/SignalServiceKit/Contacts/OWSContactsManager.swift index 76984c5665..441084d025 100644 --- a/SignalServiceKit/Contacts/OWSContactsManager.swift +++ b/SignalServiceKit/Contacts/OWSContactsManager.swift @@ -1385,6 +1385,8 @@ extension ContactManager { return .contactThread(displayName(for: thread.contactAddress, tx: tx)) case let thread as TSGroupThread: return .groupThread(thread.groupNameOrDefault) + case _ as TSReleaseNotesThread: + return .releaseNotes default: owsFailDebug("Unexpected thread type: \(type(of: thread))") return nil @@ -1421,6 +1423,7 @@ public enum ThreadDisplayName { case noteToSelf case contactThread(DisplayName) case groupThread(String) + case releaseNotes public func resolvedValue() -> String { switch self { @@ -1430,6 +1433,8 @@ public enum ThreadDisplayName { return displayName.resolvedValue() case .groupThread(let groupName): return groupName + case .releaseNotes: + return OWSLocalizedString("RELEASE_NOTES_CHANNEL_NAME", comment: "Display name for the release notes channel") } } } diff --git a/SignalServiceKit/Contacts/TSThread.swift b/SignalServiceKit/Contacts/TSThread.swift index a6ba466680..fa31ec5838 100644 --- a/SignalServiceKit/Contacts/TSThread.swift +++ b/SignalServiceKit/Contacts/TSThread.swift @@ -30,6 +30,7 @@ open class TSThread: NSObject, SDSCodableModel, InheritableRecord { case SDSRecordType.contactThread.rawValue: TSContactThread.self case SDSRecordType.groupThread.rawValue: TSGroupThread.self case SDSRecordType.privateStoryThread.rawValue: TSPrivateStoryThread.self + case SDSRecordType.releaseNotesThread.rawValue: TSReleaseNotesThread.self default: nil } } diff --git a/SignalServiceKit/Messages/BlockingManager.swift b/SignalServiceKit/Messages/BlockingManager.swift index 1c37122b76..68a4b20600 100644 --- a/SignalServiceKit/Messages/BlockingManager.swift +++ b/SignalServiceKit/Messages/BlockingManager.swift @@ -315,6 +315,8 @@ public class BlockingManager { return _isGroupIdBlocked(groupThread.groupModel.groupId, tx: transaction) } else if thread is TSPrivateStoryThread { return false + } else if thread.isReleaseNotesThread { + return false } else { owsFailDebug("Invalid thread: \(type(of: thread))") return false diff --git a/SignalServiceKit/Storage/Database/SDSRecordType.swift b/SignalServiceKit/Storage/Database/SDSRecordType.swift index 321b9f811c..bb6f107cb7 100644 --- a/SignalServiceKit/Storage/Database/SDSRecordType.swift +++ b/SignalServiceKit/Storage/Database/SDSRecordType.swift @@ -84,4 +84,5 @@ public enum SDSRecordType: UInt, CaseIterable { case paymentActivationRequestFinishedMessage = 77 case incomingArchivedPaymentMessage = 78 case outgoingArchivedPaymentMessage = 79 + case releaseNotesThread = 80 } diff --git a/SignalServiceKit/Threads/TSReleaseNotesThread.swift b/SignalServiceKit/Threads/TSReleaseNotesThread.swift new file mode 100644 index 0000000000..a6241b7efd --- /dev/null +++ b/SignalServiceKit/Threads/TSReleaseNotesThread.swift @@ -0,0 +1,49 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation + +/// Represents the Release Notes thread. +public final class TSReleaseNotesThread: TSThread { + override public class var recordType: SDSRecordType { .releaseNotesThread } + + @objc + public class var releaseNotesUniqueId: String { + "00000000-0000-5000-8000-00000000000A" + } + + public class func createReleaseNotes(transaction: DBWriteTransaction) -> TSReleaseNotesThread { + let releaseNotes = TSReleaseNotesThread(uniqueId: releaseNotesUniqueId) + releaseNotes.shouldThreadBeVisible = true + releaseNotes.anyInsert(transaction: transaction) + return releaseNotes + } + + override func deepCopy() -> TSThread { + return TSReleaseNotesThread( + id: self.id, + uniqueId: self.uniqueId, + creationDate: self.creationDate, + editTargetTimestamp: self.editTargetTimestamp, + isArchivedObsolete: self.isArchivedObsolete, + isMarkedUnreadObsolete: self.isMarkedUnreadObsolete, + lastDraftInteractionRowId: self.lastDraftInteractionRowId, + lastDraftUpdateTimestamp: self.lastDraftUpdateTimestamp, + lastInteractionRowId: self.lastInteractionRowId, + lastSentStoryTimestamp: self.lastSentStoryTimestamp, + mentionNotificationMode: self.mentionNotificationMode, + messageDraft: self.messageDraft, + messageDraftBodyRanges: self.messageDraftBodyRanges, + mutedUntilTimestampObsolete: self.mutedUntilTimestampObsolete, + shouldThreadBeVisible: self.shouldThreadBeVisible, + storyViewMode: self.storyViewMode, + ) + } + + @objc + override public func recipientAddresses(with tx: DBReadTransaction) -> [SignalServiceAddress] { + return [] + } +} diff --git a/SignalServiceKit/Threads/TSThread+OWS.swift b/SignalServiceKit/Threads/TSThread+OWS.swift index 4d4f3a8272..ce23211662 100644 --- a/SignalServiceKit/Threads/TSThread+OWS.swift +++ b/SignalServiceKit/Threads/TSThread+OWS.swift @@ -34,6 +34,10 @@ public extension TSThread { return groupThread.groupModel.groupsVersion == .V2 } + var isReleaseNotesThread: Bool { + self is TSReleaseNotesThread + } + var groupModelIfGroupThread: TSGroupModel? { guard let groupThread = self as? TSGroupThread else { return nil diff --git a/SignalUI/Appearance/UIColor+Signal.swift b/SignalUI/Appearance/UIColor+Signal.swift index d3b3a29ddf..3296be8f31 100644 --- a/SignalUI/Appearance/UIColor+Signal.swift +++ b/SignalUI/Appearance/UIColor+Signal.swift @@ -178,6 +178,20 @@ extension UIColor.Signal { ) } + public static var officialLabel: UIColor { + UIColor( + light: UIColor(rgbHex: 0x2934FD), + dark: UIColor(rgbHex: 0xC5C7F5), + ) + } + + public static var officialLabelBackground: UIColor { + UIColor( + light: UIColor(rgbHex: 0x2934FD).withAlphaComponent(0.12), + dark: UIColor(rgbHex: 0x424585), + ) + } + // MARK: Background public static var background: UIColor {