iOS звонки в foreground с full E2EE и паритетом call-attachment
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user