Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит

This commit is contained in:
2026-03-28 18:21:55 +05:00
parent 8314318a8a
commit 5af28b68a8
40 changed files with 3990 additions and 892 deletions

View File

@@ -1,224 +1,30 @@
import SwiftUI
// MARK: - Bubble Position
enum BubblePosition: Sendable, Equatable {
case single, top, mid, bottom
}
// MARK: - Message Bubble Shape
/// Unified message bubble shape: rounded-rect body + tail drawn as a **single fill**.
///
/// The body and tail are two closed subpaths inside one `Path`.
/// Non-zero winding rule fills the overlap area seamlessly
/// no anti-aliasing seam between body and tail.
///
/// For positions without a tail (`.top`, `.mid`), only the body subpath is drawn.
/// For positions with a tail (`.single`, `.bottom`), both subpaths are drawn.
///
/// The shape's `rect` includes space for the tail protrusion on the near side.
/// The body is inset from that side; the tail fills the protrusion area.
struct MessageBubbleShape: Shape {
let position: BubblePosition
let outgoing: Bool
let hasTail: Bool
/// How far the tail protrudes beyond the bubble body edge (points).
static let tailProtrusion: CGFloat = 6
private let mergeType: BubbleMergeType
private let metrics: BubbleMetrics
init(position: BubblePosition, outgoing: Bool) {
self.position = position
self.outgoing = outgoing
switch position {
case .single, .bottom: self.hasTail = true
case .top, .mid: self.hasTail = false
}
self.mergeType = BubbleGeometryEngine.mergeType(for: position)
self.metrics = .telegram()
}
static var tailProtrusion: CGFloat {
BubbleMetrics.telegram().tailProtrusion
}
func path(in rect: CGRect) -> Path {
var p = Path()
// Body rect: inset on the near side when tail is present
let bodyRect: CGRect
if hasTail {
if outgoing {
bodyRect = CGRect(x: rect.minX, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
} else {
bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
}
} else {
bodyRect = rect
}
addBody(to: &p, rect: bodyRect)
if hasTail {
addTail(to: &p, bodyRect: bodyRect)
}
return p
}
// MARK: - Body (Rounded Rect with Per-Corner Radii)
private func addBody(to p: inout Path, rect: CGRect) {
let r: CGFloat = 16
let s: CGFloat = 5
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
// Clamp to half the smallest dimension
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR)
let cTR = min(tr, maxR)
let cBL = min(bl, maxR)
let cBR = min(br, maxR)
p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
// Top edge top-right corner
p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR),
radius: cTR)
// Right edge bottom-right corner
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY),
radius: cBR)
// Bottom edge bottom-left corner
p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL),
radius: cBL)
// Left edge top-left corner
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY),
radius: cTL)
p.closeSubpath()
}
/// Figma corner radii: 8px on "connecting" side, 18px elsewhere.
private func cornerRadii(r: CGFloat, s: CGFloat)
-> (topLeading: CGFloat, topTrailing: CGFloat,
bottomLeading: CGFloat, bottomTrailing: CGFloat) {
switch position {
case .single:
return (r, r, r, r)
case .top:
return outgoing
? (r, r, r, s)
: (r, r, s, r)
case .mid:
return outgoing
? (r, s, r, s)
: (s, r, s, r)
case .bottom:
return outgoing
? (r, s, r, r)
: (s, r, r, r)
}
}
// MARK: - Tail (Figma SVG separate subpath)
/// Draws the tail as a second closed subpath that overlaps the body at the
/// bottom-near corner. Both subpaths are filled together in one `.fill()` call,
/// so the overlapping area has no visible seam.
///
/// Uses the exact Figma SVG path (viewBox 0 0 13.6216 33.3).
/// Raw SVG: straight edge at x5.6, tip protrudes LEFT to x=0.
/// The `dir` multiplier flips the protrusion direction for outgoing.
private func addTail(to p: inout Path, bodyRect: CGRect) {
// Figma SVG straight edge X defines the body attachment line
let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305
// Uniform scale: maps SVG protrusion (5.6 units) to screen protrusion
let sc = Self.tailProtrusion / svgStraightX
// Tail height in points
let tailH = svgMaxY * sc
let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX
let bottom = bodyRect.maxY
let top = bottom - tailH
// +1 = protrude RIGHT (outgoing), 1 = protrude LEFT (incoming)
let dir: CGFloat = outgoing ? 1 : -1
// Map raw Figma SVG coord screen coord
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
let dx = (svgStraightX - svgX) * sc * dir
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
}
// -- Exact Figma SVG path (from Figma API, viewBox 0 0 13.6216 33.3) --
// M5.59961 24.2305
// C5.42042 28.0524 3.19779 31.339 0 33.0244
// C0.851596 33.1596 1.72394 33.2305 2.6123 33.2305
// C6.53776 33.2305 10.1517 31.8599 13.0293 29.5596
// C10.7434 27.898 8.86922 25.7134 7.57422 23.1719
// C5.61235 19.3215 5.6123 14.281 5.6123 4.2002
// V0 H5.59961 V24.2305 Z
if outgoing {
// Forward order clockwise winding (matches body)
p.move(to: tp(5.59961, 24.2305))
p.addCurve(to: tp(0, 33.0244),
control1: tp(5.42042, 28.0524),
control2: tp(3.19779, 31.339))
p.addCurve(to: tp(2.6123, 33.2305),
control1: tp(0.851596, 33.1596),
control2: tp(1.72394, 33.2305))
p.addCurve(to: tp(13.0293, 29.5596),
control1: tp(6.53776, 33.2305),
control2: tp(10.1517, 31.8599))
p.addCurve(to: tp(7.57422, 23.1719),
control1: tp(10.7434, 27.898),
control2: tp(8.86922, 25.7134))
p.addCurve(to: tp(5.6123, 4.2002),
control1: tp(5.61235, 19.3215),
control2: tp(5.6123, 14.281))
p.addLine(to: tp(5.6123, 0))
p.addLine(to: tp(5.59961, 0))
p.addLine(to: tp(5.59961, 24.2305))
p.closeSubpath()
} else {
// Reversed order clockwise winding for incoming
// (mirroring X flips winding; reversing path order restores it)
p.move(to: tp(5.59961, 24.2305))
p.addLine(to: tp(5.59961, 0))
p.addLine(to: tp(5.6123, 0))
p.addLine(to: tp(5.6123, 4.2002))
// Curve 5 reversed (swap control points)
p.addCurve(to: tp(7.57422, 23.1719),
control1: tp(5.6123, 14.281),
control2: tp(5.61235, 19.3215))
// Curve 4 reversed
p.addCurve(to: tp(13.0293, 29.5596),
control1: tp(8.86922, 25.7134),
control2: tp(10.7434, 27.898))
// Curve 3 reversed
p.addCurve(to: tp(2.6123, 33.2305),
control1: tp(10.1517, 31.8599),
control2: tp(6.53776, 33.2305))
// Curve 2 reversed
p.addCurve(to: tp(0, 33.0244),
control1: tp(1.72394, 33.2305),
control2: tp(0.851596, 33.1596))
// Curve 1 reversed
p.addCurve(to: tp(5.59961, 24.2305),
control1: tp(3.19779, 31.339),
control2: tp(5.42042, 28.0524))
p.closeSubpath()
}
BubbleGeometryEngine.makeSwiftUIPath(
in: rect,
mergeType: mergeType,
outgoing: outgoing,
metrics: metrics
)
}
}

View File

