iOS звонки в foreground с full E2EE и паритетом call-attachment

This commit is contained in:
2026-03-28 23:40:39 +05:00
parent e49d224e6a
commit 16191ef197
30 changed files with 2719 additions and 44 deletions

View File

@@ -40,6 +40,7 @@ struct ChatDetailView: View {
@State private var showNoAvatarAlert = false
@State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false
@State private var callErrorMessage: String?
@State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage?
@@ -307,6 +308,20 @@ struct ChatDetailView: View {
} message: {
Text("Set a profile photo in Settings to share it with contacts.")
}
.alert("Call Error", isPresented: Binding(
get: { callErrorMessage != nil },
set: { isPresented in
if !isPresented {
callErrorMessage = nil
}
}
)) {
Button("OK", role: .cancel) {
callErrorMessage = nil
}
} message: {
Text(callErrorMessage ?? "Failed to start call.")
}
.sheet(isPresented: $showAttachmentPanel) {
AttachmentPanelView(
onSend: { attachments, caption in
@@ -476,11 +491,28 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
HStack(spacing: 8) {
if canStartCall {
Button { startVoiceCall() } label: {
Image(systemName: "phone.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Start Call")
}
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
}
.buttonStyle(.plain)
}
@@ -507,11 +539,28 @@ private extension ChatDetailView {
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
HStack(spacing: 8) {
if canStartCall {
Button { startVoiceCall() } label: {
Image(systemName: "phone.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Start Call")
}
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
}
.buttonStyle(.plain)
}
@@ -541,6 +590,35 @@ private extension ChatDetailView {
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
}
private var canStartCall: Bool {
!route.isSavedMessages
&& !route.isSystemAccount
&& !DatabaseManager.isGroupDialogKey(route.publicKey)
}
private func startVoiceCall() {
let peerTitle = dialog?.opponentTitle ?? route.title
let peerUsername = dialog?.opponentUsername ?? route.username
let result = CallManager.shared.startOutgoingCall(
toPublicKey: route.publicKey,
title: peerTitle,
username: peerUsername
)
switch result {
case .started:
break
case .alreadyInCall:
callErrorMessage = "You are already in another call."
case .accountNotBound:
callErrorMessage = "Account is not ready for calls yet."
case .invalidTarget:
callErrorMessage = "Unable to start call for this chat."
case .notIncoming:
callErrorMessage = "Call state is invalid."
}
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
}

View File

@@ -5,6 +5,22 @@ enum MediaBubbleCornerMaskFactory {
private static let mergedRadius: CGFloat = 8
private static let inset: CGFloat = 2
/// Full bubble mask INCLUDING tail shape used for photo-only messages
/// where the photo fills the entire bubble area.
static func fullBubbleMask(
bounds: CGRect,
mergeType: BubbleMergeType,
outgoing: Bool
) -> CAShapeLayer {
let path = BubbleGeometryEngine.makeCGPath(
in: bounds, mergeType: mergeType, outgoing: outgoing
)
let mask = CAShapeLayer()
mask.frame = bounds
mask.path = path
return mask
}
static func containerMask(
bounds: CGRect,
mergeType: BubbleMergeType,

View File

@@ -66,6 +66,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Timestamp + delivery
private let statusBackgroundView = UIView()
private let statusGradientLayer = CAGradientLayer()
private let timestampLabel = UILabel()
private let checkSentView = UIImageView()
private let checkReadView = UIImageView()
@@ -93,6 +94,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// File
private let fileContainer = UIView()
private let fileIconView = UIView()
private let fileIconSymbolView = UIImageView()
private let fileNameLabel = UILabel()
private let fileSizeLabel = UILabel()
@@ -158,10 +160,18 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(textLabel)
// Timestamp
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
statusBackgroundView.backgroundColor = .clear
statusBackgroundView.layer.cornerRadius = 7
statusBackgroundView.layer.cornerCurve = .continuous
statusBackgroundView.clipsToBounds = true
statusBackgroundView.isHidden = true
statusGradientLayer.colors = [
UIColor.black.withAlphaComponent(0.0).cgColor,
UIColor.black.withAlphaComponent(0.5).cgColor
]
statusGradientLayer.startPoint = CGPoint(x: 0, y: 0)
statusGradientLayer.endPoint = CGPoint(x: 1, y: 1)
statusBackgroundView.layer.insertSublayer(statusGradientLayer, at: 0)
bubbleView.addSubview(statusBackgroundView)
timestampLabel.font = Self.timestampFont
@@ -254,6 +264,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconView.layer.cornerRadius = 20
fileIconSymbolView.tintColor = .white
fileIconSymbolView.contentMode = .scaleAspectFit
fileIconView.addSubview(fileIconSymbolView)
fileContainer.addSubview(fileIconView)
fileNameLabel.font = Self.fileNameFont
fileNameLabel.textColor = .white
@@ -407,13 +420,44 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// File
if let layout = currentLayout, layout.hasFile {
fileContainer.isHidden = false
let fileAtt = message.attachments.first { $0.type == .file }
if let fileAtt {
if let callAtt = message.attachments.first(where: { $0.type == .call }) {
let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview)
let isOutgoing = currentLayout?.isOutgoing ?? false
let isError = durationSec == 0
if isError {
fileIconView.backgroundColor = UIColor.systemRed.withAlphaComponent(0.85)
fileIconSymbolView.image = UIImage(systemName: "xmark")
fileNameLabel.text = isOutgoing ? "Rejected call" : "Missed call"
fileSizeLabel.text = "Call was not answered or was rejected"
fileSizeLabel.textColor = UIColor.systemRed.withAlphaComponent(0.95)
} else {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(
systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill"
)
fileNameLabel.text = isOutgoing ? "Outgoing call" : "Incoming call"
fileSizeLabel.text = Self.formattedDuration(seconds: durationSec)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
}
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName
fileSizeLabel.text = ""
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
} else if message.attachments.first(where: { $0.type == .avatar }) != nil {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill")
fileNameLabel.text = "Avatar"
fileSizeLabel.text = ""
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
} else {
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
fileNameLabel.text = "File"
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
}
fileSizeLabel.text = ""
} else {
fileContainer.isHidden = true
}
@@ -462,7 +506,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
width: layout.bubbleSize.width + tailProtrusion,
height: layout.bubbleSize.height)
}
bubbleImageView.frame = imageFrame
// Telegram extends bubble image by 1pt on each side (ChatMessageBackground.swift line 115:
// `let imageFrame = CGRect(...).insetBy(dx: -1.0, dy: -1.0)`).
// This makes adjacent bubbles overlap by 2pt vertically, reducing perceived gap.
bubbleImageView.frame = imageFrame.insetBy(dx: -1, dy: -1)
bubbleImageView.image = Self.bubbleImages.image(
outgoing: layout.isOutgoing, mergeType: layout.mergeType
)
@@ -514,6 +561,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
#endif
// Telegram-style date/status pill on media-only bubbles.
updateStatusBackgroundVisibility()
updateStatusBackgroundFrame()
// Reply
@@ -538,6 +586,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
if layout.hasFile {
fileContainer.frame = layout.fileFrame
fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40)
fileIconSymbolView.frame = CGRect(x: 9, y: 9, width: 22, height: 22)
fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17)
fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15)
}
@@ -596,6 +645,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
)
}
private static func formattedDuration(seconds: Int) -> String {
let safe = max(seconds, 0)
let minutes = safe / 60
let secs = safe % 60
return String(format: "%d:%02d", minutes, secs)
}
// MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting(
@@ -1306,6 +1362,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
width: contentRect.width + insets.left + insets.right,
height: contentRect.height + insets.top + insets.bottom
)
CATransaction.begin()
CATransaction.setDisableActions(true)
statusGradientLayer.frame = statusBackgroundView.bounds
CATransaction.commit()
}
private func bringStatusOverlayToFront() {

View File

@@ -171,6 +171,7 @@ final class NativeMessageListController: UIViewController {
listConfig.backgroundColor = .clear
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
section.interGroupSpacing = 0
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
return section
}