Фикс: детерминированный роутинг PacketSearch на iOS без подмешивания фоновых результатов

This commit is contained in:
2026-03-28 21:07:05 +05:00
parent 5af28b68a8
commit e49d224e6a
13 changed files with 613 additions and 62 deletions

View File

@@ -17,8 +17,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
@@ -41,6 +41,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200
@@ -49,9 +54,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Subviews (always present, hidden when unused)
// Bubble
// Bubble uses Telegram-exact stretchable image for the fill (raster tail)
// + CAShapeLayer for shadow path (approximate, vector)
private let bubbleView = UIView()
private let bubbleLayer = CAShapeLayer()
private let bubbleImageView = UIImageView()
private let bubbleLayer = CAShapeLayer() // shadow only, fillColor = clear
private let bubbleOutlineLayer = CAShapeLayer()
// Text (CoreText rendering matches Telegram's CTTypesetter + CTRunDraw pipeline)
@@ -131,8 +138,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
backgroundColor = .clear
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble
bubbleLayer.fillColor = Self.outgoingColor.cgColor
// Bubble CAShapeLayer for shadow (index 0), then outline, then raster image on top
bubbleLayer.fillColor = UIColor.clear.cgColor
bubbleLayer.fillRule = .nonZero
bubbleLayer.shadowColor = UIColor.black.cgColor
bubbleLayer.shadowOpacity = 0.12
@@ -142,6 +149,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleOutlineLayer.fillColor = UIColor.clear.cgColor
bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1)
bubbleView.layer.insertSublayer(bubbleOutlineLayer, above: bubbleLayer)
// Raster bubble image (Telegram-exact tail) added last so it renders above outline
bubbleImageView.contentMode = .scaleToFill
bubbleView.addSubview(bubbleImageView)
contentView.addSubview(bubbleView)
// Text (CoreTextLabel no font/color/lines config; all baked into CoreTextTextLayout)
@@ -168,7 +178,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.addSubview(clockMinView)
// Reply quote
replyBar.layer.cornerRadius = 1.5
replyBar.layer.cornerRadius = 4.0
replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont
replyContainer.addSubview(replyNameLabel)
@@ -362,8 +372,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
deliveryFailedButton.isHidden = !(isOutgoing && message.deliveryStatus == .error)
updateStatusBackgroundVisibility()
// Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
// Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView)
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote
@@ -438,20 +447,29 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
x: bubbleX, y: layout.groupGap,
width: layout.bubbleSize.width, height: layout.bubbleSize.height
)
bubbleLayer.frame = bubbleView.bounds
let shapeRect: CGRect
if layout.hasTail {
if layout.isOutgoing {
shapeRect = CGRect(x: 0, y: 0,
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
} else {
shapeRect = CGRect(x: -tailProtrusion, y: 0,
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
}
// Raster bubble image (Telegram-exact tail via stretchable image)
// Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles,
// not just tailed ones. This keeps right edges aligned in a group.
// For non-tailed, the tail area is transparent in the stretchable image.
let imageFrame: CGRect
if layout.isOutgoing {
imageFrame = CGRect(x: 0, y: 0,
width: layout.bubbleSize.width + tailProtrusion,
height: layout.bubbleSize.height)
} else {
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
imageFrame = CGRect(x: -tailProtrusion, y: 0,
width: layout.bubbleSize.width + tailProtrusion,
height: layout.bubbleSize.height)
}
bubbleImageView.frame = imageFrame
bubbleImageView.image = Self.bubbleImages.image(
outgoing: layout.isOutgoing, mergeType: layout.mergeType
)
// Vector shadow path (approximate shape, used only for shadow)
bubbleLayer.frame = bubbleView.bounds
let shapeRect = imageFrame
bubbleLayer.path = BubblePathCache.shared.path(
size: shapeRect.size, origin: shapeRect.origin,
mergeType: layout.mergeType,
@@ -468,8 +486,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
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
// 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)

View File

@@ -34,8 +34,8 @@ final class ChatListViewModel: ObservableObject {
// MARK: - Init
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
self.searchDispatcher = searchDispatcher
init(searchDispatcher: SearchResultDispatching? = nil) {
self.searchDispatcher = searchDispatcher ?? LiveSearchResultDispatcher()
configureRecentSearches()
setupSearchCallback()
}

View File

@@ -34,8 +34,8 @@ final class SearchViewModel: ObservableObject {
// MARK: - Init
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
self.searchDispatcher = searchDispatcher
init(searchDispatcher: SearchResultDispatching? = nil) {
self.searchDispatcher = searchDispatcher ?? LiveSearchResultDispatcher()
configureRecentSearches()
setupSearchCallback()
}