@@ -127,11 +127,21 @@ struct ChatDetailView: View {
}
private var maxBubbleWidth: CGFloat {
let w = UIScreen.main.bounds.width
if w <= 500 {
return max(224, min(w * 0.72, w - 104))
}
return min(w * 0.66, 460)
let screenWidth = UIScreen.main.bounds.width
let listHorizontalInsets: CGFloat = 20 // NativeMessageList section insets: leading/trailing 10
let bubbleHorizontalMargins: CGFloat = 16 // 8pt left + 8pt right bubble lane reserves
let availableWidth = max(40, screenWidth - listHorizontalInsets - bubbleHorizontalMargins)
// Telegram ChatMessageItemWidthFill:
// compactInset = 36, compactWidthBoundary = 500, freeMaximumFillFactor = 0.85/0.65.
let compactInset: CGFloat = 36
let freeFillFactor: CGFloat = screenWidth > 680 ? 0.65 : 0.85
let widthByInset = availableWidth - compactInset
let widthByFactor = availableWidth * freeFillFactor
let width = min(widthByInset, widthByFactor)
return max(40, width)
}
/// Visual chat content: messages list + gradient overlays + background.
@@ -1096,8 +1106,8 @@ private extension ChatDetailView {
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
let parts = file.preview.components(separatedBy: "::")
if parts.count >= 3 { return parts[2] }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
@@ -1121,9 +1131,8 @@ private extension ChatDetailView {
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
// Parse filename from preview (tag::fileSize::fileName)
let parts = file.preview.components(separatedBy: "::")
if parts.count >= 3 { return parts[2] }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
@@ -1261,7 +1270,7 @@ private extension ChatDetailView {
for att in replyData.attachments {
if att.type == AttachmentType.image.rawValue {
// Image re-upload
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id) {
if let image = AttachmentCache.shared.cachedImage(forAttachmentId: att.id) {
// JPEG encoding (10-50ms) off main thread
let jpegData = await Task.detached(priority: .userInitiated) {
image.jpegData(compressionQuality: 0.85)
@@ -1269,14 +1278,35 @@ private extension ChatDetailView {
if let jpegData {
forwardedImages[att.id] = jpegData
#if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
print("📤 Image \(att.id.prefix(16)): loaded from memory cache (\(jpegData.count) bytes)")
#endif
continue
}
}
// Slow path: disk I/O + decrypt off main thread.
await ImageLoadLimiter.shared.acquire()
let image = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: att.id)
}.value
await ImageLoadLimiter.shared.release()
if let image {
// JPEG encoding (10-50ms) off main thread
let jpegData = await Task.detached(priority: .userInitiated) {
image.jpegData(compressionQuality: 0.85)
}.value
if let jpegData {
forwardedImages[att.id] = jpegData
#if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from disk cache (\(jpegData.count) bytes)")
#endif
continue
}
}
// Not in cache download from CDN, decrypt, then include.
let cdnTag = att.preview.components(separatedBy: "::").first ?? ""
let cdnTag = AttachmentPreviewCodec.downloadTag(from: att.preview)
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
@@ -1333,8 +1363,11 @@ private extension ChatDetailView {
} else if att.type == AttachmentType.file.rawValue {
// File re-upload (Desktop parity: prepareAttachmentsToSend)
let parts = att.preview.components(separatedBy: "::")
let fileName = parts.count > 2 ? parts[2] : "file"
let parsedFile = AttachmentPreviewCodec.parseFilePreview(
att.preview,
fallbackFileName: "file"
)
let fileName = parsedFile.fileName
// Try local cache first
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
@@ -1346,7 +1379,7 @@ private extension ChatDetailView {
}
// Not in cache download from CDN, decrypt
let cdnTag = parts.first ?? ""
let cdnTag = parsedFile.downloadTag
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
@@ -1947,18 +1980,28 @@ struct ForwardedImagePreviewCell: View {
blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64)
}
// Check cache immediately image may already be there.
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
// Fast path: memory cache only (no disk/crypto on UI path).
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
// Retry: the original MessageImageView may still be downloading.
// Poll up to 5 times with 500ms intervals (2.5s total) covers most download durations.
// Slow path: one background disk/decrypt attempt.
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .utility) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
if let loaded, !Task.isCancelled {
cachedImage = loaded
return
}
// Retry memory cache only: original MessageImageView may still be downloading.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
@@ -1968,8 +2011,7 @@ struct ForwardedImagePreviewCell: View {
private func extractBlurHash() -> String? {
guard !attachment.preview.isEmpty else { return nil }
let parts = attachment.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : attachment.preview
let hash = AttachmentPreviewCodec.blurHash(from: attachment.preview)
return hash.isEmpty ? nil : hash
}
}
@@ -2003,16 +2045,28 @@ struct ReplyQuoteThumbnail: View {
}
}
.task {
// Check AttachmentCache for the actual downloaded photo.
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
// Fast path: memory cache only.
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
// Retry image may be downloading in MessageImageView.
// Slow path: one background disk/decrypt attempt.
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .utility) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
if let loaded, !Task.isCancelled {
cachedImage = loaded
return
}
// Retry memory cache only image may still be downloading in MessageImageView.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}

View File

@@ -169,21 +169,33 @@ struct FullScreenImageViewer: View {
struct FullScreenImageFromCache: View {
let attachmentId: String
let onDismiss: () -> Void
@State private var image: UIImage?
@State private var isLoading = true
var body: some View {
if let image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) {
if let image {
FullScreenImageViewer(image: image, onDismiss: onDismiss)
} else {
// Cache miss show error with close button
// Cache miss/loading state show placeholder with close button.
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 16) {
Image(systemName: "photo")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Image not available")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.5))
if isLoading {
VStack(spacing: 16) {
ProgressView()
.tint(.white)
Text("Loading...")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.5))
}
} else {
VStack(spacing: 16) {
Image(systemName: "photo")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
Text("Image not available")
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.5))
}
}
VStack {
HStack {
@@ -204,6 +216,21 @@ struct FullScreenImageFromCache: View {
Spacer()
}
}
.task(id: attachmentId) {
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
image = cached
isLoading = false
return
}
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}.value
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }
image = loaded
isLoading = false
}
}
}
}

View File

@@ -325,7 +325,13 @@ struct ImageGalleryViewer: View {
for offset in [-2, -1, 1, 2] {
let i = index + offset
guard i >= 0, i < state.images.count else { continue }
_ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId)
let attachmentId = state.images[i].attachmentId
guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue }
Task.detached(priority: .utility) {
await ImageLoadLimiter.shared.acquire()
_ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
await ImageLoadLimiter.shared.release()
}
}
}

View File

