Фикс: детерминированный роутинг 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

@@ -59,8 +59,8 @@
/* Begin PBXFileReference section */
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; };
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = "<group>"; };
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SchemaParityTests.swift; sourceTree = "<group>"; };

View File

@@ -29,7 +29,7 @@ struct BubbleMetrics: Sendable {
mainRadius: 16,
auxiliaryRadius: 8,
tailProtrusion: 6,
defaultSpacing: 2 + screenPixel,
defaultSpacing: screenPixel,
mergedSpacing: 0,
textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11),
mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)

View File

@@ -0,0 +1,194 @@
import UIKit
/// Generates stretchable bubble images using Telegram's **exact** raster approach.
///
/// Telegram draws the tail via two ellipses + `.copy` blend mode erase in a CGContext.
/// This cannot be replicated with vector paths (CAShapeLayer / UIBezierPath) because
/// non-zero winding fill rule treats CCW subpaths as winding=-1 (filled, not erased).
///
/// Source: `ChatMessageBubbleImages.swift` lines 146-428 in Telegram-iOS.
///
/// Pipeline:
/// 1. Draw body (rounded rect) + tail (bottomEllipse QuadCurves + topEllipse erase) in fixed 33pt system
/// 2. Mirror horizontally for incoming
/// 3. Make stretchable via 9-slice (`stretchableImage(withLeftCapWidth:topCapHeight:)`)
/// 4. Cache all variants (mergeType × direction)
enum BubbleImageFactory {
// MARK: - Public
/// Pre-rendered stretchable images for all merge types, keyed by `BubbleMergeType`.
struct ImageSet {
let outgoing: [BubbleMergeType: UIImage]
let incoming: [BubbleMergeType: UIImage]
func image(outgoing: Bool, mergeType: BubbleMergeType) -> UIImage? {
outgoing ? self.outgoing[mergeType] : self.incoming[mergeType]
}
}
/// Generate all bubble images for the given colors.
static func generate(
outgoingColor: UIColor,
incomingColor: UIColor,
mainRadius: CGFloat = 16,
auxiliaryRadius: CGFloat = 8
) -> ImageSet {
let types: [BubbleMergeType] = [
.none, // single message has tail
.top(side: false), // first in group no tail
.both, // middle in group no tail
.bottom, // last in group has tail
]
var outgoing: [BubbleMergeType: UIImage] = [:]
var incoming: [BubbleMergeType: UIImage] = [:]
for type in types {
outgoing[type] = renderBubble(
mergeType: type, incoming: false,
fillColor: outgoingColor,
maxRadius: mainRadius, minRadius: auxiliaryRadius
)
incoming[type] = renderBubble(
mergeType: type, incoming: true,
fillColor: incomingColor,
maxRadius: mainRadius, minRadius: auxiliaryRadius
)
}
return ImageSet(outgoing: outgoing, incoming: incoming)
}
// MARK: - Private: Telegram-exact rendering
/// Render a single stretchable bubble image.
/// Exact port of `messageBubbleImage()` from `ChatMessageBubbleImages.swift`.
private static func renderBubble(
mergeType: BubbleMergeType,
incoming: Bool,
fillColor: UIColor,
maxRadius: CGFloat,
minRadius: CGFloat
) -> UIImage {
// Constants from Telegram
let fixedDiameter: CGFloat = 33.0
let strokeInset: CGFloat = 1.0
let additionalInset: CGFloat = 1.0
let innerW = fixedDiameter + 6.0 // 39
let innerH = fixedDiameter // 33
let rawW = innerW + strokeInset * 2 // 41
let rawH = innerH + strokeInset * 2 // 35
let imgW = rawW + additionalInset * 2 // 43
let imgH = rawH + additionalInset * 2 // 37
let imageSize = CGSize(width: imgW, height: imgH)
// Stretch points (9-slice)
let outStretchX = Int(additionalInset + strokeInset + round(fixedDiameter / 2.0)) - 1 // 18
let outStretchY = Int(additionalInset + strokeInset + round(fixedDiameter / 2.0)) // 19
let inStretchX = Int(rawW) - outStretchX + Int(additionalInset) // 24
// Corner radii in OUTGOING orientation
let (tl, tr, bl, br) = radii(for: mergeType, max: maxRadius, min: minRadius)
let drawTail = BubbleGeometryEngine.hasTail(for: mergeType)
// Telegram ellipses
let bottomEllipse = CGRect(x: 24, y: 16, width: 27, height: 17)
let topEllipse = CGRect(x: 33, y: 14, width: 23, height: 21)
let scale = UIScreen.main.scale
let format = UIGraphicsImageRendererFormat()
format.scale = scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: imageSize, format: format)
let image = renderer.image { rendererCtx in
let ctx = rendererCtx.cgContext
// Mirror for incoming (horizontal flip)
// Telegram: scaleBy(x: incoming ? -1 : 1, y: -1)
// The y:-1 is for Core Graphics coordinate flip.
// In UIGraphicsImageRenderer the context is already UIKit-y-down,
// so we only need horizontal flip for incoming.
if incoming {
ctx.translateBy(x: imageSize.width, y: 0)
ctx.scaleBy(x: -1, y: 1)
}
ctx.translateBy(x: additionalInset + strokeInset, y: additionalInset + strokeInset)
// Fill body + tail with the bubble color
ctx.setFillColor(fillColor.cgColor)
// Body: rounded rect 33×33
ctx.move(to: CGPoint(x: 0, y: tl))
ctx.addArc(tangent1End: .zero, tangent2End: CGPoint(x: tl, y: 0), radius: tl)
ctx.addLine(to: CGPoint(x: fixedDiameter - tr, y: 0))
ctx.addArc(tangent1End: CGPoint(x: fixedDiameter, y: 0),
tangent2End: CGPoint(x: fixedDiameter, y: tr), radius: tr)
ctx.addLine(to: CGPoint(x: fixedDiameter, y: fixedDiameter - br))
ctx.addArc(tangent1End: CGPoint(x: fixedDiameter, y: fixedDiameter),
tangent2End: CGPoint(x: fixedDiameter - br, y: fixedDiameter), radius: br)
ctx.addLine(to: CGPoint(x: bl, y: fixedDiameter))
ctx.addArc(tangent1End: CGPoint(x: 0, y: fixedDiameter),
tangent2End: CGPoint(x: 0, y: fixedDiameter - bl), radius: bl)
ctx.addLine(to: CGPoint(x: 0, y: tl))
ctx.fillPath()
// Tail (only for .none and .bottom)
if drawTail {
// bottomEllipse lower half two QuadCurves
if maxRadius >= 14.0 {
ctx.move(to: CGPoint(x: bottomEllipse.minX, y: bottomEllipse.midY))
ctx.addQuadCurve(
to: CGPoint(x: bottomEllipse.midX, y: bottomEllipse.maxY),
control: CGPoint(x: bottomEllipse.minX, y: bottomEllipse.maxY))
ctx.addQuadCurve(
to: CGPoint(x: bottomEllipse.maxX, y: bottomEllipse.midY),
control: CGPoint(x: bottomEllipse.maxX, y: bottomEllipse.maxY))
ctx.fillPath()
} else {
ctx.fill(CGRect(
x: bottomEllipse.minX - 2, y: bottomEllipse.midY,
width: bottomEllipse.width + 2, height: bottomEllipse.height / 2))
}
// Connecting rect (fills gap between body center and bottomEllipse)
ctx.fill(CGRect(
x: fixedDiameter / 2, y: floor(fixedDiameter / 2),
width: fixedDiameter / 2,
height: ceil(bottomEllipse.midY) - floor(fixedDiameter / 2)))
// topEllipse ERASE the key trick that only works in raster!
ctx.setFillColor(UIColor.clear.cgColor)
ctx.setBlendMode(.copy)
ctx.fillEllipse(in: topEllipse)
}
}
// Make stretchable (9-slice)
let stretchX = incoming ? inStretchX : outStretchX
return image.stretchableImage(withLeftCapWidth: stretchX, topCapHeight: outStretchY)
}
/// Corner radii in outgoing orientation (Telegram convention).
/// Same logic as `messageBubbleArguments()` in ChatMessageBubbleImages.swift.
private static func radii(
for mergeType: BubbleMergeType,
max maxR: CGFloat,
min minR: CGFloat
) -> (CGFloat, CGFloat, CGFloat, CGFloat) {
switch mergeType {
case .none: return (maxR, maxR, maxR, maxR)
case .both: return (maxR, minR, maxR, minR)
case .bottom: return (maxR, minR, maxR, maxR)
case .top(let side):
return (maxR, maxR, side ? minR : maxR, minR)
case .side: return (maxR, maxR, minR, minR)
case .extracted: return (maxR, maxR, maxR, maxR)
}
}
}

