Фикс: детерминированный роутинг PacketSearch на iOS без подмешивания фоновых результатов
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user