@@ -0,0 +1,160 @@
import UIKit
enum MediaBubbleCornerMaskFactory {
private static let mainRadius: CGFloat = 16
private static let mergedRadius: CGFloat = 8
private static let inset: CGFloat = 2
static func containerMask(
bounds: CGRect,
mergeType: BubbleMergeType,
outgoing: Bool
) -> CAShapeLayer {
let radii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing)
return makeMaskLayer(
in: bounds,
topLeft: radii.topLeft,
topRight: radii.topRight,
bottomLeft: radii.bottomLeft,
bottomRight: radii.bottomRight
)
}
static func tileMask(
tileFrame: CGRect,
containerBounds: CGRect,
mergeType: BubbleMergeType,
outgoing: Bool
) -> CAShapeLayer? {
let eps: CGFloat = 0.5
let touchesLeft = abs(tileFrame.minX - containerBounds.minX) <= eps
let touchesRight = abs(tileFrame.maxX - containerBounds.maxX) <= eps
let touchesTop = abs(tileFrame.minY - containerBounds.minY) <= eps
let touchesBottom = abs(tileFrame.maxY - containerBounds.maxY) <= eps
guard touchesLeft || touchesRight || touchesTop || touchesBottom else {
return nil
}
let containerRadii = mediaCornerRadii(mergeType: mergeType, outgoing: outgoing)
let tl = (touchesTop && touchesLeft) ? containerRadii.topLeft : 0
let tr = (touchesTop && touchesRight) ? containerRadii.topRight : 0
let bl = (touchesBottom && touchesLeft) ? containerRadii.bottomLeft : 0
let br = (touchesBottom && touchesRight) ? containerRadii.bottomRight : 0
let local = CGRect(origin: .zero, size: tileFrame.size)
return makeMaskLayer(
in: local,
topLeft: tl,
topRight: tr,
bottomLeft: bl,
bottomRight: br
)
}
private static func mediaCornerRadii(
mergeType: BubbleMergeType,
outgoing: Bool
) -> (topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
let metrics = BubbleMetrics(
mainRadius: mainRadius,
auxiliaryRadius: mergedRadius,
tailProtrusion: 6,
defaultSpacing: 0,
mergedSpacing: 0,
textInsets: .zero,
mediaStatusInsets: .zero
)
let base = BubbleGeometryEngine.cornerRadii(
mergeType: mergeType,
outgoing: outgoing,
metrics: metrics
)
var adjusted = base
if BubbleGeometryEngine.hasTail(for: mergeType) {
if outgoing {
adjusted.bottomRight = min(adjusted.bottomRight, mergedRadius)
} else {
adjusted.bottomLeft = min(adjusted.bottomLeft, mergedRadius)
}
}
return (
topLeft: max(adjusted.topLeft - inset, 0),
topRight: max(adjusted.topRight - inset, 0),
bottomLeft: max(adjusted.bottomLeft - inset, 0),
bottomRight: max(adjusted.bottomRight - inset, 0)
)
}
private static func makeMaskLayer(
in rect: CGRect,
topLeft: CGFloat,
topRight: CGFloat,
bottomLeft: CGFloat,
bottomRight: CGFloat
) -> CAShapeLayer {
let path = roundedPath(
in: rect,
topLeft: topLeft,
topRight: topRight,
bottomLeft: bottomLeft,
bottomRight: bottomRight
)
let mask = CAShapeLayer()
mask.frame = rect
mask.path = path.cgPath
return mask
}
private static func roundedPath(
in rect: CGRect,
topLeft: CGFloat,
topRight: CGFloat,
bottomLeft: CGFloat,
bottomRight: CGFloat
) -> UIBezierPath {
let maxRadius = min(rect.width, rect.height) / 2
let cTL = min(topLeft, maxRadius)
let cTR = min(topRight, maxRadius)
let cBL = min(bottomLeft, maxRadius)
let cBR = min(bottomRight, maxRadius)
let path = UIBezierPath()
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
path.addArc(
withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
radius: cTR,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
path.addArc(
withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
radius: cBR,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
path.addArc(
withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
radius: cBL,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
path.addArc(
withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
radius: cTL,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
path.close()
return path
}
}

View File

@@ -193,15 +193,14 @@ struct MessageAvatarView: View {
/// Extracts the blurhash from preview string.
/// Format: "tag::blurhash" returns "blurhash".
private func extractBlurHash(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.count > 1 ? parts[1] : ""
AttachmentPreviewCodec.blurHash(from: preview)
}
// MARK: - Download
private func loadFromCache() async {
// Fast path: NSCache hit (synchronous, sub-microsecond)
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
avatarImage = cached
showAvatar = true // No animation for cached show immediately
return
@@ -322,7 +321,6 @@ struct MessageAvatarView: View {
/// Extracts the server tag from preview string.
/// Format: "tag::blurhash" returns "tag".
private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
AttachmentPreviewCodec.downloadTag(from: preview)
}
}

View File

@@ -172,8 +172,8 @@ struct MessageCellView: View, Equatable {
if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
if let file = fileAttachments.first {
let parts = file.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
@@ -581,8 +581,7 @@ struct MessageCellView: View, Equatable {
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
let parts = att.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : att.preview
let hash = AttachmentPreviewCodec.blurHash(from: att.preview)
return hash.isEmpty ? nil : hash
}()
@@ -628,8 +627,8 @@ struct MessageCellView: View, Equatable {
@ViewBuilder
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
let filename: String = {
let parts = attachment.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return attachment.id.isEmpty ? "File" : attachment.id
}()
HStack(spacing: 8) {

View File

@@ -93,11 +93,8 @@ struct MessageFileView: View {
/// Parses "tag::filesize::filename" preview format.
private var fileMetadata: (tag: String, size: Int, name: String) {
let parts = attachment.preview.components(separatedBy: "::")
let tag = parts.first ?? ""
let size = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
let name = parts.count > 2 ? parts[2] : "file"
return (tag, size, name)
let parsed = AttachmentPreviewCodec.parseFilePreview(attachment.preview)
return (parsed.downloadTag, parsed.fileSize, parsed.fileName)
}
private var fileName: String { fileMetadata.name }

View File

@@ -230,8 +230,8 @@ struct MessageImageView: View {
private func loadFromCache() async {
PerformanceLogger.shared.track("image.cacheLoad")
// Fast path: NSCache hit (synchronous, sub-microsecond)
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
// Fast path: memory-only NSCache hit (no disk/crypto on main thread).
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
image = cached
return
}
@@ -331,12 +331,10 @@ struct MessageImageView: View {
// MARK: - Preview Parsing
private func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
AttachmentPreviewCodec.downloadTag(from: preview)
}
private func extractBlurHash(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.count > 1 ? parts[1] : ""
AttachmentPreviewCodec.blurHash(from: preview)
}
}

View File

@@ -23,110 +23,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
private static let statusBubbleInsets = UIEdgeInsets(top: 3, left: 7, bottom: 3, right: 7)
private static let bubbleMetrics = BubbleMetrics.telegram()
private static let statusBubbleInsets = bubbleMetrics.mediaStatusInsets
private static let sendingClockAnimationKey = "clockFrameAnimation"
// MARK: - Telegram Check Images (CGContext ported from PresentationThemeEssentialGraphics.swift)
/// Telegram-exact checkmark image via CGContext stroke.
/// `partial: true` single arm (/), `partial: false` full V ().
/// Canvas: 11-unit coordinate space scaled to `width` pt.
private static func generateTelegramCheck(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? {
let height = floor(width * 9.0 / 11.0)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
return renderer.image { ctx in
let gc = ctx.cgContext
// Keep UIKit default Y-down coordinates; Telegram check path points
// are already authored for this orientation in our renderer.
gc.clear(CGRect(x: 0, y: 0, width: width, height: height))
gc.scaleBy(x: width / 11.0, y: width / 11.0)
gc.translateBy(x: 1.0, y: 1.0)
gc.setStrokeColor(color.cgColor)
gc.setLineWidth(0.99)
gc.setLineCap(.round)
gc.setLineJoin(.round)
if partial {
// Single arm: bottom-left top-right diagonal
gc.move(to: CGPoint(x: 0.5, y: 7))
gc.addLine(to: CGPoint(x: 7, y: 0))
} else {
// Full V: left bottom-center (rounded tip) top-right
gc.move(to: CGPoint(x: 0, y: 4))
gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047))
gc.addCurve(to: CGPoint(x: 3.04490857, y: 6.95157047),
control1: CGPoint(x: 2.97734507, y: 6.97734507),
control2: CGPoint(x: 3.01913396, y: 6.97734507))
gc.addCurve(to: CGPoint(x: 3.04660389, y: 6.9498112),
control1: CGPoint(x: 3.04548448, y: 6.95099456),
control2: CGPoint(x: 3.04604969, y: 6.95040803))
gc.addLine(to: CGPoint(x: 9.5, y: 0))
}
gc.strokePath()
}
}
/// Telegram-exact clock frame image.
private static func generateTelegramClockFrame(color: UIColor) -> UIImage? {
let size = CGSize(width: 11, height: 11)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
// Telegram uses `generateImage(contextGenerator:)` (non-rotated context).
// Flip UIKit context to the same Y-up coordinate space.
gc.translateBy(x: 0, y: size.height)
gc.scaleBy(x: 1, y: -1)
gc.clear(CGRect(origin: .zero, size: size))
gc.setStrokeColor(color.cgColor)
gc.setFillColor(color.cgColor)
gc.setLineWidth(1.0)
gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10))
gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5))
}
}
/// Telegram-exact clock minute/hour image.
private static func generateTelegramClockMin(color: UIColor) -> UIImage? {
let size = CGSize(width: 11, height: 11)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
// Match Telegram's non-rotated drawing context coordinates.
gc.translateBy(x: 0, y: size.height)
gc.scaleBy(x: 1, y: -1)
gc.clear(CGRect(origin: .zero, size: size))
gc.setFillColor(color.cgColor)
gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0))
}
}
/// Error indicator (circle with exclamation mark).
private static func generateErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? {
let size = CGSize(width: width, height: width)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
gc.scaleBy(x: width / 11.0, y: width / 11.0)
gc.setFillColor(color.cgColor)
gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0))
gc.setFillColor(UIColor.white.cgColor)
gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25))
gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5))
}
}
// Pre-rendered images (cached at class load Telegram caches in PrincipalThemeEssentialGraphics)
private static let outgoingCheckColor = UIColor.white
private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5)
private static let mediaMetaColor = UIColor.white
private static let fullCheckImage = generateTelegramCheck(partial: false, color: outgoingCheckColor)
private static let partialCheckImage = generateTelegramCheck(partial: true, color: outgoingCheckColor)
private static let clockFrameImage = generateTelegramClockFrame(color: outgoingClockColor)
private static let clockMinImage = generateTelegramClockMin(color: outgoingClockColor)
private static let mediaFullCheckImage = generateTelegramCheck(partial: false, color: mediaMetaColor)
private static let mediaPartialCheckImage = generateTelegramCheck(partial: true, color: mediaMetaColor)
private static let mediaClockFrameImage = generateTelegramClockFrame(color: mediaMetaColor)
private static let mediaClockMinImage = generateTelegramClockMin(color: mediaMetaColor)
private static let errorIcon = generateErrorIcon(color: .systemRed)
private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor)
private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor)
private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor)
private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor)
private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor)
private static let mediaPartialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: mediaMetaColor)
private static let mediaClockFrameImage = StatusIconRenderer.makeClockFrameImage(color: mediaMetaColor)
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
@@ -197,6 +110,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private var totalPhotoAttachmentCount = 0
private var photoLoadTasks: [String: Task<Void, Never>] = [:]
private var photoDownloadTasks: [String: Task<Void, Never>] = [:]
private var photoBlurHashTasks: [String: Task<Void, Never>] = [:]
private var downloadingAttachmentIds: Set<String> = []
private var failedAttachmentIds: Set<String> = []
@@ -394,7 +308,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
self.actions = actions
let isOutgoing = currentLayout?.isOutgoing ?? false
let isMediaStatus = currentLayout?.messageType == .photo
let isMediaStatus: Bool = {
guard let type = currentLayout?.messageType else { return false }
return type == .photo || type == .photoWithCaption
}()
// Text use cached CoreTextTextLayout from measurement phase.
// Same CTTypesetter pipeline identical line breaks, zero recomputation.
@@ -482,7 +399,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
if let layout = currentLayout, layout.hasFile {
fileContainer.isHidden = false
let fileAtt = message.attachments.first { $0.type == .file }
fileNameLabel.text = fileAtt?.preview.components(separatedBy: "::").last ?? "File"
if let fileAtt {
fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName
} else {
fileNameLabel.text = "File"
}
fileSizeLabel.text = ""
} else {
fileContainer.isHidden = true
@@ -502,14 +423,15 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
guard let layout = currentLayout else { return }
let cellW = contentView.bounds.width
let tailW: CGFloat = layout.hasTail ? 6 : 0
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0
// Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
let bubbleX: CGFloat
if layout.isOutgoing {
bubbleX = cellW - layout.bubbleSize.width - 6 - 2 - layout.deliveryFailedInset
bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset
} else {
bubbleX = 6 + 2
bubbleX = tailProtrusion + 2
}
bubbleView.frame = CGRect(
@@ -522,17 +444,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
if layout.hasTail {
if layout.isOutgoing {
shapeRect = CGRect(x: 0, y: 0,
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
} else {
shapeRect = CGRect(x: -6, y: 0,
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
shapeRect = CGRect(x: -tailProtrusion, y: 0,
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
}
} else {
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
}
bubbleLayer.path = BubblePathCache.shared.path(
size: shapeRect.size, origin: shapeRect.origin,
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail
mergeType: layout.mergeType,
isOutgoing: layout.isOutgoing,
metrics: Self.bubbleMetrics
)
bubbleLayer.shadowPath = bubbleLayer.path
bubbleOutlineLayer.frame = bubbleView.bounds
@@ -569,6 +493,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
checkReadView.frame = layout.checkReadFrame
clockFrameView.frame = layout.clockFrame
clockMinView.frame = layout.clockFrame
#if DEBUG
assertStatusLaneFramesValid(layout: layout)
#endif
// Telegram-style date/status pill on media-only bubbles.
updateStatusBackgroundFrame()
@@ -734,12 +661,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
let attachment = photoAttachments[sender.tag]
if AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) != nil {
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id)
return
}
downloadPhotoAttachment(attachment: attachment, message: message)
Task { [weak self] in
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.message?.id == message.id else {
return
}
if loaded != nil {
actions.onImageTap(attachment.id)
} else {
self.downloadPhotoAttachment(attachment: attachment, message: message)
}
}
}
}
private func configurePhoto(for message: ChatMessage) {
@@ -767,6 +712,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
downloadingAttachmentIds.remove(attachmentId)
failedAttachmentIds.remove(attachmentId)
}
for (attachmentId, task) in photoBlurHashTasks where !activeIds.contains(attachmentId) {
task.cancel()
photoBlurHashTasks.removeValue(forKey: attachmentId)
}
for index in 0..<photoTileImageViews.count {
let isActiveTile = index < photoAttachments.count
@@ -789,7 +738,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
let attachment = photoAttachments[index]
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
failedAttachmentIds.remove(attachment.id)
setPhotoTileImage(cached, at: index, animated: false)
placeholderView.isHidden = true
@@ -797,7 +746,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
indicator.isHidden = true
errorView.isHidden = true
} else {
setPhotoTileImage(Self.blurHashImage(from: attachment.preview), at: index, animated: false)
if let blur = Self.cachedBlurHashImage(from: attachment.preview) {
setPhotoTileImage(blur, at: index, animated: false)
} else {
setPhotoTileImage(nil, at: index, animated: false)
startPhotoBlurHashTask(attachment: attachment)
}
placeholderView.isHidden = imageView.image != nil
let hasFailed = failedAttachmentIds.contains(attachment.id)
if hasFailed {
@@ -823,8 +777,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
private func layoutPhotoTiles() {
guard !photoAttachments.isEmpty else { return }
updatePhotoContainerMask()
guard let layout = currentLayout, !photoAttachments.isEmpty else { return }
updatePhotoContainerMask(layout: layout)
let frames = Self.photoTileFrames(count: photoAttachments.count, in: photoContainer.bounds)
for (index, frame) in frames.enumerated() where index < photoTileImageViews.count {
photoTileImageViews[index].frame = frame
@@ -845,58 +799,61 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoContainer.bringSubviewToFront(photoUploadingIndicator)
photoContainer.bringSubviewToFront(photoOverflowOverlayView)
layoutPhotoOverflowOverlay(frames: frames)
applyPhotoLastTileMask(frames: frames, layout: layout)
}
private func updatePhotoContainerMask() {
guard let layout = currentLayout else {
private func updatePhotoContainerMask(layout: MessageCellLayout? = nil) {
guard let layout = layout ?? currentLayout else {
photoContainer.layer.mask = nil
return
}
photoContainer.layer.mask = MediaBubbleCornerMaskFactory.containerMask(
bounds: photoContainer.bounds,
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
)
}
let inset: CGFloat = 2
let r: CGFloat = max(16 - inset, 0)
let s: CGFloat = max(5 - inset, 0)
let tailJoin: CGFloat = max(10 - inset, 0)
let rect = photoContainer.bounds
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch layout.position {
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 .mid: return layout.isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom:
return layout.isOutgoing
? (r, s, r, tailJoin)
: (s, r, tailJoin, r)
}
}()
private func applyPhotoLastTileMask(frames: [CGRect], layout: MessageCellLayout) {
guard !frames.isEmpty else { return }
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
// Reset per-tile masks first.
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].layer.mask = nil
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
let path = UIBezierPath()
path.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
path.addArc(withCenter: CGPoint(x: rect.maxX - cTR, y: rect.minY + cTR),
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
path.addArc(withCenter: CGPoint(x: rect.maxX - cBR, y: rect.maxY - cBR),
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
path.addArc(withCenter: CGPoint(x: rect.minX + cBL, y: rect.maxY - cBL),
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
path.addArc(withCenter: CGPoint(x: rect.minX + cTL, y: rect.minY + cTL),
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
path.close()
let lastVisibleIndex = photoAttachments.count - 1
guard lastVisibleIndex >= 0, lastVisibleIndex < frames.count else { return }
let tileFrame = frames[lastVisibleIndex]
guard let prototypeMask = MediaBubbleCornerMaskFactory.tileMask(
tileFrame: tileFrame,
containerBounds: photoContainer.bounds,
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
) else {
return
}
applyMaskPrototype(prototypeMask, to: photoTileImageViews[lastVisibleIndex])
applyMaskPrototype(prototypeMask, to: photoTilePlaceholderViews[lastVisibleIndex])
applyMaskPrototype(prototypeMask, to: photoTileButtons[lastVisibleIndex])
// Keep overflow badge clipping aligned with the same rounded corner.
applyMaskPrototype(prototypeMask, to: photoOverflowOverlayView)
}
private func applyMaskPrototype(_ prototype: CAShapeLayer, to view: UIView) {
guard let path = prototype.path else {
view.layer.mask = nil
return
}
let mask = CAShapeLayer()
mask.frame = rect
mask.path = path.cgPath
photoContainer.layer.mask = mask
mask.frame = CGRect(origin: .zero, size: view.bounds.size)
mask.path = path
view.layer.mask = mask
}
private static func photoTileFrames(count: Int, in bounds: CGRect) -> [CGRect] {
@@ -1032,6 +989,41 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoAttachments.firstIndex(where: { $0.id == attachmentId })
}
private func startPhotoBlurHashTask(attachment: MessageAttachment) {
let attachmentId = attachment.id
guard photoBlurHashTasks[attachmentId] == nil else { return }
let hash = Self.extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return }
if let cached = Self.blurHashCache.object(forKey: hash as NSString) {
if let tileIndex = tileIndex(for: attachmentId), tileIndex < photoTileImageViews.count {
setPhotoTileImage(cached, at: tileIndex, animated: false)
photoTilePlaceholderViews[tileIndex].isHidden = true
}
return
}
photoBlurHashTasks[attachmentId] = Task { [weak self] in
let decoded = await Task.detached(priority: .utility) {
UIImage.fromBlurHash(hash, width: 48, height: 48)
}.value
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.photoBlurHashTasks.removeValue(forKey: attachmentId)
guard let decoded,
let tileIndex = self.tileIndex(for: attachmentId),
tileIndex < self.photoTileImageViews.count else {
return
}
Self.blurHashCache.setObject(decoded, forKey: hash as NSString)
// Do not override already loaded real image.
guard self.photoTileImageViews[tileIndex].image == nil else { return }
self.setPhotoTileImage(decoded, at: tileIndex, animated: false)
self.photoTilePlaceholderViews[tileIndex].isHidden = true
}
}
}
private func startPhotoLoadTask(attachment: MessageAttachment) {
if photoLoadTasks[attachment.id] != nil { return }
let attachmentId = attachment.id
@@ -1140,6 +1132,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
task.cancel()
}
photoDownloadTasks.removeAll()
for task in photoBlurHashTasks.values {
task.cancel()
}
photoBlurHashTasks.removeAll()
downloadingAttachmentIds.removeAll()
failedAttachmentIds.removeAll()
photoContainer.layer.mask = nil
@@ -1151,35 +1147,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
for index in 0..<photoTileImageViews.count {
photoTileImageViews[index].image = nil
photoTileImageViews[index].isHidden = true
photoTileImageViews[index].layer.mask = nil
photoTilePlaceholderViews[index].isHidden = true
photoTilePlaceholderViews[index].layer.mask = nil
photoTileActivityIndicators[index].stopAnimating()
photoTileActivityIndicators[index].isHidden = true
photoTileErrorViews[index].isHidden = true
photoTileButtons[index].isHidden = true
photoTileButtons[index].layer.mask = nil
}
photoOverflowOverlayView.layer.mask = nil
}
private static func extractTag(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.first ?? preview
AttachmentPreviewCodec.downloadTag(from: preview)
}
private static func extractBlurHash(from preview: String) -> String {
let parts = preview.components(separatedBy: "::")
return parts.count > 1 ? parts[1] : ""
AttachmentPreviewCodec.blurHash(from: preview)
}
private static func blurHashImage(from preview: String) -> UIImage? {
private static func cachedBlurHashImage(from preview: String) -> UIImage? {
let hash = extractBlurHash(from: preview)
guard !hash.isEmpty else { return nil }
if let cached = blurHashCache.object(forKey: hash as NSString) {
return cached
}
guard let image = UIImage.fromBlurHash(hash, width: 48, height: 48) else {
return nil
}
blurHashCache.setObject(image, forKey: hash as NSString)
return image
return blurHashCache.object(forKey: hash as NSString)
}
private static func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
@@ -1243,14 +1234,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func animateCheckAppearanceIfNeeded(isSentVisible: Bool, isReadVisible: Bool) {
if isSentVisible && !wasSentCheckVisible {
checkSentView.alpha = 0
checkSentView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
checkSentView.alpha = 1
checkSentView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
UIView.animate(
withDuration: 0.16,
withDuration: 0.1,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkSentView.alpha = 1
self.checkSentView.transform = .identity
}
} else if !isSentVisible {
@@ -1259,14 +1249,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
if isReadVisible && !wasReadCheckVisible {
checkReadView.alpha = 0
checkReadView.transform = CGAffineTransform(translationX: 2, y: 0).scaledBy(x: 0.9, y: 0.9)
checkReadView.alpha = 1
checkReadView.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
UIView.animate(
withDuration: 0.16,
delay: 0.02,
withDuration: 0.1,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState]
) {
self.checkReadView.alpha = 1
self.checkReadView.transform = .identity
}
} else if !isReadVisible {
@@ -1312,6 +1301,30 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
bubbleView.bringSubviewToFront(clockMinView)
}
#if DEBUG
private func assertStatusLaneFramesValid(layout: MessageCellLayout) {
let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize)
let frames = [
("timestamp", layout.timestampFrame),
("checkSent", layout.checkSentFrame),
("checkRead", layout.checkReadFrame),
("clock", layout.clockFrame)
]
for (name, frame) in frames {
assert(frame.origin.x.isFinite && frame.origin.y.isFinite
&& frame.size.width.isFinite && frame.size.height.isFinite,
"Status frame \(name) has non-finite values: \(frame)")
assert(frame.width >= 0 && frame.height >= 0,
"Status frame \(name) has negative size: \(frame)")
guard !frame.isEmpty else { continue }
let insetBounds = bubbleBounds.insetBy(dx: -1.0, dy: -1.0)
assert(insetBounds.contains(frame),
"Status frame \(name) is outside bubble bounds. frame=\(frame), bubble=\(bubbleBounds)")
}
}
#endif
// MARK: - Reuse
override func prepareForReuse() {
@@ -1369,18 +1382,35 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
final class BubblePathCache {
static let shared = BubblePathCache()
private let pathVersion = 8
private let pathVersion = 9
private var cache: [String: CGPath] = [:]
func path(
size: CGSize, origin: CGPoint,
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
mergeType: BubbleMergeType,
isOutgoing: Bool,
metrics: BubbleMetrics
) -> CGPath {
let key = "v\(pathVersion)_\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
let key = [
"v\(pathVersion)",
"\(Int(size.width))x\(Int(size.height))",
"ox\(Int(origin.x))",
"oy\(Int(origin.y))",
"\(mergeType)",
"\(isOutgoing)",
"r\(Int(metrics.mainRadius))",
"m\(Int(metrics.auxiliaryRadius))",
"t\(Int(metrics.tailProtrusion))",
].joined(separator: "_")
if let cached = cache[key] { return cached }
let rect = CGRect(origin: origin, size: size)
let path = makeBubblePath(in: rect, position: position, isOutgoing: isOutgoing, hasTail: hasTail)
let path = BubbleGeometryEngine.makeCGPath(
in: rect,
mergeType: mergeType,
outgoing: isOutgoing,
metrics: metrics
)
cache[key] = path
// Evict if cache grows too large
@@ -1390,101 +1420,4 @@ final class BubblePathCache {
return path
}
private func makeBubblePath(
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath {
let r: CGFloat = 16, s: CGFloat = 5, tailW: CGFloat = 6
// Body rect
let bodyRect: CGRect
if hasTail {
bodyRect = isOutgoing
? CGRect(x: rect.minX, y: rect.minY, width: rect.width - tailW, height: rect.height)
: CGRect(x: rect.minX + tailW, y: rect.minY, width: rect.width - tailW, height: rect.height)
} else {
bodyRect = rect
}
// Corner radii
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch position {
case .single: return (r, r, r, r)
case .top: return isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom: return isOutgoing ? (r, s, r, r) : (s, r, r, r)
}
}()
let maxR = min(bodyRect.width, bodyRect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
let path = CGMutablePath()
// Rounded rect body
path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY))
path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY))
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY),
tangent2End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY + cTR), radius: cTR)
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY),
tangent2End: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY), radius: cBR)
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY),
tangent2End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - cBL), radius: cBL)
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.minY),
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
path.closeSubpath()
// Stable Figma tail (previous behavior)
if hasTail {
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
}
return path
}
/// Figma SVG tail path (stable shape used before recent experiments).
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305
let scale: CGFloat = 6.0 / svgStraightX
let tailH = svgMaxY * scale
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
let bottom = bodyRect.maxY
let top = bottom - tailH
let dir: CGFloat = isOutgoing ? 1 : -1
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
let dx = (svgStraightX - svgX) * scale * dir
return CGPoint(x: bodyEdge + dx, y: top + svgY * scale)
}
if isOutgoing {
path.move(to: tp(5.59961, 24.2305))
path.addCurve(to: tp(0, 33.0244), control1: tp(5.42042, 28.0524), control2: tp(3.19779, 31.339))
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(0.851596, 33.1596), control2: tp(1.72394, 33.2305))
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(6.53776, 33.2305), control2: tp(10.1517, 31.8599))
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(10.7434, 27.898), control2: tp(8.86922, 25.7134))
path.addCurve(to: tp(5.6123, 4.2002), control1: tp(5.61235, 19.3215), control2: tp(5.6123, 14.281))
path.addLine(to: tp(5.6123, 0))
path.addLine(to: tp(5.59961, 0))
path.addLine(to: tp(5.59961, 24.2305))
path.closeSubpath()
} else {
path.move(to: tp(5.59961, 24.2305))
path.addLine(to: tp(5.59961, 0))
path.addLine(to: tp(5.6123, 0))
path.addLine(to: tp(5.6123, 4.2002))
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(5.6123, 14.281), control2: tp(5.61235, 19.3215))
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(8.86922, 25.7134), control2: tp(10.7434, 27.898))
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(10.1517, 31.8599), control2: tp(6.53776, 33.2305))
path.addCurve(to: tp(0, 33.0244), control1: tp(1.72394, 33.2305), control2: tp(0.851596, 33.1596))
path.addCurve(to: tp(5.59961, 24.2305), control1: tp(3.19779, 31.339), control2: tp(5.42042, 28.0524))
path.closeSubpath()
}
}
}

