Чат: сузил bubble и улучшил пропорции media-сообщений

This commit is contained in:
2026-03-28 08:38:03 +05:00
parent d706ef6d62
commit 1978db0f38
3 changed files with 125 additions and 15 deletions

View File

@@ -211,9 +211,21 @@ extension MessageCellLayout {
// Tiny floor just to prevent zero-width collapse. // Tiny floor just to prevent zero-width collapse.
// Telegram does NOT force a large minW short messages get tight bubbles. // Telegram does NOT force a large minW short messages get tight bubbles.
let minW: CGFloat = 40 let minW: CGFloat = 40
let mediaWidthFraction: CGFloat
let mediaAbsoluteCap: CGFloat
if config.imageCount == 1 {
mediaWidthFraction = 0.64
mediaAbsoluteCap = 288
} else if config.imageCount == 2 {
mediaWidthFraction = 0.67
mediaAbsoluteCap = 300
} else {
mediaWidthFraction = 0.7
mediaAbsoluteCap = 312
}
let mediaBubbleMaxWidth = min( let mediaBubbleMaxWidth = min(
effectiveMaxBubbleWidth, effectiveMaxBubbleWidth,
max(220, UIScreen.main.bounds.width * 0.78) min(mediaAbsoluteCap, max(200, UIScreen.main.bounds.width * mediaWidthFraction))
) )
var bubbleW: CGFloat var bubbleW: CGFloat
@@ -390,14 +402,14 @@ extension MessageCellLayout {
/// Photo collage height same formulas as C++ MessageLayout & PhotoCollageView.swift. /// Photo collage height same formulas as C++ MessageLayout & PhotoCollageView.swift.
private static func collageHeight(count: Int, width: CGFloat) -> CGFloat { private static func collageHeight(count: Int, width: CGFloat) -> CGFloat {
guard count > 0 else { return 0 } guard count > 0 else { return 0 }
if count == 1 { return min(width * 0.75, 320) } if count == 1 { return max(180, min(width * 0.93, 340)) }
if count == 2 { if count == 2 {
let cellW = (width - 2) / 2 let cellW = (width - 2) / 2
return min(cellW * 1.2, 320) return min(cellW * 1.28, 330)
} }
if count == 3 { if count == 3 {
let leftW = width * 0.66 let leftW = width * 0.66
return min(leftW * 1.1, 320) return min(leftW * 1.16, 330)
} }
if count == 4 { if count == 4 {
let cellW = (width - 2) / 2 let cellW = (width - 2) / 2

View File

@@ -129,9 +129,9 @@ struct ChatDetailView: View {
private var maxBubbleWidth: CGFloat { private var maxBubbleWidth: CGFloat {
let w = UIScreen.main.bounds.width let w = UIScreen.main.bounds.width
if w <= 500 { if w <= 500 {
return max(240, min(w * 0.78, w - 86)) return max(224, min(w * 0.72, w - 104))
} }
return min(w * 0.72, 520) return min(w * 0.66, 460)
} }
/// Visual chat content: messages list + gradient overlays + background. /// Visual chat content: messages list + gradient overlays + background.

View File

@@ -163,7 +163,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var photoTileImageViews: [UIImageView] = [] private var photoTileImageViews: [UIImageView] = []
private var photoTilePlaceholderViews: [UIView] = [] private var photoTilePlaceholderViews: [UIView] = []
private var photoTileActivityIndicators: [UIActivityIndicatorView] = [] private var photoTileActivityIndicators: [UIActivityIndicatorView] = []
private var photoTileErrorViews: [UIImageView] = []
private var photoTileButtons: [UIButton] = [] private var photoTileButtons: [UIButton] = []
private let photoUploadingOverlayView = UIView()
private let photoUploadingIndicator = UIActivityIndicatorView(style: .medium)
private let photoOverflowOverlayView = UIView() private let photoOverflowOverlayView = UIView()
private let photoOverflowLabel = UILabel() private let photoOverflowLabel = UILabel()
@@ -195,6 +198,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var photoLoadTasks: [String: Task<Void, Never>] = [:] private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:] private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = [] private var downloadingAttachmentIds: Set<String> = []
private var failedAttachmentIds: Set<String> = []
// MARK: - Init // MARK: - Init
@@ -230,8 +234,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(textLabel) bubbleView.addSubview(textLabel)
// Timestamp // Timestamp
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.32) statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
statusBackgroundView.layer.cornerRadius = 6 statusBackgroundView.layer.cornerRadius = 7
statusBackgroundView.layer.cornerCurve = .continuous
statusBackgroundView.isHidden = true statusBackgroundView.isHidden = true
bubbleView.addSubview(statusBackgroundView) bubbleView.addSubview(statusBackgroundView)
@@ -281,6 +286,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.isHidden = true indicator.isHidden = true
photoContainer.addSubview(indicator) photoContainer.addSubview(indicator)
let errorView = UIImageView(image: Self.errorIcon)
errorView.contentMode = .center
errorView.isHidden = true
photoContainer.addSubview(errorView)
let button = UIButton(type: .custom) let button = UIButton(type: .custom)
button.tag = index button.tag = index
button.isHidden = true button.isHidden = true
@@ -290,9 +300,20 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTileImageViews.append(imageView) photoTileImageViews.append(imageView)
photoTilePlaceholderViews.append(placeholderView) photoTilePlaceholderViews.append(placeholderView)
photoTileActivityIndicators.append(indicator) photoTileActivityIndicators.append(indicator)
photoTileErrorViews.append(errorView)
photoTileButtons.append(button) photoTileButtons.append(button)
} }
photoUploadingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.22)
photoUploadingOverlayView.isHidden = true
photoUploadingOverlayView.isUserInteractionEnabled = false
photoContainer.addSubview(photoUploadingOverlayView)
photoUploadingIndicator.color = .white
photoUploadingIndicator.hidesWhenStopped = true
photoUploadingIndicator.isHidden = true
photoContainer.addSubview(photoUploadingIndicator)
photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45) photoOverflowOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.45)
photoOverflowOverlayView.layer.cornerCurve = .continuous photoOverflowOverlayView.layer.cornerCurve = .continuous
photoOverflowOverlayView.layer.cornerRadius = 0 photoOverflowOverlayView.layer.cornerRadius = 0
@@ -516,11 +537,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleLayer.shadowPath = bubbleLayer.path bubbleLayer.shadowPath = bubbleLayer.path
bubbleOutlineLayer.frame = bubbleView.bounds bubbleOutlineLayer.frame = bubbleView.bounds
bubbleOutlineLayer.path = bubbleLayer.path bubbleOutlineLayer.path = bubbleLayer.path
if layout.hasTail { let hasPhotoContent = layout.hasPhoto
if hasPhotoContent {
bubbleLayer.shadowOpacity = 0.04
bubbleLayer.shadowRadius = 0.4
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else if layout.hasTail {
// Tail path is appended as a second subpath; stroking it produces // Tail path is appended as a second subpath; stroking it produces
// a visible seam at the junction. Keep fill-only for tailed bubbles. // a visible seam at the junction. Keep fill-only for tailed bubbles.
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
} else { } else {
bubbleLayer.shadowOpacity = 0.12
bubbleLayer.shadowRadius = 0.6
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent( bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent(
layout.isOutgoing ? 0.16 : 0.22 layout.isOutgoing ? 0.16 : 0.22
).cgColor ).cgColor
@@ -732,6 +765,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
task.cancel() task.cancel()
photoDownloadTasks.removeValue(forKey: attachmentId) photoDownloadTasks.removeValue(forKey: attachmentId)
downloadingAttachmentIds.remove(attachmentId) downloadingAttachmentIds.remove(attachmentId)
failedAttachmentIds.remove(attachmentId)
} }
for index in 0..<photoTileImageViews.count { for index in 0..<photoTileImageViews.count {
@@ -739,6 +773,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let imageView = photoTileImageViews[index] let imageView = photoTileImageViews[index]
let placeholderView = photoTilePlaceholderViews[index] let placeholderView = photoTilePlaceholderViews[index]
let indicator = photoTileActivityIndicators[index] let indicator = photoTileActivityIndicators[index]
let errorView = photoTileErrorViews[index]
let button = photoTileButtons[index] let button = photoTileButtons[index]
button.isHidden = !isActiveTile button.isHidden = !isActiveTile
@@ -749,29 +784,42 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
placeholderView.isHidden = true placeholderView.isHidden = true
indicator.stopAnimating() indicator.stopAnimating()
indicator.isHidden = true indicator.isHidden = true
errorView.isHidden = true
continue continue
} }
let attachment = photoAttachments[index] let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
failedAttachmentIds.remove(attachment.id)
setPhotoTileImage(cached, at: index, animated: false) setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true placeholderView.isHidden = true
indicator.stopAnimating() indicator.stopAnimating()
indicator.isHidden = true indicator.isHidden = true
errorView.isHidden = true
} else { } else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false) setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
placeholderView.isHidden = imageView.image != nil placeholderView.isHidden = imageView.image != nil
if downloadingAttachmentIds.contains(attachment.id) { let hasFailed = failedAttachmentIds.contains(attachment.id)
if hasFailed {
indicator.stopAnimating()
indicator.isHidden = true
errorView.isHidden = false
} else if downloadingAttachmentIds.contains(attachment.id) {
indicator.startAnimating() indicator.startAnimating()
indicator.isHidden = false indicator.isHidden = false
errorView.isHidden = true
} else { } else {
indicator.stopAnimating() indicator.stopAnimating()
indicator.isHidden = true indicator.isHidden = true
errorView.isHidden = true
} }
startPhotoLoadTask(attachment: attachment) startPhotoLoadTask(attachment: attachment)
} }
} }
layoutPhotoOverflowOverlay() layoutPhotoOverflowOverlay()
updatePhotoUploadingOverlay(
isVisible: layout.isOutgoing && message.deliveryStatus == .waiting
)
} }
private func layoutPhotoTiles() { private func layoutPhotoTiles() {
@@ -783,7 +831,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTilePlaceholderViews[index].frame = frame photoTilePlaceholderViews[index].frame = frame
photoTileButtons[index].frame = frame photoTileButtons[index].frame = frame
photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY) photoTileActivityIndicators[index].center = CGPoint(x: frame.midX, y: frame.midY)
photoTileErrorViews[index].frame = CGRect(
x: frame.midX - 10, y: frame.midY - 10,
width: 20, height: 20
)
} }
photoUploadingOverlayView.frame = photoContainer.bounds
photoUploadingIndicator.center = CGPoint(
x: photoContainer.bounds.midX,
y: photoContainer.bounds.midY
)
photoContainer.bringSubviewToFront(photoUploadingOverlayView)
photoContainer.bringSubviewToFront(photoUploadingIndicator)
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
layoutPhotoOverflowOverlay(frames: frames) layoutPhotoOverflowOverlay(frames: frames)
} }
@@ -795,14 +855,21 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let inset: CGFloat = 2 let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0) let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(8 - inset, 0) let s: CGFloat = max(5 - inset, 0)
let tailJoin: CGFloat = max(10 - inset, 0)
let rect = photoContainer.bounds let rect = photoContainer.bounds
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = { let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch layout.position { switch layout.position {
case .single: return (r, r, r, r) case .single:
return layout.isOutgoing
? (r, r, r, tailJoin)
: (r, r, tailJoin, r)
case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r) case .top: return layout.isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r) case .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom: return layout.isOutgoing ? (r, s, r, r) : (s, r, r, r) case .bottom:
return layout.isOutgoing
? (r, s, r, tailJoin)
: (s, r, tailJoin, r)
} }
}() }()
@@ -950,6 +1017,17 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoOverflowLabel.frame = photoOverflowOverlayView.bounds photoOverflowLabel.frame = photoOverflowOverlayView.bounds
} }
private func updatePhotoUploadingOverlay(isVisible: Bool) {
photoUploadingOverlayView.isHidden = !isVisible
if isVisible {
photoUploadingIndicator.isHidden = false
photoUploadingIndicator.startAnimating()
} else {
photoUploadingIndicator.stopAnimating()
photoUploadingIndicator.isHidden = true
}
}
private func tileIndex(for attachmentId: String) -> Int? { private func tileIndex(for attachmentId: String) -> Int? {
photoAttachments.firstIndex(where: { $0.id == attachmentId }) photoAttachments.firstIndex(where: { $0.id == attachmentId })
} }
@@ -972,10 +1050,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let loaded else { let loaded else {
return return
} }
self.failedAttachmentIds.remove(attachmentId)
self.setPhotoTileImage(loaded, at: tileIndex, animated: true) self.setPhotoTileImage(loaded, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
} }
} }
} }
@@ -986,14 +1066,22 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
guard !tag.isEmpty, guard !tag.isEmpty,
let storedPassword = message.attachmentPassword, let storedPassword = message.attachmentPassword,
!storedPassword.isEmpty else { !storedPassword.isEmpty else {
failedAttachmentIds.insert(attachment.id)
if let tileIndex = tileIndex(for: attachment.id), tileIndex < photoTileErrorViews.count {
photoTileActivityIndicators[tileIndex].stopAnimating()
photoTileActivityIndicators[tileIndex].isHidden = true
photoTileErrorViews[tileIndex].isHidden = false
}
return return
} }
let attachmentId = attachment.id let attachmentId = attachment.id
failedAttachmentIds.remove(attachmentId)
downloadingAttachmentIds.insert(attachmentId) downloadingAttachmentIds.insert(attachmentId)
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count { if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileActivityIndicators.count {
photoTileActivityIndicators[tileIndex].startAnimating() photoTileActivityIndicators[tileIndex].startAnimating()
photoTileActivityIndicators[tileIndex].isHidden = false photoTileActivityIndicators[tileIndex].isHidden = false
photoTileErrorViews[tileIndex].isHidden = true
} }
photoDownloadTasks[attachmentId] = Task { [weak self] in photoDownloadTasks[attachmentId] = Task { [weak self] in
@@ -1011,9 +1099,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return return
} }
if let image { if let image {
self.failedAttachmentIds.remove(attachmentId)
AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId) AttachmentCache.shared.saveImage(image, forAttachmentId: attachmentId)
self.setPhotoTileImage(image, at: tileIndex, animated: true) self.setPhotoTileImage(image, at: tileIndex, animated: true)
self.photoTilePlaceholderViews[tileIndex].isHidden = true self.photoTilePlaceholderViews[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = true
} else {
self.failedAttachmentIds.insert(attachmentId)
self.photoTileErrorViews[tileIndex].isHidden = false
} }
self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileActivityIndicators[tileIndex].isHidden = true
@@ -1023,12 +1116,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
guard let self else { return } guard let self else { return }
self.photoDownloadTasks.removeValue(forKey: attachmentId) self.photoDownloadTasks.removeValue(forKey: attachmentId)
self.downloadingAttachmentIds.remove(attachmentId) self.downloadingAttachmentIds.remove(attachmentId)
self.failedAttachmentIds.insert(attachmentId)
guard let tileIndex = self.tileIndex(for: attachmentId), guard let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileActivityIndicators.count else { tileIndex < self.photoTileActivityIndicators.count else {
return return
} }
self.photoTileActivityIndicators[tileIndex].stopAnimating() self.photoTileActivityIndicators[tileIndex].stopAnimating()
self.photoTileActivityIndicators[tileIndex].isHidden = true self.photoTileActivityIndicators[tileIndex].isHidden = true
self.photoTileErrorViews[tileIndex].isHidden = false
} }
} }
} }
@@ -1046,7 +1141,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
} }
photoDownloadTasks.removeAll() photoDownloadTasks.removeAll()
downloadingAttachmentIds.removeAll() downloadingAttachmentIds.removeAll()
failedAttachmentIds.removeAll()
photoContainer.layer.mask = nil photoContainer.layer.mask = nil
updatePhotoUploadingOverlay(isVisible: false)
photoOverflowOverlayView.isHidden = true photoOverflowOverlayView.isHidden = true
photoOverflowLabel.isHidden = true photoOverflowLabel.isHidden = true
photoOverflowLabel.text = nil photoOverflowLabel.text = nil
@@ -1057,6 +1154,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoTilePlaceholderViews[index].isHidden = true photoTilePlaceholderViews[index].isHidden = true
photoTileActivityIndicators[index].stopAnimating() photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileButtons[index].isHidden = true photoTileButtons[index].isHidden = true
} }
} }
@@ -1271,7 +1369,7 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
final class BubblePathCache { final class BubblePathCache {
static let shared = BubblePathCache() static let shared = BubblePathCache()
private let pathVersion = 7 private let pathVersion = 8
private var cache: [String: CGPath] = [:] private var cache: [String: CGPath] = [:]
func path( func path(
@@ -1296,7 +1394,7 @@ final class BubblePathCache {
private func makeBubblePath( private func makeBubblePath(
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath { ) -> CGPath {
let r: CGFloat = 16, s: CGFloat = 8, tailW: CGFloat = 6 let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6
// Body rect // Body rect
let bodyRect: CGRect let bodyRect: CGRect