basic support for release notes chat

This commit is contained in:
kate-signal 2026-05-12 13:18:53 -04:00 committed by GitHub
parent c75e957b68
commit 218b7ffdbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 272 additions and 4 deletions

View File

@ -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 = "<group>"; };
045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessagesDetailsViewController.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>"; };
0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = "<group>"; };
0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -494,6 +494,7 @@ public struct CVComponentState: Equatable {
let mutualGroupsText: NSAttributedString?
let threadType: SafetyTipsType
let shouldShowSafetyTipsButton: Bool
let isOfficialChat: Bool
}
let avatarDataSource: ConversationAvatarDataSource?

View File

@ -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,
)
}
}

View File

@ -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()
}

View File

@ -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()

View File

@ -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

View File

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

View File

@ -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)
}

View File

@ -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 {

View File

@ -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";

View File

@ -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 {

View File

@ -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))
}

View File

@ -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")
}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -84,4 +84,5 @@ public enum SDSRecordType: UInt, CaseIterable {
case paymentActivationRequestFinishedMessage = 77
case incomingArchivedPaymentMessage = 78
case outgoingArchivedPaymentMessage = 79
case releaseNotesThread = 80
}

View File

@ -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 []
}
}

View File

@ -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

View File

@ -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 {