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 0000000000..16b55255c7 Binary files /dev/null and b/Signal/Images.xcassets/signal-logo-release-notes.imageset/signal-logo-release-notes.pdf differ 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 {