Паритет вложений и поиска на iOS (desktop/server/android), новые autotests и аудит
This commit is contained in:
@@ -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 x≈5.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
81
Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift
Normal file
81
Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user