View File

@@ -13,6 +13,15 @@ import UIKit
@MainActor
final class NativeMessageListController: UIViewController {
private enum UIConstants {
static let messageToComposerGap: CGFloat = 16
static let scrollButtonSize: CGFloat = 40
static let scrollButtonIconCanvas: CGFloat = 38
static let scrollButtonBaseTrailing: CGFloat = 8
static let scrollButtonCompactExtraTrailing: CGFloat = 18
static let scrollButtonBottomOffset: CGFloat = 20
}
// MARK: - Configuration
struct Config {
@@ -73,6 +82,11 @@ final class NativeMessageListController: UIViewController {
// MARK: - Scroll-to-Bottom Button
private var scrollToBottomButton: UIButton?
private var scrollToBottomButtonContainer: UIView?
private var scrollToBottomTrailingConstraint: NSLayoutConstraint?
private var scrollToBottomBottomConstraint: NSLayoutConstraint?
private var scrollToBottomBadgeView: UIView?
private var scrollToBottomBadgeLabel: UILabel?
/// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback.
private var lastReportedAtBottom: Bool = true
@@ -142,6 +156,7 @@ final class NativeMessageListController: UIViewController {
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
applyInsets()
updateScrollToBottomButtonConstraints()
// Update composer bottom when keyboard is hidden
if currentKeyboardHeight == 0 {
composerBottomConstraint?.constant = -view.safeAreaInsets.bottom
@@ -327,7 +342,7 @@ final class NativeMessageListController: UIViewController {
// MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer)
private func setupScrollToBottomButton(above composer: UIView) {
let size: CGFloat = 42
let size = UIConstants.scrollButtonSize
let rect = CGRect(x: 0, y: 0, width: size, height: size)
// Container: Auto Layout positions it, clipsToBounds prevents overflow.
@@ -339,41 +354,66 @@ final class NativeMessageListController: UIViewController {
container.isUserInteractionEnabled = true
view.addSubview(container)
let trailing = container.trailingAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: -UIConstants.scrollButtonBaseTrailing
)
let bottom = container.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor,
constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
)
NSLayoutConstraint.activate([
container.widthAnchor.constraint(equalToConstant: size),
container.heightAnchor.constraint(equalToConstant: size),
container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
container.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -76),
trailing,
bottom,
])
scrollToBottomTrailingConstraint = trailing
scrollToBottomBottomConstraint = bottom
scrollToBottomButtonContainer = container
// Button: hardcoded 42×42 frame. NO UIView.transform scale is done
// Button: hardcoded 40×40 frame. NO UIView.transform scale is done
// at CALayer level so UIKit never recalculates bounds through the
// transform matrix during interactive keyboard dismiss.
let button = UIButton(type: .custom)
button.frame = rect
button.clipsToBounds = true
button.alpha = 0
button.layer.transform = CATransform3DMakeScale(0.01, 0.01, 1.0)
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
button.layer.allowsEdgeAntialiasing = true
container.addSubview(button)
// Glass circle background: hardcoded 42×42 frame, no autoresizingMask.
// Glass circle background: hardcoded 40×40 frame, no autoresizingMask.
let glass = TelegramGlassUIView(frame: rect)
glass.isCircle = true
glass.isUserInteractionEnabled = false
button.addSubview(glass)
// Chevron down icon: hardcoded 42×42 frame, centered contentMode.
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
let chevron = UIImage(systemName: "chevron.down", withConfiguration: config)
let imageView = UIImageView(image: chevron)
imageView.tintColor = .white
// Telegram-style down icon (canvas 38×38, line width 1.5).
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
imageView.contentMode = .center
imageView.frame = rect
button.addSubview(imageView)
let badgeView = UIView(frame: .zero)
badgeView.backgroundColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1)
badgeView.layer.cornerCurve = .continuous
badgeView.layer.cornerRadius = 10
badgeView.isHidden = true
button.addSubview(badgeView)
scrollToBottomBadgeView = badgeView
let badgeLabel = UILabel(frame: .zero)
badgeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
badgeLabel.textColor = .white
badgeLabel.textAlignment = .center
badgeView.addSubview(badgeLabel)
scrollToBottomBadgeLabel = badgeLabel
button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside)
scrollToBottomButton = button
updateScrollToBottomButtonConstraints()
updateScrollToBottomBadge()
}
@objc private func scrollToBottomTapped() {
@@ -382,7 +422,7 @@ final class NativeMessageListController: UIViewController {
}
/// Show/hide the scroll-to-bottom button with CALayer-level scaling.
/// UIView.bounds stays 42×42 at ALL times only rendered pixels scale.
/// UIView.bounds stays 40×40 at ALL times only rendered pixels scale.
/// No UIView.transform, no layoutIfNeeded completely bypasses the
/// Auto Layout transform race condition during interactive dismiss.
func setScrollToBottomVisible(_ visible: Bool) {
@@ -390,13 +430,79 @@ final class NativeMessageListController: UIViewController {
let isCurrentlyVisible = button.alpha > 0.5
guard visible != isCurrentlyVisible else { return }
UIView.animate(withDuration: visible ? 0.25 : 0.2, delay: 0,
usingSpringWithDamping: 0.8, initialSpringVelocity: 0,
UIView.animate(withDuration: 0.3, delay: 0,
usingSpringWithDamping: 0.82, initialSpringVelocity: 0,
options: .beginFromCurrentState) {
button.alpha = visible ? 1 : 0
button.layer.transform = visible
? CATransform3DIdentity
: CATransform3DMakeScale(0.01, 0.01, 1.0)
: CATransform3DMakeScale(0.2, 0.2, 1.0)
}
updateScrollToBottomBadge()
}
private func updateScrollToBottomButtonConstraints() {
let safeBottom = view.safeAreaInsets.bottom
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
}
private func updateScrollToBottomBadge() {
guard let badgeView = scrollToBottomBadgeView,
let badgeLabel = scrollToBottomBadgeLabel else {
return
}
let unreadCount = DialogRepository.shared.dialogs[config.opponentPublicKey]?.unreadCount ?? 0
guard unreadCount > 0 else {
badgeView.isHidden = true
badgeLabel.text = nil
return
}
let badgeText = Self.compactUnreadCountString(unreadCount)
badgeLabel.text = badgeText
let badgeFont = badgeLabel.font ?? UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
let textWidth = ceil((badgeText as NSString).size(withAttributes: [.font: badgeFont]).width)
let badgeWidth = max(20, textWidth + 11)
let badgeFrame = CGRect(
x: floor((UIConstants.scrollButtonSize - badgeWidth) / 2),
y: -7,
width: badgeWidth,
height: 20
)
badgeView.frame = badgeFrame
badgeView.layer.cornerRadius = badgeFrame.height * 0.5
badgeLabel.frame = badgeView.bounds
badgeView.isHidden = false
}
private static func makeTelegramDownButtonImage() -> UIImage? {
let size = CGSize(width: UIConstants.scrollButtonIconCanvas, height: UIConstants.scrollButtonIconCanvas)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
gc.clear(CGRect(origin: .zero, size: size))
gc.setStrokeColor(UIColor.white.cgColor)
gc.setLineWidth(1.5)
gc.setLineCap(.round)
gc.setLineJoin(.round)
let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
gc.strokePath()
}.withRenderingMode(.alwaysOriginal)
}
private static func compactUnreadCountString(_ count: Int) -> String {
if count >= 1_000_000 {
return "\(count / 1_000_000)M"
} else if count >= 1_000 {
return "\(count / 1_000)K"
} else {
return "\(count)"
}
}
@@ -425,6 +531,7 @@ final class NativeMessageListController: UIViewController {
}
dataSource.apply(snapshot, animatingDifferences: animated)
updateScrollToBottomBadge()
}
// MARK: - Layout Calculation (Telegram asyncLayout pattern)
@@ -473,10 +580,11 @@ final class NativeMessageListController: UIViewController {
/// would double the adjustment content teleports upward.
private func applyInsets() {
guard collectionView != nil else { return }
updateScrollToBottomButtonConstraints()
let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom)
let composerHeight = lastComposerHeight
let newInsetTop = composerHeight + composerBottom
let newInsetTop = composerHeight + composerBottom + UIConstants.messageToComposerGap
let topInset = view.safeAreaInsets.top + 6
let oldInsetTop = collectionView.contentInset.top
@@ -496,6 +604,7 @@ final class NativeMessageListController: UIViewController {
if shouldCompensate {
collectionView.contentOffset.y = oldOffset - delta
}
updateScrollToBottomBadge()
}
/// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
@@ -588,7 +697,7 @@ final class NativeMessageListController: UIViewController {
// Telegram pattern: animate composer position + content insets in ONE block.
// Explicit composerHeightConstraint prevents the 372pt inflation bug.
let composerH = lastComposerHeight
let newInsetTop = composerH + composerBottom
let newInsetTop = composerH + composerBottom + UIConstants.messageToComposerGap
let topInset = view.safeAreaInsets.top + 6
let oldInsetTop = collectionView.contentInset.top
let delta = newInsetTop - oldInsetTop
@@ -655,6 +764,7 @@ extension NativeMessageListController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
let isAtBottom = offsetFromBottom < 50
updateScrollToBottomBadge()
// Dedup only fire when value actually changes.
// Without this, callback fires 60fps during keyboard animation

View File

@@ -0,0 +1,81 @@
import UIKit
enum StatusIconRenderer {
static func makeCheckImage(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? {
let height = floor(width * 9.0 / 11.0)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height))
return renderer.image { ctx in
let gc = ctx.cgContext
gc.clear(CGRect(x: 0, y: 0, width: width, height: height))
gc.scaleBy(x: width / 11.0, y: width / 11.0)
gc.translateBy(x: 1.0, y: 1.0)
gc.setStrokeColor(color.cgColor)
gc.setLineWidth(0.99)
gc.setLineCap(.round)
gc.setLineJoin(.round)
if partial {
gc.move(to: CGPoint(x: 0.5, y: 7))
gc.addLine(to: CGPoint(x: 7, y: 0))
} else {
gc.move(to: CGPoint(x: 0, y: 4))
gc.addLine(to: CGPoint(x: 2.95157047, y: 6.95157047))
gc.addCurve(
to: CGPoint(x: 3.04490857, y: 6.95157047),
control1: CGPoint(x: 2.97734507, y: 6.97734507),
control2: CGPoint(x: 3.01913396, y: 6.97734507)
)
gc.addCurve(
to: CGPoint(x: 3.04660389, y: 6.9498112),
control1: CGPoint(x: 3.04548448, y: 6.95099456),
control2: CGPoint(x: 3.04604969, y: 6.95040803)
)
gc.addLine(to: CGPoint(x: 9.5, y: 0))
}
gc.strokePath()
}
}
static func makeClockFrameImage(color: UIColor) -> UIImage? {
let size = CGSize(width: 11, height: 11)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
gc.translateBy(x: 0, y: size.height)
gc.scaleBy(x: 1, y: -1)
gc.clear(CGRect(origin: .zero, size: size))
gc.setStrokeColor(color.cgColor)
gc.setFillColor(color.cgColor)
gc.setLineWidth(1.0)
gc.strokeEllipse(in: CGRect(x: 0.5, y: 0.5, width: 10, height: 10))
gc.fill(CGRect(x: 5.0, y: 3.0, width: 1.0, height: 2.5))
}
}
static func makeClockMinImage(color: UIColor) -> UIImage? {
let size = CGSize(width: 11, height: 11)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
gc.translateBy(x: 0, y: size.height)
gc.scaleBy(x: 1, y: -1)
gc.clear(CGRect(origin: .zero, size: size))
gc.setFillColor(color.cgColor)
gc.fill(CGRect(x: 5.0, y: 5.0, width: 4.5, height: 1.0))
}
}
static func makeErrorIcon(color: UIColor, width: CGFloat = 20) -> UIImage? {
let size = CGSize(width: width, height: width)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let gc = ctx.cgContext
gc.scaleBy(x: width / 11.0, y: width / 11.0)
gc.setFillColor(color.cgColor)
gc.fillEllipse(in: CGRect(x: 0.0, y: 0.0, width: 11.0, height: 11.0))
gc.setFillColor(UIColor.white.cgColor)
gc.fill(CGRect(x: 5.0, y: 2.5, width: 1.0, height: 4.25))
gc.fillEllipse(in: CGRect(x: 4.75, y: 7.8, width: 1.5, height: 1.5))
}
}
}

View File

@@ -106,7 +106,18 @@ struct ZoomableImagePage: View {
}
}
.task {
image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
image = cached
return
}
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .utility) {
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}.value
await ImageLoadLimiter.shared.release()
if !Task.isCancelled {
image = loaded
}
}
}

