Telegram-style date pills в чат-листе — sticky headers с push-переходом между секциями
This commit is contained in:
@@ -858,11 +858,23 @@ final class MessageRepository: ObservableObject {
|
||||
let plainText: String
|
||||
|
||||
if !privateKey.isEmpty {
|
||||
if let data = try? CryptoManager.shared.decryptWithPassword(record.text, password: privateKey),
|
||||
// Prefer requireCompression: true — encryptWithPassword always uses rawDeflate,
|
||||
// so decompression acts as verification. The uncompressed fallback (~1/256
|
||||
// false-positive chance with wrong key) can return AES garbage.
|
||||
if let data = try? CryptoManager.shared.decryptWithPassword(
|
||||
record.text, password: privateKey, requireCompression: true
|
||||
),
|
||||
let decrypted = String(data: data, encoding: .utf8) {
|
||||
plainText = decrypted
|
||||
} else if let data = try? CryptoManager.shared.decryptWithPassword(
|
||||
record.text, password: privateKey
|
||||
),
|
||||
let decrypted = String(data: data, encoding: .utf8),
|
||||
!Self.isProbablyEncryptedPayload(decrypted) {
|
||||
// Uncompressed fallback for legacy messages — but reject if result
|
||||
// looks like ciphertext (false-positive AES with wrong key).
|
||||
plainText = decrypted
|
||||
} else {
|
||||
// Android parity: safePlainMessageFallback() — return "" if ciphertext, raw if plaintext
|
||||
let fallback = Self.safePlainMessageFallback(record.text)
|
||||
#if DEBUG
|
||||
if !fallback.isEmpty {
|
||||
|
||||
@@ -67,6 +67,12 @@ struct MessageCellLayout: Sendable {
|
||||
let forwardAvatarFrame: CGRect
|
||||
let forwardNameFrame: CGRect
|
||||
|
||||
// MARK: - Date Header (optional)
|
||||
|
||||
let showsDateHeader: Bool
|
||||
let dateHeaderText: String
|
||||
let dateHeaderHeight: CGFloat
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum MessageType: Sendable {
|
||||
@@ -102,6 +108,8 @@ extension MessageCellLayout {
|
||||
let forwardImageCount: Int
|
||||
let forwardFileCount: Int
|
||||
let forwardCaption: String?
|
||||
let showsDateHeader: Bool
|
||||
let dateHeaderText: String
|
||||
}
|
||||
|
||||
private struct MediaDimensions {
|
||||
@@ -403,11 +411,14 @@ extension MessageCellLayout {
|
||||
// Stretchable bubble image min height
|
||||
bubbleH = max(bubbleH, 37)
|
||||
|
||||
let totalH = groupGap + bubbleH
|
||||
// Date header adds height above the bubble.
|
||||
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
||||
|
||||
let totalH = dateHeaderH + groupGap + bubbleH
|
||||
|
||||
// Bubble X (approximate — overridden in layoutSubviews with actual cellWidth)
|
||||
let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8
|
||||
let bubbleFrame = CGRect(x: bubbleX, y: groupGap, width: bubbleW, height: bubbleH)
|
||||
let bubbleFrame = CGRect(x: bubbleX, y: dateHeaderH + groupGap, width: bubbleW, height: bubbleH)
|
||||
|
||||
// ── STEP 5: Geometry assignment ──
|
||||
|
||||
@@ -565,7 +576,10 @@ extension MessageCellLayout {
|
||||
isForward: config.isForward,
|
||||
forwardHeaderFrame: fwdHeaderFrame,
|
||||
forwardAvatarFrame: fwdAvatarFrame,
|
||||
forwardNameFrame: fwdNameFrame
|
||||
forwardNameFrame: fwdNameFrame,
|
||||
showsDateHeader: config.showsDateHeader,
|
||||
dateHeaderText: config.dateHeaderText,
|
||||
dateHeaderHeight: dateHeaderH
|
||||
)
|
||||
return (layout, cachedTextLayout)
|
||||
}
|
||||
@@ -727,6 +741,13 @@ extension MessageCellLayout {
|
||||
return false
|
||||
}
|
||||
|
||||
// Break groups at day boundaries (date separator will appear between them).
|
||||
let msgDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
|
||||
let neighborDate = Date(timeIntervalSince1970: TimeInterval(neighbor.timestamp) / 1000)
|
||||
if !Calendar.current.isDate(msgDate, inSameDayAs: neighborDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Long gaps should split groups.
|
||||
if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
|
||||
return false
|
||||
@@ -769,11 +790,33 @@ extension MessageCellLayout {
|
||||
timestampFormatter.locale = .autoupdatingCurrent
|
||||
timestampFormatter.timeZone = .autoupdatingCurrent
|
||||
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let sameYearFormatter = DateFormatter()
|
||||
sameYearFormatter.dateFormat = "MMMM d"
|
||||
sameYearFormatter.locale = .autoupdatingCurrent
|
||||
let diffYearFormatter = DateFormatter()
|
||||
diffYearFormatter.dateFormat = "MMMM d, yyyy"
|
||||
diffYearFormatter.locale = .autoupdatingCurrent
|
||||
|
||||
for (index, message) in messages.enumerated() {
|
||||
let isOutgoing = message.fromPublicKey == currentPublicKey
|
||||
let timestampText = timestampFormatter.string(
|
||||
from: Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
|
||||
)
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
|
||||
let timestampText = timestampFormatter.string(from: messageDate)
|
||||
|
||||
// Date header: show on first message of each calendar day
|
||||
let showsDateHeader: Bool
|
||||
if index == 0 {
|
||||
showsDateHeader = true
|
||||
} else {
|
||||
let prevDate = Date(timeIntervalSince1970: TimeInterval(messages[index - 1].timestamp) / 1000)
|
||||
showsDateHeader = !calendar.isDate(messageDate, inSameDayAs: prevDate)
|
||||
}
|
||||
let dateHeaderText = showsDateHeader
|
||||
? Self.formatDateHeader(messageDate, now: now, calendar: calendar,
|
||||
sameYearFormatter: sameYearFormatter,
|
||||
diffYearFormatter: diffYearFormatter)
|
||||
: ""
|
||||
|
||||
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
|
||||
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||
@@ -843,7 +886,9 @@ extension MessageCellLayout {
|
||||
isForward: isForward,
|
||||
forwardImageCount: isForward ? images.count : 0,
|
||||
forwardFileCount: isForward ? files.count : 0,
|
||||
forwardCaption: nil
|
||||
forwardCaption: nil,
|
||||
showsDateHeader: showsDateHeader,
|
||||
dateHeaderText: dateHeaderText
|
||||
)
|
||||
|
||||
let (layout, textLayout) = calculate(config: config)
|
||||
@@ -855,6 +900,30 @@ extension MessageCellLayout {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Header Formatting (Thread-Safe)
|
||||
|
||||
extension MessageCellLayout {
|
||||
/// Telegram-style date header text: Today / Yesterday / March 8 / March 8, 2025
|
||||
static func formatDateHeader(
|
||||
_ date: Date,
|
||||
now: Date,
|
||||
calendar: Calendar,
|
||||
sameYearFormatter: DateFormatter,
|
||||
diffYearFormatter: DateFormatter
|
||||
) -> String {
|
||||
if calendar.isDateInToday(date) {
|
||||
return "Today"
|
||||
}
|
||||
if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
}
|
||||
if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
|
||||
return sameYearFormatter.string(from: date)
|
||||
}
|
||||
return diffYearFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry Helpers
|
||||
|
||||
private extension CGSize {
|
||||
|
||||
@@ -1506,6 +1506,9 @@ final class SessionManager {
|
||||
|
||||
guard let cryptoResult else {
|
||||
Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…")
|
||||
// Still recalculate dialog — cleans up stale ciphertext in lastMessage
|
||||
// that may persist from previous sessions or failed decryptions.
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
||||
return
|
||||
}
|
||||
let text = cryptoResult.text
|
||||
@@ -1804,9 +1807,15 @@ final class SessionManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let privateKeyHex, !packet.content.isEmpty else {
|
||||
guard let privateKeyHex else {
|
||||
return nil
|
||||
}
|
||||
// Allow empty content for messages with attachments (photo-only, call, etc.).
|
||||
// Normally content is always non-empty (XChaCha20 of "" still produces ciphertext),
|
||||
// but buggy senders or edge cases may send empty content with valid attachments.
|
||||
if packet.content.isEmpty {
|
||||
return ("", nil)
|
||||
}
|
||||
|
||||
// Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
|
||||
if isOwnMessage, !packet.aesChachaKey.isEmpty {
|
||||
|
||||
@@ -59,7 +59,7 @@ struct MessageCellView: View, Equatable {
|
||||
}
|
||||
.modifier(ConditionalSwipeToReply(
|
||||
enabled: !isSavedMessages && !isSystemAccount
|
||||
&& !message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }),
|
||||
&& !message.attachments.contains(where: { $0.type == .avatar }),
|
||||
onReply: { actions.onReply(message) }
|
||||
))
|
||||
.overlay {
|
||||
|
||||
@@ -77,6 +77,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
// MARK: - Subviews (always present, hidden when unused)
|
||||
|
||||
// Date separator header (Telegram-style centered pill)
|
||||
private let dateHeaderContainer = UIView()
|
||||
private let dateHeaderLabel = UILabel()
|
||||
|
||||
// Bubble — uses Telegram-exact stretchable image for the fill (raster tail)
|
||||
// + CAShapeLayer for shadow path (approximate, vector)
|
||||
private let bubbleView = UIView()
|
||||
@@ -100,6 +104,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private let replyBar = UIView()
|
||||
private let replyNameLabel = UILabel()
|
||||
private let replyTextLabel = UILabel()
|
||||
private var replyMessageId: String?
|
||||
|
||||
// Photo collage (up to 5 tiles)
|
||||
private let photoContainer = UIView()
|
||||
@@ -146,9 +151,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
private var message: ChatMessage?
|
||||
private var actions: MessageCellActions?
|
||||
private var currentLayout: MessageCellLayout?
|
||||
private(set) var currentLayout: MessageCellLayout?
|
||||
var isSavedMessages = false
|
||||
var isSystemAccount = false
|
||||
/// When true, the inline date header pill is hidden (floating sticky one covers it).
|
||||
var isInlineDateHeaderHidden = false
|
||||
private var isDeliveryFailedVisible = false
|
||||
private var wasSentCheckVisible = false
|
||||
private var wasReadCheckVisible = false
|
||||
@@ -180,6 +187,19 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
clipsToBounds = false
|
||||
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
|
||||
|
||||
// Date header pill (Telegram-style centered date separator with glass)
|
||||
dateHeaderContainer.clipsToBounds = false
|
||||
dateHeaderContainer.isHidden = true
|
||||
let inlineGlass = TelegramGlassUIView(frame: .zero)
|
||||
inlineGlass.isUserInteractionEnabled = false
|
||||
inlineGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
dateHeaderContainer.addSubview(inlineGlass)
|
||||
dateHeaderLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||
dateHeaderLabel.textColor = .white
|
||||
dateHeaderLabel.textAlignment = .center
|
||||
dateHeaderContainer.addSubview(dateHeaderLabel)
|
||||
contentView.addSubview(dateHeaderContainer)
|
||||
|
||||
// Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top
|
||||
bubbleLayer.fillColor = UIColor.clear.cgColor
|
||||
bubbleLayer.fillRule = .nonZero
|
||||
@@ -229,6 +249,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
replyTextLabel.font = Self.replyTextFont
|
||||
replyTextLabel.lineBreakMode = .byTruncatingTail
|
||||
replyContainer.addSubview(replyTextLabel)
|
||||
let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped))
|
||||
replyContainer.addGestureRecognizer(replyTap)
|
||||
replyContainer.isUserInteractionEnabled = true
|
||||
bubbleView.addSubview(replyContainer)
|
||||
|
||||
// Photo collage
|
||||
@@ -421,10 +444,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
actions: MessageCellActions,
|
||||
replyName: String? = nil,
|
||||
replyText: String? = nil,
|
||||
replyMessageId: String? = nil,
|
||||
forwardSenderName: String? = nil
|
||||
) {
|
||||
self.message = message
|
||||
self.actions = actions
|
||||
self.replyMessageId = replyMessageId
|
||||
|
||||
let isOutgoing = currentLayout?.isOutgoing ?? false
|
||||
let isMediaStatus: Bool = {
|
||||
@@ -684,6 +709,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
let cellW = contentView.bounds.width
|
||||
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
|
||||
|
||||
// Inline date pills are always hidden — floating overlay pills in
|
||||
// NativeMessageListController handle all date display + push mechanics.
|
||||
// Cell still reserves dateHeaderHeight for visual spacing between sections.
|
||||
dateHeaderContainer.isHidden = true
|
||||
|
||||
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
||||
let bubbleX: CGFloat
|
||||
if layout.isOutgoing {
|
||||
@@ -693,7 +723,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
|
||||
bubbleView.frame = CGRect(
|
||||
x: bubbleX, y: layout.groupGap,
|
||||
x: bubbleX, y: layout.bubbleFrame.minY,
|
||||
width: layout.bubbleSize.width, height: layout.bubbleSize.height
|
||||
)
|
||||
|
||||
@@ -1274,6 +1304,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func replyQuoteTapped() {
|
||||
guard let replyMessageId, let actions else { return }
|
||||
actions.onScrollToMessage(replyMessageId)
|
||||
}
|
||||
|
||||
@objc private func fileContainerTapped() {
|
||||
guard let message, let actions else { return }
|
||||
let isCallType = message.attachments.contains { $0.type == .call }
|
||||
@@ -1990,6 +2025,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
layer.removeAnimation(forKey: "insertionSlide")
|
||||
layer.removeAnimation(forKey: "insertionMove")
|
||||
contentView.layer.removeAnimation(forKey: "insertionAlpha")
|
||||
dateHeaderContainer.isHidden = true
|
||||
dateHeaderLabel.text = nil
|
||||
isInlineDateHeaderHidden = false
|
||||
message = nil
|
||||
actions = nil
|
||||
currentLayout = nil
|
||||
@@ -2013,6 +2051,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
statusBackgroundView.isHidden = true
|
||||
resetPhotoTiles()
|
||||
replyContainer.isHidden = true
|
||||
replyMessageId = nil
|
||||
fileContainer.isHidden = true
|
||||
callArrowView.isHidden = true
|
||||
callBackButton.isHidden = true
|
||||
|
||||
@@ -104,6 +104,11 @@ final class NativeMessageListController: UIViewController {
|
||||
/// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
|
||||
private var lastReportedAtBottom: Bool = true
|
||||
|
||||
// MARK: - Floating Date Headers (Telegram-style per-section pills)
|
||||
private var datePillPool: [(container: UIView, label: UILabel)] = []
|
||||
private var dateHideTimer: Timer?
|
||||
private var areDatePillsVisible = false
|
||||
|
||||
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
||||
private var emptyStateGuide: UILayoutGuide?
|
||||
@@ -136,6 +141,7 @@ final class NativeMessageListController: UIViewController {
|
||||
setupCollectionView()
|
||||
setupNativeCellRegistration()
|
||||
setupDataSource()
|
||||
setupFloatingDateHeader()
|
||||
|
||||
// Create pure UIKit composer after view hierarchy is ready.
|
||||
if shouldSetupComposer {
|
||||
@@ -188,6 +194,9 @@ final class NativeMessageListController: UIViewController {
|
||||
}
|
||||
// ComposerView reports height via delegate (composerHeightDidChange).
|
||||
// No polling needed — pure UIKit, no UIHostingController inflation bug.
|
||||
|
||||
// Date pills sit between collection view and composer (z-order).
|
||||
// Composer covers pills naturally — no bringSubviewToFront needed.
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
@@ -260,6 +269,7 @@ final class NativeMessageListController: UIViewController {
|
||||
let replyAtt = message.attachments.first { $0.type == .messages }
|
||||
var replyName: String?
|
||||
var replyText: String?
|
||||
var replyMessageId: String?
|
||||
var forwardSenderName: String?
|
||||
|
||||
if let att = replyAtt {
|
||||
@@ -287,6 +297,7 @@ final class NativeMessageListController: UIViewController {
|
||||
// Reply quote
|
||||
replyName = name
|
||||
replyText = first.message.isEmpty ? "Photo" : first.message
|
||||
replyMessageId = first.message_id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +311,7 @@ final class NativeMessageListController: UIViewController {
|
||||
actions: self.config.actions,
|
||||
replyName: replyName,
|
||||
replyText: replyText,
|
||||
replyMessageId: replyMessageId,
|
||||
forwardSenderName: forwardSenderName
|
||||
)
|
||||
}
|
||||
@@ -390,7 +402,7 @@ final class NativeMessageListController: UIViewController {
|
||||
let container = UIView(frame: .zero)
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.backgroundColor = .clear
|
||||
container.clipsToBounds = true
|
||||
container.clipsToBounds = false
|
||||
container.isUserInteractionEnabled = true
|
||||
view.addSubview(container)
|
||||
|
||||
@@ -417,7 +429,7 @@ final class NativeMessageListController: UIViewController {
|
||||
// transform matrix during interactive keyboard dismiss.
|
||||
let button = UIButton(type: .custom)
|
||||
button.frame = rect
|
||||
button.clipsToBounds = true
|
||||
button.clipsToBounds = false
|
||||
button.alpha = 0
|
||||
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
|
||||
button.layer.allowsEdgeAntialiasing = true
|
||||
@@ -548,6 +560,210 @@ final class NativeMessageListController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Floating Date Headers (Telegram-style per-section pills)
|
||||
|
||||
/// Create a reusable date pill overlay (glass + label).
|
||||
private func makeDatePill() -> (container: UIView, label: UILabel) {
|
||||
let c = UIView()
|
||||
c.alpha = 0
|
||||
c.isHidden = true
|
||||
c.isUserInteractionEnabled = false
|
||||
let glass = TelegramGlassUIView(frame: .zero)
|
||||
glass.isUserInteractionEnabled = false
|
||||
// NO autoresizingMask — frame set explicitly in updateFloatingDateHeader
|
||||
// to prevent 1-frame inflation when pool pill reuses with different text.
|
||||
glass.tag = 42 // marker to find glass subview later
|
||||
c.addSubview(glass)
|
||||
let l = UILabel()
|
||||
l.font = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||
l.textColor = .white
|
||||
l.textAlignment = .center
|
||||
c.addSubview(l)
|
||||
return (c, l)
|
||||
}
|
||||
|
||||
private func setupFloatingDateHeader() {
|
||||
// Pre-create pool of 4 pills (max visible date sections at once).
|
||||
for _ in 0..<4 {
|
||||
let pill = makeDatePill()
|
||||
view.addSubview(pill.container)
|
||||
datePillPool.append(pill)
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram-exact sticky date header positioning.
|
||||
///
|
||||
/// Core formula from Telegram ListView (`.bottom` stick direction):
|
||||
/// `headerY = min(max(sectionTopY, stickyY), sectionBottomY - pillH)`
|
||||
///
|
||||
/// This single formula handles natural position, sticky, AND push between
|
||||
/// adjacent sections — no separate "stuck" vs "approach" logic needed.
|
||||
private func updateFloatingDateHeader() {
|
||||
guard !messages.isEmpty, collectionView != nil else { return }
|
||||
|
||||
let pillH: CGFloat = 24
|
||||
let hPad: CGFloat = 7
|
||||
let stickyY = view.safeAreaInsets.top + 8
|
||||
|
||||
// 1. Group visible cells by date → section ranges in screen coords.
|
||||
struct DateSection {
|
||||
let text: String
|
||||
var topY: CGFloat // visual top of section (oldest msg = smallest Y)
|
||||
var bottomY: CGFloat // visual bottom of section (newest msg = largest Y)
|
||||
}
|
||||
|
||||
// Build date for each visible cell, collect section ranges.
|
||||
var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:]
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
for cell in collectionView.visibleCells {
|
||||
guard let nativeCell = cell as? NativeMessageCell,
|
||||
let layout = nativeCell.currentLayout else { continue }
|
||||
// Determine this cell's date text
|
||||
// Use the layout's dateHeaderText if available, else compute from message
|
||||
let cellFrame = collectionView.convert(cell.frame, to: view)
|
||||
|
||||
// We need the date text for this cell. Find its message.
|
||||
guard let ip = collectionView.indexPath(for: cell) else { continue }
|
||||
let msgIndex = messages.count - 1 - ip.item
|
||||
guard msgIndex >= 0, msgIndex < messages.count else { continue }
|
||||
let msgDate = Date(timeIntervalSince1970: TimeInterval(messages[msgIndex].timestamp) / 1000)
|
||||
let dateText = Self.formatDateText(msgDate)
|
||||
|
||||
if var existing = sectionMap[dateText] {
|
||||
existing.topY = min(existing.topY, cellFrame.minY)
|
||||
existing.bottomY = max(existing.bottomY, cellFrame.maxY)
|
||||
sectionMap[dateText] = existing
|
||||
} else {
|
||||
sectionMap[dateText] = (cellFrame.minY, cellFrame.maxY)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort sections by topY (top to bottom on screen).
|
||||
let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) }
|
||||
.sorted { $0.topY < $1.topY }
|
||||
|
||||
// 2. Position each section's pill using Telegram's formula.
|
||||
var usedPillCount = 0
|
||||
for section in sections {
|
||||
guard usedPillCount < datePillPool.count else { break }
|
||||
|
||||
// Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
|
||||
// +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
|
||||
let naturalY = section.topY + 9
|
||||
let headerY = min(max(naturalY, stickyY), section.bottomY - pillH)
|
||||
|
||||
// Is this pill stuck (clamped to stickyY) or at natural position?
|
||||
let isStuck = naturalY < stickyY && headerY <= stickyY
|
||||
|
||||
// Skip only if pill is completely above screen.
|
||||
if headerY + pillH < 0 { continue }
|
||||
|
||||
let pill = datePillPool[usedPillCount]
|
||||
|
||||
// Prevent resize animation when reusing pool pill with different text.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
pill.label.text = section.text
|
||||
pill.label.sizeToFit()
|
||||
let textW = ceil(pill.label.intrinsicContentSize.width)
|
||||
let pillW = textW + hPad * 2
|
||||
let screenW = UIScreen.main.bounds.width
|
||||
let pillFrame = CGRect(
|
||||
x: round((screenW - pillW) / 2), y: headerY,
|
||||
width: pillW, height: pillH
|
||||
)
|
||||
pill.container.frame = pillFrame
|
||||
pill.container.layer.cornerRadius = pillH / 2
|
||||
pill.label.frame = pill.container.bounds
|
||||
// Explicitly set glass frame (no autoresizingMask — prevents 1-frame inflation).
|
||||
if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
|
||||
glass.frame = pill.container.bounds
|
||||
glass.layoutIfNeeded()
|
||||
}
|
||||
pill.container.isHidden = false
|
||||
// Natural-position pills always visible. Stuck pills fade with timer.
|
||||
pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
|
||||
pill.container.tag = isStuck ? 1 : 0
|
||||
CATransaction.commit()
|
||||
|
||||
usedPillCount += 1
|
||||
}
|
||||
|
||||
// Hide unused pills.
|
||||
for i in usedPillCount..<datePillPool.count {
|
||||
datePillPool[i].container.isHidden = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if !sections.isEmpty {
|
||||
let desc = sections.enumerated().map { i, s in
|
||||
let y = min(max(s.topY + 6, stickyY), s.bottomY - pillH)
|
||||
return "\(s.text)@\(Int(y))[\(Int(s.topY))→\(Int(s.bottomY))]"
|
||||
}.joined(separator: " | ")
|
||||
print("📅 pills=\(usedPillCount) \(desc)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// 3. Show/hide with timer.
|
||||
if usedPillCount > 0 {
|
||||
showDatePills()
|
||||
}
|
||||
}
|
||||
|
||||
private static func formatDateText(_ date: Date) -> String {
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDateInToday(date) { return "Today" }
|
||||
if calendar.isDateInYesterday(date) { return "Yesterday" }
|
||||
let now = Date()
|
||||
if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
|
||||
return sameYearDateFormatter.string(from: date)
|
||||
}
|
||||
return diffYearDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func showDatePills() {
|
||||
dateHideTimer?.invalidate()
|
||||
if !areDatePillsVisible {
|
||||
areDatePillsVisible = true
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
for pill in self.datePillPool where !pill.container.isHidden {
|
||||
// tag=1 → stuck pill (fade in). tag=0 → natural (already alpha=1).
|
||||
if pill.container.tag == 1 { pill.container.alpha = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
dateHideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
|
||||
self?.hideDatePills()
|
||||
}
|
||||
}
|
||||
|
||||
private func hideDatePills() {
|
||||
guard areDatePillsVisible else { return }
|
||||
areDatePillsVisible = false
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
for pill in self.datePillPool where !pill.container.isHidden {
|
||||
// Only fade STUCK pills (tag=1). Natural pills stay visible.
|
||||
if pill.container.tag == 1 { pill.container.alpha = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let sameYearDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMMM d"
|
||||
f.locale = .autoupdatingCurrent
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let diffYearDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMMM d, yyyy"
|
||||
f.locale = .autoupdatingCurrent
|
||||
return f
|
||||
}()
|
||||
|
||||
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||
|
||||
func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
|
||||
@@ -957,6 +1173,8 @@ final class NativeMessageListController: UIViewController {
|
||||
extension NativeMessageListController: UICollectionViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingDateHeader()
|
||||
|
||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
let isAtBottom = offsetFromBottom < 50
|
||||
updateScrollToBottomBadge()
|
||||
|
||||
@@ -21,10 +21,8 @@ enum TelegramContextMenuBuilder {
|
||||
) -> [TelegramContextMenuItem] {
|
||||
var items: [TelegramContextMenuItem] = []
|
||||
|
||||
let isAvatarOrForwarded = message.attachments.contains(where: {
|
||||
$0.type == .avatar || $0.type == .messages
|
||||
})
|
||||
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
|
||||
let isAvatar = message.attachments.contains(where: { $0.type == .avatar })
|
||||
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatar
|
||||
|
||||
if canReplyForward {
|
||||
items.append(TelegramContextMenuItem(
|
||||
|
||||
@@ -154,7 +154,13 @@ private extension ChatRowView {
|
||||
if isTyping && !dialog.isSavedMessages {
|
||||
return "typing..."
|
||||
}
|
||||
if dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty {
|
||||
return "No messages yet"
|
||||
}
|
||||
// Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user.
|
||||
// This catches stale data persisted before isGarbageText was improved.
|
||||
if Self.looksLikeCiphertext(raw) {
|
||||
return "No messages yet"
|
||||
}
|
||||
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
||||
@@ -170,6 +176,27 @@ private extension ChatRowView {
|
||||
Self.messageTextCache[dialog.lastMessage] = result
|
||||
return result
|
||||
}
|
||||
|
||||
/// Detects encrypted payload formats that should never be shown in UI.
|
||||
private static func looksLikeCiphertext(_ text: String) -> Bool {
|
||||
// CHNK: chunked format
|
||||
if text.hasPrefix("CHNK:") { return true }
|
||||
// ivBase64:ctBase64 or hex-encoded XChaCha20 ciphertext
|
||||
let parts = text.components(separatedBy: ":")
|
||||
if parts.count == 2 {
|
||||
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
|
||||
let bothBase64 = parts.allSatisfy { part in
|
||||
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
|
||||
}
|
||||
if bothBase64 { return true }
|
||||
}
|
||||
// Pure hex string (≥40 chars, only hex digits) — XChaCha20 wire format
|
||||
if text.count >= 40 {
|
||||
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
if text.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trailing Column
|
||||
|
||||
@@ -116,8 +116,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
|
||||
// MARK: Sender identification
|
||||
// Server sends `from` = sender public key (personal_message) or group ID (group_message).
|
||||
let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo)
|
||||
// Server sends `dialog` = sender public key (personal_message) or group ID (group_message).
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: userInfo)
|
||||
|
||||
// Resolve sender display name from App Group cache (synced by DialogRepository).
|
||||
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
||||
@@ -143,17 +144,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
|
||||
|
||||
// If server sent visible alert, NSE handles sound+badge. Just sync badge.
|
||||
// If muted, wake app but don't show notification.
|
||||
// If server sent visible alert, NSE handles sound+badge — don't double-count.
|
||||
// If muted, wake app but don't show notification (NSE also suppresses muted).
|
||||
if hasVisibleAlert || isMuted {
|
||||
if !isMuted {
|
||||
// Increment badge only for non-muted visible alerts.
|
||||
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
|
||||
let newBadge = currentBadge + 1
|
||||
shared?.set(newBadge, forKey: "app_badge_count")
|
||||
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
|
||||
UNUserNotificationCenter.current().setBadgeCount(newBadge)
|
||||
}
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
@@ -276,7 +269,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
userInfo: [AnyHashable: Any],
|
||||
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
let callerKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo)
|
||||
let callerKey = userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: userInfo)
|
||||
guard !callerKey.isEmpty else {
|
||||
completionHandler(.noData)
|
||||
return
|
||||
@@ -314,7 +308,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
/// this helper is a fallback for other contexts (notification tap, etc.).
|
||||
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
|
||||
firstNonBlank(userInfo, keys: [
|
||||
"from", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||
"dialog", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||
"public_key", "publicKey"
|
||||
]) ?? ""
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
private static let badgeKey = "app_badge_count"
|
||||
|
||||
/// Android parity: multiple key names for sender public key extraction.
|
||||
/// Server currently sends `from` field in data-only push.
|
||||
/// Server sends `dialog` field (was `from`). Both kept for backward compat.
|
||||
private static let senderKeyNames = [
|
||||
"from", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||
"dialog", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||
"public_key", "publicKey"
|
||||
]
|
||||
private static let senderNameKeyNames = [
|
||||
@@ -86,7 +86,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = "call"
|
||||
|
||||
let callerKey = content.userInfo["from"] as? String
|
||||
let callerKey = content.userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: content.userInfo)
|
||||
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
||||
let callerName = contactNames[callerKey]
|
||||
@@ -116,8 +116,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
// 1. Add sound for vibration — server APNs payload has no sound field.
|
||||
content.sound = .default
|
||||
|
||||
// 2. Extract sender key — server sends `from` field.
|
||||
let senderKey = content.userInfo["from"] as? String
|
||||
// 2. Extract sender key — server sends `dialog` field (was `from`).
|
||||
let senderKey = content.userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: content.userInfo)
|
||||
|
||||
// 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge.
|
||||
@@ -151,10 +151,10 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
|
||||
if let resolvedName, !resolvedName.isEmpty {
|
||||
updatedInfo["sender_name"] = resolvedName
|
||||
if content.title.isEmpty {
|
||||
// Always prefer local name — server sends title at push time,
|
||||
// but user may have a custom contact name in App Group cache.
|
||||
content.title = resolvedName
|
||||
}
|
||||
}
|
||||
content.userInfo = updatedInfo
|
||||
|
||||
// 7. Ensure notification category for CarPlay parity.
|
||||
|
||||
Reference in New Issue
Block a user