View File

@@ -185,9 +185,14 @@ extension MessageCellLayout {
let bottomPad: CGFloat = metrics.textInsets.bottom
let leftPad: CGFloat = metrics.textInsets.left
let rightPad: CGFloat = metrics.textInsets.right
let statusTrailingCompensation: CGFloat = isTextMessage
? max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
: 0
// Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space rightPad-5=6pt compensation)
// Incoming: timestamp at rightPad (11pt) from edge, same as text 0pt compensation
let statusTrailingCompensation: CGFloat
if isTextMessage && config.isOutgoing {
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
} else {
statusTrailingCompensation = 0
}
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
// Text is measured at the WIDEST possible constraint.
@@ -343,8 +348,10 @@ extension MessageCellLayout {
}
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
bubbleH = max(bubbleH, 35)
bubbleH = max(bubbleH, 37)
}
// Stretchable bubble image min height
bubbleH = max(bubbleH, 37)
let totalH = groupGap + bubbleH
@@ -358,9 +365,18 @@ extension MessageCellLayout {
// checkFrame.maxX = bubbleW - textStatusRightInset for text bubbles
// tsFrame.maxX = checkFrame.minX - timeToCheckGap
// checkFrame.minX = bubbleW - inset - checkW
let metadataRightInset: CGFloat = isMediaMessage
? 6
: (isTextMessage ? textStatusLaneMetrics.textStatusRightInset : rightPad)
let metadataRightInset: CGFloat
if isMediaMessage {
metadataRightInset = 6
} else if isTextMessage {
// Outgoing: 5pt (checkmarks fill the gap to rightPad)
// Incoming: rightPad (11pt, same as text no checkmarks to fill the gap)
metadataRightInset = config.isOutgoing
? textStatusLaneMetrics.textStatusRightInset
: rightPad
} else {
metadataRightInset = rightPad
}
let metadataBottomInset: CGFloat = isMediaMessage ? 6 : bottomPad
let statusEndX = bubbleW - metadataRightInset
let statusEndY = bubbleH - metadataBottomInset

View File

@@ -81,10 +81,9 @@ final class ProtocolManager: @unchecked Sendable {
syncBatchLock.unlock()
return val
}
private let searchHandlersLock = NSLock()
private let resultHandlersLock = NSLock()
private let packetQueueLock = NSLock()
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
private let searchRouter = SearchPacketRouter()
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
// Saved credentials for auto-reconnect
@@ -145,6 +144,7 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = false
clearPacketQueue()
clearResultHandlers()
searchRouter.resetPending()
syncBatchLock.lock()
_syncBatchActive = false
syncBatchLock.unlock()
@@ -184,6 +184,7 @@ final class ProtocolManager: @unchecked Sendable {
pingTimeoutTask = nil
handshakeComplete = false
heartbeatTask?.cancel()
searchRouter.resetPending()
connectionState = .connecting
client.forceReconnect()
}
@@ -208,6 +209,7 @@ final class ProtocolManager: @unchecked Sendable {
Self.logger.info("⚡ Fast reconnect — state=\(self.connectionState.rawValue)")
handshakeComplete = false
heartbeatTask?.cancel()
searchRouter.resetPending()
connectionState = .connecting
client.forceReconnect()
}
@@ -254,6 +256,7 @@ final class ProtocolManager: @unchecked Sendable {
pingTimeoutTask = nil
handshakeComplete = false
heartbeatTask?.cancel()
searchRouter.resetPending()
Task { @MainActor in
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
// forceReconnect() is called synchronously below if it completes fast,
@@ -268,6 +271,14 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Sending
func sendSearchPacket(
_ packet: PacketSearch,
channel: SearchPacketChannel = .unscoped
) {
searchRouter.enqueueOutgoingRequest(channel: channel)
sendPacket(packet)
}
func sendPacket(_ packet: any Packet) {
PerformanceLogger.shared.track("protocol.sendPacket")
let id = String(type(of: packet).packetId, radix: 16)
@@ -285,18 +296,15 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Search Handlers (Android-like wait/unwait)
@discardableResult
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
let id = UUID()
searchHandlersLock.lock()
searchResultHandlers[id] = handler
searchHandlersLock.unlock()
return id
func addSearchResultHandler(
channel: SearchPacketChannel = .unscoped,
_ handler: @escaping (PacketSearch) -> Void
) -> UUID {
searchRouter.addHandler(channel: channel, handler)
}
func removeSearchResultHandler(_ id: UUID) {
searchHandlersLock.lock()
searchResultHandlers.removeValue(forKey: id)
searchHandlersLock.unlock()
searchRouter.removeHandler(id)
}
// MARK: - Result Handlers (Android parity: waitPacket(0x02))
@@ -347,6 +355,7 @@ final class ProtocolManager: @unchecked Sendable {
pingVerificationInProgress = false
pingTimeoutTask?.cancel()
pingTimeoutTask = nil
self.searchRouter.resetPending()
Task { @MainActor in
self.connectionState = .disconnected
@@ -465,9 +474,11 @@ final class ProtocolManager: @unchecked Sendable {
}
case 0x03:
if let p = packet as? PacketSearch {
Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
let routedChannel = routeIncomingSearchPacket(p)
Self.logger.debug(
"📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil), routed=\(String(describing: routedChannel))"
)
onSearchResult?(p)
notifySearchResultHandlers(p)
}
case 0x05:
if let p = packet as? PacketOnlineState {
@@ -557,14 +568,8 @@ final class ProtocolManager: @unchecked Sendable {
}
}
private func notifySearchResultHandlers(_ packet: PacketSearch) {
searchHandlersLock.lock()
let handlers = Array(searchResultHandlers.values)
searchHandlersLock.unlock()
for handler in handlers {
handler(packet)
}
private func routeIncomingSearchPacket(_ packet: PacketSearch) -> SearchPacketChannel {
searchRouter.dispatchIncomingResponse(packet)
}
private func notifyResultHandlers(_ packet: PacketResult) {
@@ -609,6 +614,7 @@ final class ProtocolManager: @unchecked Sendable {
case .needDeviceVerification:
handshakeComplete = false
Self.logger.info("Server requires device verification — approve this device from your other Rosetta app")
searchRouter.resetPending()
Task { @MainActor in
self.connectionState = .deviceVerificationRequired

View File

@@ -0,0 +1,112 @@
import Foundation
enum SearchPacketChannel: Hashable, Sendable {
case ui(UUID)
case userInfo
case unscoped
nonisolated static func == (lhs: SearchPacketChannel, rhs: SearchPacketChannel) -> Bool {
switch (lhs, rhs) {
case let (.ui(left), .ui(right)):
return left == right
case (.userInfo, .userInfo), (.unscoped, .unscoped):
return true
default:
return false
}
}
nonisolated func hash(into hasher: inout Hasher) {
switch self {
case let .ui(id):
hasher.combine(0)
hasher.combine(id)
case .userInfo:
hasher.combine(1)
case .unscoped:
hasher.combine(2)
}
}
}
/// Deterministic router for PacketSearch responses.
///
/// Outgoing search requests enqueue their logical channel in FIFO order.
/// Incoming responses are routed to the first pending channel, while `unscoped`
/// handlers remain compatibility listeners that receive all routed responses.
final class SearchPacketRouter {
private struct HandlerEntry {
let channel: SearchPacketChannel
let handler: (PacketSearch) -> Void
}
private let lock = NSLock()
private var handlers: [UUID: HandlerEntry] = [:]
private var pendingChannels: [SearchPacketChannel] = []
@discardableResult
func addHandler(
channel: SearchPacketChannel = .unscoped,
_ handler: @escaping (PacketSearch) -> Void
) -> UUID {
let id = UUID()
lock.lock()
handlers[id] = HandlerEntry(channel: channel, handler: handler)
lock.unlock()
return id
}
func removeHandler(_ id: UUID) {
lock.lock()
handlers.removeValue(forKey: id)
lock.unlock()
}
func enqueueOutgoingRequest(channel: SearchPacketChannel) {
lock.lock()
pendingChannels.append(channel)
lock.unlock()
}
func resetPending() {
lock.lock()
pendingChannels.removeAll()
lock.unlock()
}
/// Routes one incoming response to the next pending channel (FIFO).
/// Returns the routed channel for observability/logging.
@discardableResult
func dispatchIncomingResponse(_ packet: PacketSearch) -> SearchPacketChannel {
let routedChannel: SearchPacketChannel
let callbacks: [(PacketSearch) -> Void]
lock.lock()
if pendingChannels.isEmpty {
routedChannel = .unscoped
} else {
routedChannel = pendingChannels.removeFirst()
}
callbacks = handlers.values
.filter { $0.channel == .unscoped || $0.channel == routedChannel }
.map(\.handler)
lock.unlock()
for callback in callbacks {
callback(packet)
}
return routedChannel
}
// MARK: - Test support
var pendingCount: Int {
lock.lock()
let count = pendingChannels.count
lock.unlock()
return count
}
}

View File

@@ -35,6 +35,12 @@ struct LivePacketFlowSender: PacketFlowSending {
}
struct LiveSearchResultDispatcher: SearchResultDispatching {
private let channel: SearchPacketChannel
init(channel: SearchPacketChannel = .ui(UUID())) {
self.channel = channel
}
var connectionState: ConnectionState {
ProtocolManager.shared.connectionState
}
@@ -44,12 +50,12 @@ struct LiveSearchResultDispatcher: SearchResultDispatching {
}
func sendSearchPacket(_ packet: PacketSearch) {
ProtocolManager.shared.sendPacket(packet)
ProtocolManager.shared.sendSearchPacket(packet, channel: channel)
}
@discardableResult
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
ProtocolManager.shared.addSearchResultHandler(handler)
ProtocolManager.shared.addSearchResultHandler(channel: channel, handler)
}
func removeSearchResultHandler(_ id: UUID) {

View File

@@ -1242,7 +1242,7 @@ final class SessionManager {
var searchPacket = PacketSearch()
searchPacket.privateKey = hash
searchPacket.search = self.currentPublicKey
ProtocolManager.shared.sendPacket(searchPacket)
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
}
// Reset sync state if a previous connection dropped mid-sync,
@@ -1661,7 +1661,7 @@ final class SessionManager {
var searchPacket = PacketSearch()
searchPacket.privateKey = hash
searchPacket.search = publicKey
ProtocolManager.shared.sendPacket(searchPacket)
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
}
private func requestSynchronize(cursor: Int64? = nil) {
@@ -2025,7 +2025,7 @@ final class SessionManager {
var searchPacket = PacketSearch()
searchPacket.privateKey = privateKeyHash
searchPacket.search = normalized
ProtocolManager.shared.sendPacket(searchPacket)
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
}
/// Request user info for all existing dialog opponents after sync completes.
@@ -2089,7 +2089,7 @@ final class SessionManager {
/// This runs independently of ChatListViewModel's search UI handler.
/// Also detects own profile in search results and updates SessionManager + AccountManager.
private func setupUserInfoSearchHandler() {
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler(channel: .userInfo) { [weak self] packet in
Task { @MainActor [weak self] in
guard let self else { return }
let ownKey = self.currentPublicKey

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()
}

View File

@@ -66,6 +66,43 @@ final class SearchParityTests: XCTestCase {
XCTAssertEqual(merged[0].title, "Server Name")
XCTAssertEqual(merged[1].publicKey, localOnlyKey)
}
func testActiveUiSearchIgnoresBackgroundUserInfoPackets() async {
let bus = ChannelSearchBus()
let uiChannel = SearchPacketChannel.ui(UUID())
let uiDispatcher = ChannelBusSearchDispatcher(bus: bus, channel: uiChannel)
let userInfoDispatcher = ChannelBusSearchDispatcher(bus: bus, channel: .userInfo)
let vm = SearchViewModel(searchDispatcher: uiDispatcher)
var backgroundRequest = PacketSearch()
backgroundRequest.search = "02aa"
userInfoDispatcher.sendSearchPacket(backgroundRequest)
vm.setSearchQuery("alice")
try? await Task.sleep(for: .milliseconds(1200))
XCTAssertEqual(uiDispatcher.sentQueries, ["alice"])
var backgroundPacket = PacketSearch()
backgroundPacket.users = [
SearchUser(username: "random_user", title: "Random User", publicKey: "02aa", verified: 0, online: 0),
]
userInfoDispatcher.emit(backgroundPacket)
try? await Task.sleep(for: .milliseconds(80))
XCTAssertTrue(vm.searchResults.isEmpty)
XCTAssertTrue(vm.isSearching)
var uiPacket = PacketSearch()
uiPacket.users = [
SearchUser(username: "alice_user", title: "Alice", publicKey: "02bb", verified: 1, online: 0),
]
uiDispatcher.emit(uiPacket)
try? await Task.sleep(for: .milliseconds(80))
XCTAssertEqual(vm.searchResults.count, 1)
XCTAssertEqual(vm.searchResults.first?.username, "alice_user")
XCTAssertFalse(vm.isSearching)
}
}
private extension SearchParityTests {
@@ -124,3 +161,74 @@ private final class MockSearchDispatcher: SearchResultDispatching {
handlers.removeValue(forKey: id)
}
}
@MainActor
private final class ChannelBusSearchDispatcher: SearchResultDispatching {
let connectionState: ConnectionState = .authenticated
let privateHash: String? = "mock-private-hash"
private(set) var sentQueries: [String] = []
private let bus: ChannelSearchBus
private let channel: SearchPacketChannel
init(bus: ChannelSearchBus, channel: SearchPacketChannel) {
self.bus = bus
self.channel = channel
}
func sendSearchPacket(_ packet: PacketSearch) {
sentQueries.append(packet.search)
bus.enqueue(channel: channel)
}
@discardableResult
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
bus.addHandler(channel: channel, handler)
}
func removeSearchResultHandler(_ id: UUID) {
bus.removeHandler(id)
}
func emit(_ packet: PacketSearch) {
bus.dispatch(packet)
}
}
private final class ChannelSearchBus {
private struct HandlerEntry {
let channel: SearchPacketChannel
let handler: (PacketSearch) -> Void
}
private var handlers: [UUID: HandlerEntry] = [:]
private var pendingChannels: [SearchPacketChannel] = []
@discardableResult
func addHandler(
channel: SearchPacketChannel,
_ handler: @escaping (PacketSearch) -> Void
) -> UUID {
let id = UUID()
handlers[id] = HandlerEntry(channel: channel, handler: handler)
return id
}
func removeHandler(_ id: UUID) {
handlers.removeValue(forKey: id)
}
func enqueue(channel: SearchPacketChannel) {
pendingChannels.append(channel)
}
func dispatch(_ packet: PacketSearch) {
let routed = pendingChannels.isEmpty ? SearchPacketChannel.unscoped : pendingChannels.removeFirst()
let callbacks = handlers.values
.filter { $0.channel == .unscoped || $0.channel == routed }
.map(\.handler)
for callback in callbacks {
callback(packet)
}
}
}

View File

@@ -0,0 +1,93 @@
import XCTest
@testable import Rosetta
final class SearchRoutingTests: XCTestCase {
func testResponseRoutesToMatchingChannel() {
let router = SearchPacketRouter()
let uiChannel = SearchPacketChannel.ui(UUID())
var uiHits = 0
var userInfoHits = 0
var unscopedHits = 0
_ = router.addHandler(channel: uiChannel) { _ in uiHits += 1 }
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
router.enqueueOutgoingRequest(channel: uiChannel)
let firstRouted = router.dispatchIncomingResponse(PacketSearch())
XCTAssertEqual(firstRouted, uiChannel)
XCTAssertEqual(uiHits, 1)
XCTAssertEqual(userInfoHits, 0)
XCTAssertEqual(unscopedHits, 1)
router.enqueueOutgoingRequest(channel: .userInfo)
let secondRouted = router.dispatchIncomingResponse(PacketSearch())
XCTAssertEqual(secondRouted, .userInfo)
XCTAssertEqual(uiHits, 1)
XCTAssertEqual(userInfoHits, 1)
XCTAssertEqual(unscopedHits, 2)
}
func testInterleavingUiAndUserInfoDoesNotCrossTalk() {
let router = SearchPacketRouter()
let uiA = SearchPacketChannel.ui(UUID())
let uiB = SearchPacketChannel.ui(UUID())
var uiAHits = 0
var uiBHits = 0
var userInfoHits = 0
_ = router.addHandler(channel: uiA) { _ in uiAHits += 1 }
_ = router.addHandler(channel: uiB) { _ in uiBHits += 1 }
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
router.enqueueOutgoingRequest(channel: uiA)
router.enqueueOutgoingRequest(channel: .userInfo)
router.enqueueOutgoingRequest(channel: uiB)
_ = router.dispatchIncomingResponse(PacketSearch())
_ = router.dispatchIncomingResponse(PacketSearch())
_ = router.dispatchIncomingResponse(PacketSearch())
XCTAssertEqual(uiAHits, 1)
XCTAssertEqual(userInfoHits, 1)
XCTAssertEqual(uiBHits, 1)
}
func testResetPendingClearsRoutingState() {
let router = SearchPacketRouter()
let uiChannel = SearchPacketChannel.ui(UUID())
var uiHits = 0
var unscopedHits = 0
_ = router.addHandler(channel: uiChannel) { _ in uiHits += 1 }
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
router.enqueueOutgoingRequest(channel: uiChannel)
XCTAssertEqual(router.pendingCount, 1)
router.resetPending()
XCTAssertEqual(router.pendingCount, 0)
let routed = router.dispatchIncomingResponse(PacketSearch())
XCTAssertEqual(routed, .unscoped)
XCTAssertEqual(uiHits, 0)
XCTAssertEqual(unscopedHits, 1)
}
func testFallbackToUnscopedWhenNoPending() {
let router = SearchPacketRouter()
var unscopedHits = 0
var userInfoHits = 0
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
let routed = router.dispatchIncomingResponse(PacketSearch())
XCTAssertEqual(routed, .unscoped)
XCTAssertEqual(unscopedHits, 1)
XCTAssertEqual(userInfoHits, 0)
}
}