diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 9b1b9e4..9632052 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -59,8 +59,8 @@ /* Begin PBXFileReference section */ 0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = ""; }; - 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = ""; }; 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 = ""; }; 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = ""; }; 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 = ""; }; diff --git a/Rosetta/Core/Layout/BubbleGeometryEngine.swift b/Rosetta/Core/Layout/BubbleGeometryEngine.swift index 2134575..60d656a 100644 --- a/Rosetta/Core/Layout/BubbleGeometryEngine.swift +++ b/Rosetta/Core/Layout/BubbleGeometryEngine.swift @@ -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) diff --git a/Rosetta/Core/Layout/BubbleImageFactory.swift b/Rosetta/Core/Layout/BubbleImageFactory.swift new file mode 100644 index 0000000..6b3ce33 --- /dev/null +++ b/Rosetta/Core/Layout/BubbleImageFactory.swift @@ -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) + } + } +} diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index cb19325..0ac64f5 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -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 diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 2030d8a..63ddb20 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -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 diff --git a/Rosetta/Core/Network/Protocol/SearchPacketRouter.swift b/Rosetta/Core/Network/Protocol/SearchPacketRouter.swift new file mode 100644 index 0000000..c20b32e --- /dev/null +++ b/Rosetta/Core/Network/Protocol/SearchPacketRouter.swift @@ -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 + } +} diff --git a/Rosetta/Core/Services/ParityTestSupportInterfaces.swift b/Rosetta/Core/Services/ParityTestSupportInterfaces.swift index 3a91fd6..a4bd9c6 100644 --- a/Rosetta/Core/Services/ParityTestSupportInterfaces.swift +++ b/Rosetta/Core/Services/ParityTestSupportInterfaces.swift @@ -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) { diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 97edfa0..777a077 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 0408851..f54edbe 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -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 = { let cache = NSCache() 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) diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift index dabcfdc..1b15169 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -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() } diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift index ad12a01..8d00ed7 100644 --- a/Rosetta/Features/Chats/Search/SearchViewModel.swift +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -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() } diff --git a/RosettaTests/SearchParityTests.swift b/RosettaTests/SearchParityTests.swift index 5247250..192a052 100644 --- a/RosettaTests/SearchParityTests.swift +++ b/RosettaTests/SearchParityTests.swift @@ -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) + } + } +} diff --git a/RosettaTests/SearchRoutingTests.swift b/RosettaTests/SearchRoutingTests.swift new file mode 100644 index 0000000..9267c69 --- /dev/null +++ b/RosettaTests/SearchRoutingTests.swift @@ -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) + } +}