View File

@@ -24,27 +24,17 @@ struct ChatListSearchContent: View {
private extension ChatListSearchContent {
/// Desktop-parity: skeleton empty results only one visible at a time.
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
/// to avoid @Published re-render cascade through ChatListView.
/// Uses unified search policy from ChatListViewModel callback merge.
@ViewBuilder
var activeSearchContent: some View {
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
// Local results: match by username ONLY (desktop parity server matches usernames)
let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in
!query.isEmpty && dialog.opponentUsername.lowercased().contains(query)
}
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
}
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
let hasAnyResult = !viewModel.serverSearchResults.isEmpty
if viewModel.isServerSearching && !hasAnyResult {
SearchSkeletonView()
} else if !viewModel.isServerSearching && !hasAnyResult {
noResultsState
} else {
resultsList(localResults: localResults, serverOnly: serverOnly)
resultsList(results: viewModel.serverSearchResults)
}
}
@@ -68,23 +58,14 @@ private extension ChatListSearchContent {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable list of local dialogs + server results.
/// Scrollable list of merged search results.
/// Shows skeleton rows at the bottom while server is still searching.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
func resultsList(results: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(localResults) { dialog in
Button {
onOpenDialog(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
}
.buttonStyle(.plain)
}
ForEach(serverOnly, id: \.publicKey) { user in
ForEach(results, id: \.publicKey) { user in
serverUserRow(user)
if user.publicKey != serverOnly.last?.publicKey {
if user.publicKey != results.last?.publicKey {
Divider()
.padding(.leading, 76)
.foregroundStyle(RosettaColors.Adaptive.divider)

View File

@@ -30,10 +30,12 @@ final class ChatListViewModel: ObservableObject {
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
private let recentRepository = RecentSearchesRepository.shared
private let searchDispatcher: SearchResultDispatching
// MARK: - Init
init() {
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
self.searchDispatcher = searchDispatcher
configureRecentSearches()
setupSearchCallback()
}
@@ -107,7 +109,7 @@ final class ChatListViewModel: ObservableObject {
// MARK: - Actions
func setSearchQuery(_ query: String) {
searchQuery = normalizeSearchInput(query)
searchQuery = SearchParityPolicy.sanitizeInput(query)
triggerServerSearch()
}
@@ -132,11 +134,11 @@ final class ChatListViewModel: ObservableObject {
private func setupSearchCallback() {
if let token = searchHandlerToken {
ProtocolManager.shared.removeSearchResultHandler(token)
searchDispatcher.removeSearchResultHandler(token)
}
Self.logger.debug("Setting up search callback")
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.logger.debug("Search callback: self is nil")
@@ -147,7 +149,16 @@ final class ChatListViewModel: ObservableObject {
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
self.serverSearchResults = packet.users
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
let localMatches = SearchParityPolicy.localAugmentedUsers(
query: query,
currentPublicKey: SessionManager.shared.currentPublicKey,
dialogs: Array(DialogRepository.shared.dialogs.values)
)
self.serverSearchResults = SearchParityPolicy.mergeServerAndLocal(
server: packet.users,
local: localMatches
)
self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
for user in packet.users {
@@ -169,7 +180,7 @@ final class ChatListViewModel: ObservableObject {
searchRetryTask?.cancel()
searchRetryTask = nil
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
let trimmed = SearchParityPolicy.normalizedQuery(searchQuery)
if trimmed.isEmpty {
// Guard: only publish if value actually changes (avoids extra re-renders)
if !serverSearchResults.isEmpty { serverSearchResults = [] }
@@ -184,7 +195,7 @@ final class ChatListViewModel: ObservableObject {
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
self.sendSearchPacket(query: currentQuery)
@@ -193,8 +204,8 @@ final class ChatListViewModel: ObservableObject {
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
private func sendSearchPacket(query: String) {
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
let connState = searchDispatcher.connectionState
let hash = SessionManager.shared.privateKeyHash ?? searchDispatcher.privateHash
guard connState == .authenticated, let hash else {
// Not authenticated wait for reconnect then send
@@ -205,9 +216,9 @@ final class ChatListViewModel: ObservableObject {
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(500))
guard let self, !Task.isCancelled else { return }
let current = self.searchQuery.trimmingCharacters(in: .whitespaces)
let current = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard current == query else { return } // Query changed, abort
if ProtocolManager.shared.connectionState == .authenticated {
if self.searchDispatcher.connectionState == .authenticated {
Self.logger.debug("Connection restored — sending pending search")
self.sendSearchPacket(query: query)
return
@@ -223,14 +234,9 @@ final class ChatListViewModel: ObservableObject {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = query.lowercased()
packet.search = query
Self.logger.debug("📤 Sending search packet for '\(query)'")
ProtocolManager.shared.sendPacket(packet)
}
private func normalizeSearchInput(_ input: String) -> String {
input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
searchDispatcher.sendSearchPacket(packet)
}
// MARK: - Recent Searches

View File

@@ -30,10 +30,12 @@ final class SearchViewModel: ObservableObject {
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
private let recentRepository = RecentSearchesRepository.shared
private let searchDispatcher: SearchResultDispatching
// MARK: - Init
init() {
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
self.searchDispatcher = searchDispatcher
configureRecentSearches()
setupSearchCallback()
}
@@ -41,7 +43,7 @@ final class SearchViewModel: ObservableObject {
// MARK: - Search Logic
func setSearchQuery(_ query: String) {
searchQuery = normalizeSearchInput(query)
searchQuery = SearchParityPolicy.sanitizeInput(query)
onSearchQueryChanged()
}
@@ -49,15 +51,15 @@ final class SearchViewModel: ObservableObject {
searchTask?.cancel()
searchTask = nil
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
let normalized = SearchParityPolicy.normalizedQuery(searchQuery)
if normalized.isEmpty {
searchResults = []
isSearching = false
lastSearchedText = ""
return
}
if trimmed == lastSearchedText {
if normalized == lastSearchedText {
return
}
@@ -71,13 +73,13 @@ final class SearchViewModel: ObservableObject {
return
}
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else {
let currentQuery = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard !currentQuery.isEmpty, currentQuery == normalized else {
return
}
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
let connState = self.searchDispatcher.connectionState
let hash = SessionManager.shared.privateKeyHash ?? self.searchDispatcher.privateHash
guard connState == .authenticated, let hash else {
self.isSearching = false
@@ -88,9 +90,9 @@ final class SearchViewModel: ObservableObject {
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery.lowercased()
packet.search = currentQuery
ProtocolManager.shared.sendPacket(packet)
self.searchDispatcher.sendSearchPacket(packet)
}
}
@@ -107,30 +109,27 @@ final class SearchViewModel: ObservableObject {
private func setupSearchCallback() {
if let token = searchHandlerToken {
ProtocolManager.shared.removeSearchResultHandler(token)
searchDispatcher.removeSearchResultHandler(token)
}
searchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
searchHandlerToken = searchDispatcher.addSearchResultHandler { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let query = self.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let query = SearchParityPolicy.normalizedQuery(self.searchQuery)
guard !query.isEmpty else {
self.isSearching = false
return
}
// Merge server results with client-side public key matches.
// Server only matches by username; public key matching is local
// (same approach as Android).
var merged = packet.users
let serverKeys = Set(merged.map(\.publicKey))
let localMatches = self.findLocalPublicKeyMatches(query: query)
for match in localMatches where !serverKeys.contains(match.publicKey) {
merged.append(match)
}
self.searchResults = merged
let localMatches = SearchParityPolicy.localAugmentedUsers(
query: query,
currentPublicKey: SessionManager.shared.currentPublicKey,
dialogs: Array(DialogRepository.shared.dialogs.values)
)
self.searchResults = SearchParityPolicy.mergeServerAndLocal(
server: packet.users,
local: localMatches
)
self.isSearching = false
// Update dialog info from server results
@@ -147,57 +146,6 @@ final class SearchViewModel: ObservableObject {
}
}
// MARK: - Client-Side Public Key Matching
/// Matches the query against local dialogs' public keys and the user's own
/// key (Saved Messages). The server only searches by username, so public
/// key look-ups must happen on the client (matches Android behaviour).
private func findLocalPublicKeyMatches(query: String) -> [SearchUser] {
let normalized = query.lowercased().replacingOccurrences(of: "0x", with: "")
// Only treat as a public key search when every character is hex
guard !normalized.isEmpty, normalized.allSatisfy(\.isHexDigit) else {
return []
}
var results: [SearchUser] = []
// Check own public key Saved Messages
let ownKey = SessionManager.shared.currentPublicKey.lowercased().replacingOccurrences(of: "0x", with: "")
if ownKey.hasPrefix(normalized) || ownKey == normalized {
results.append(SearchUser(
username: "",
title: "Saved Messages",
publicKey: SessionManager.shared.currentPublicKey,
verified: 0,
online: 0
))
}
// Check local dialogs
for dialog in DialogRepository.shared.dialogs.values {
let dialogKey = dialog.opponentKey.lowercased().replacingOccurrences(of: "0x", with: "")
guard dialogKey.hasPrefix(normalized) || dialogKey == normalized else { continue }
// Skip if it's our own key (already handled as Saved Messages)
guard dialog.opponentKey != SessionManager.shared.currentPublicKey else { continue }
results.append(SearchUser(
username: dialog.opponentUsername,
title: dialog.opponentTitle,
publicKey: dialog.opponentKey,
verified: dialog.verified,
online: dialog.isOnline ? 0 : 1
))
}
return results
}
private func normalizeSearchInput(_ input: String) -> String {
input.replacingOccurrences(of: "@", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Recent Searches
func addToRecent(_ user: SearchUser) {