Telegram-style date pills в чат-листе — sticky headers с push-переходом между секциями
This commit is contained in:
@@ -858,11 +858,23 @@ final class MessageRepository: ObservableObject {
|
|||||||
let plainText: String
|
let plainText: String
|
||||||
|
|
||||||
if !privateKey.isEmpty {
|
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) {
|
let decrypted = String(data: data, encoding: .utf8) {
|
||||||
plainText = decrypted
|
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 {
|
} else {
|
||||||
// Android parity: safePlainMessageFallback() — return "" if ciphertext, raw if plaintext
|
|
||||||
let fallback = Self.safePlainMessageFallback(record.text)
|
let fallback = Self.safePlainMessageFallback(record.text)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if !fallback.isEmpty {
|
if !fallback.isEmpty {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ struct MessageCellLayout: Sendable {
|
|||||||
let forwardAvatarFrame: CGRect
|
let forwardAvatarFrame: CGRect
|
||||||
let forwardNameFrame: CGRect
|
let forwardNameFrame: CGRect
|
||||||
|
|
||||||
|
// MARK: - Date Header (optional)
|
||||||
|
|
||||||
|
let showsDateHeader: Bool
|
||||||
|
let dateHeaderText: String
|
||||||
|
let dateHeaderHeight: CGFloat
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|
||||||
enum MessageType: Sendable {
|
enum MessageType: Sendable {
|
||||||
@@ -102,6 +108,8 @@ extension MessageCellLayout {
|
|||||||
let forwardImageCount: Int
|
let forwardImageCount: Int
|
||||||
let forwardFileCount: Int
|
let forwardFileCount: Int
|
||||||
let forwardCaption: String?
|
let forwardCaption: String?
|
||||||
|
let showsDateHeader: Bool
|
||||||
|
let dateHeaderText: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MediaDimensions {
|
private struct MediaDimensions {
|
||||||
@@ -403,11 +411,14 @@ extension MessageCellLayout {
|
|||||||
// Stretchable bubble image min height
|
// Stretchable bubble image min height
|
||||||
bubbleH = max(bubbleH, 37)
|
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)
|
// Bubble X (approximate — overridden in layoutSubviews with actual cellWidth)
|
||||||
let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8
|
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 ──
|
// ── STEP 5: Geometry assignment ──
|
||||||
|
|
||||||
@@ -565,7 +576,10 @@ extension MessageCellLayout {
|
|||||||
isForward: config.isForward,
|
isForward: config.isForward,
|
||||||
forwardHeaderFrame: fwdHeaderFrame,
|
forwardHeaderFrame: fwdHeaderFrame,
|
||||||
forwardAvatarFrame: fwdAvatarFrame,
|
forwardAvatarFrame: fwdAvatarFrame,
|
||||||
forwardNameFrame: fwdNameFrame
|
forwardNameFrame: fwdNameFrame,
|
||||||
|
showsDateHeader: config.showsDateHeader,
|
||||||
|
dateHeaderText: config.dateHeaderText,
|
||||||
|
dateHeaderHeight: dateHeaderH
|
||||||
)
|
)
|
||||||
return (layout, cachedTextLayout)
|
return (layout, cachedTextLayout)
|
||||||
}
|
}
|
||||||
@@ -727,6 +741,13 @@ extension MessageCellLayout {
|
|||||||
return false
|
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.
|
// Long gaps should split groups.
|
||||||
if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
|
if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
|
||||||
return false
|
return false
|
||||||
@@ -769,11 +790,33 @@ extension MessageCellLayout {
|
|||||||
timestampFormatter.locale = .autoupdatingCurrent
|
timestampFormatter.locale = .autoupdatingCurrent
|
||||||
timestampFormatter.timeZone = .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() {
|
for (index, message) in messages.enumerated() {
|
||||||
let isOutgoing = message.fromPublicKey == currentPublicKey
|
let isOutgoing = message.fromPublicKey == currentPublicKey
|
||||||
let timestampText = timestampFormatter.string(
|
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
|
||||||
from: 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)
|
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
|
||||||
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
|
||||||
@@ -843,7 +886,9 @@ extension MessageCellLayout {
|
|||||||
isForward: isForward,
|
isForward: isForward,
|
||||||
forwardImageCount: isForward ? images.count : 0,
|
forwardImageCount: isForward ? images.count : 0,
|
||||||
forwardFileCount: isForward ? files.count : 0,
|
forwardFileCount: isForward ? files.count : 0,
|
||||||
forwardCaption: nil
|
forwardCaption: nil,
|
||||||
|
showsDateHeader: showsDateHeader,
|
||||||
|
dateHeaderText: dateHeaderText
|
||||||
)
|
)
|
||||||
|
|
||||||
let (layout, textLayout) = calculate(config: config)
|
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
|
// MARK: - Geometry Helpers
|
||||||
|
|
||||||
private extension CGSize {
|
private extension CGSize {
|
||||||
|
|||||||
@@ -1506,6 +1506,9 @@ final class SessionManager {
|
|||||||
|
|
||||||
guard let cryptoResult else {
|
guard let cryptoResult else {
|
||||||
Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…")
|
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
|
return
|
||||||
}
|
}
|
||||||
let text = cryptoResult.text
|
let text = cryptoResult.text
|
||||||
@@ -1804,9 +1807,15 @@ final class SessionManager {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let privateKeyHex, !packet.content.isEmpty else {
|
guard let privateKeyHex else {
|
||||||
return nil
|
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).
|
// Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
|
||||||
if isOwnMessage, !packet.aesChachaKey.isEmpty {
|
if isOwnMessage, !packet.aesChachaKey.isEmpty {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ struct MessageCellView: View, Equatable {
|
|||||||
}
|
}
|
||||||
.modifier(ConditionalSwipeToReply(
|
.modifier(ConditionalSwipeToReply(
|
||||||
enabled: !isSavedMessages && !isSystemAccount
|
enabled: !isSavedMessages && !isSystemAccount
|
||||||
&& !message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }),
|
&& !message.attachments.contains(where: { $0.type == .avatar }),
|
||||||
onReply: { actions.onReply(message) }
|
onReply: { actions.onReply(message) }
|
||||||
))
|
))
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
// MARK: - Subviews (always present, hidden when unused)
|
// 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)
|
// Bubble — uses Telegram-exact stretchable image for the fill (raster tail)
|
||||||
// + CAShapeLayer for shadow path (approximate, vector)
|
// + CAShapeLayer for shadow path (approximate, vector)
|
||||||
private let bubbleView = UIView()
|
private let bubbleView = UIView()
|
||||||
@@ -100,6 +104,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
private let replyBar = UIView()
|
private let replyBar = UIView()
|
||||||
private let replyNameLabel = UILabel()
|
private let replyNameLabel = UILabel()
|
||||||
private let replyTextLabel = UILabel()
|
private let replyTextLabel = UILabel()
|
||||||
|
private var replyMessageId: String?
|
||||||
|
|
||||||
// Photo collage (up to 5 tiles)
|
// Photo collage (up to 5 tiles)
|
||||||
private let photoContainer = UIView()
|
private let photoContainer = UIView()
|
||||||
@@ -146,9 +151,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
private var message: ChatMessage?
|
private var message: ChatMessage?
|
||||||
private var actions: MessageCellActions?
|
private var actions: MessageCellActions?
|
||||||
private var currentLayout: MessageCellLayout?
|
private(set) var currentLayout: MessageCellLayout?
|
||||||
var isSavedMessages = false
|
var isSavedMessages = false
|
||||||
var isSystemAccount = 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 isDeliveryFailedVisible = false
|
||||||
private var wasSentCheckVisible = false
|
private var wasSentCheckVisible = false
|
||||||
private var wasReadCheckVisible = false
|
private var wasReadCheckVisible = false
|
||||||
@@ -180,6 +187,19 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
clipsToBounds = false
|
clipsToBounds = false
|
||||||
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
|
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
|
// Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top
|
||||||
bubbleLayer.fillColor = UIColor.clear.cgColor
|
bubbleLayer.fillColor = UIColor.clear.cgColor
|
||||||
bubbleLayer.fillRule = .nonZero
|
bubbleLayer.fillRule = .nonZero
|
||||||
@@ -229,6 +249,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
replyTextLabel.font = Self.replyTextFont
|
replyTextLabel.font = Self.replyTextFont
|
||||||
replyTextLabel.lineBreakMode = .byTruncatingTail
|
replyTextLabel.lineBreakMode = .byTruncatingTail
|
||||||
replyContainer.addSubview(replyTextLabel)
|
replyContainer.addSubview(replyTextLabel)
|
||||||
|
let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped))
|
||||||
|
replyContainer.addGestureRecognizer(replyTap)
|
||||||
|
replyContainer.isUserInteractionEnabled = true
|
||||||
bubbleView.addSubview(replyContainer)
|
bubbleView.addSubview(replyContainer)
|
||||||
|
|
||||||
// Photo collage
|
// Photo collage
|
||||||
@@ -421,10 +444,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
actions: MessageCellActions,
|
actions: MessageCellActions,
|
||||||
replyName: String? = nil,
|
replyName: String? = nil,
|
||||||
replyText: String? = nil,
|
replyText: String? = nil,
|
||||||
|
replyMessageId: String? = nil,
|
||||||
forwardSenderName: String? = nil
|
forwardSenderName: String? = nil
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
|
self.replyMessageId = replyMessageId
|
||||||
|
|
||||||
let isOutgoing = currentLayout?.isOutgoing ?? false
|
let isOutgoing = currentLayout?.isOutgoing ?? false
|
||||||
let isMediaStatus: Bool = {
|
let isMediaStatus: Bool = {
|
||||||
@@ -684,6 +709,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
let cellW = contentView.bounds.width
|
let cellW = contentView.bounds.width
|
||||||
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
|
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
|
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
|
||||||
let bubbleX: CGFloat
|
let bubbleX: CGFloat
|
||||||
if layout.isOutgoing {
|
if layout.isOutgoing {
|
||||||
@@ -693,7 +723,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bubbleView.frame = CGRect(
|
bubbleView.frame = CGRect(
|
||||||
x: bubbleX, y: layout.groupGap,
|
x: bubbleX, y: layout.bubbleFrame.minY,
|
||||||
width: layout.bubbleSize.width, height: layout.bubbleSize.height
|
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() {
|
@objc private func fileContainerTapped() {
|
||||||
guard let message, let actions else { return }
|
guard let message, let actions else { return }
|
||||||
let isCallType = message.attachments.contains { $0.type == .call }
|
let isCallType = message.attachments.contains { $0.type == .call }
|
||||||
@@ -1990,6 +2025,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
layer.removeAnimation(forKey: "insertionSlide")
|
layer.removeAnimation(forKey: "insertionSlide")
|
||||||
layer.removeAnimation(forKey: "insertionMove")
|
layer.removeAnimation(forKey: "insertionMove")
|
||||||
contentView.layer.removeAnimation(forKey: "insertionAlpha")
|
contentView.layer.removeAnimation(forKey: "insertionAlpha")
|
||||||
|
dateHeaderContainer.isHidden = true
|
||||||
|
dateHeaderLabel.text = nil
|
||||||
|
isInlineDateHeaderHidden = false
|
||||||
message = nil
|
message = nil
|
||||||
actions = nil
|
actions = nil
|
||||||
currentLayout = nil
|
currentLayout = nil
|
||||||
@@ -2013,6 +2051,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
statusBackgroundView.isHidden = true
|
statusBackgroundView.isHidden = true
|
||||||
resetPhotoTiles()
|
resetPhotoTiles()
|
||||||
replyContainer.isHidden = true
|
replyContainer.isHidden = true
|
||||||
|
replyMessageId = nil
|
||||||
fileContainer.isHidden = true
|
fileContainer.isHidden = true
|
||||||
callArrowView.isHidden = true
|
callArrowView.isHidden = true
|
||||||
callBackButton.isHidden = true
|
callBackButton.isHidden = true
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ final class NativeMessageListController: UIViewController {
|
|||||||
/// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
|
/// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback.
|
||||||
private var lastReportedAtBottom: Bool = true
|
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)
|
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||||
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
||||||
private var emptyStateGuide: UILayoutGuide?
|
private var emptyStateGuide: UILayoutGuide?
|
||||||
@@ -136,6 +141,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
setupCollectionView()
|
setupCollectionView()
|
||||||
setupNativeCellRegistration()
|
setupNativeCellRegistration()
|
||||||
setupDataSource()
|
setupDataSource()
|
||||||
|
setupFloatingDateHeader()
|
||||||
|
|
||||||
// Create pure UIKit composer after view hierarchy is ready.
|
// Create pure UIKit composer after view hierarchy is ready.
|
||||||
if shouldSetupComposer {
|
if shouldSetupComposer {
|
||||||
@@ -188,6 +194,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
// ComposerView reports height via delegate (composerHeightDidChange).
|
// ComposerView reports height via delegate (composerHeightDidChange).
|
||||||
// No polling needed — pure UIKit, no UIHostingController inflation bug.
|
// 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() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
@@ -260,6 +269,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
let replyAtt = message.attachments.first { $0.type == .messages }
|
let replyAtt = message.attachments.first { $0.type == .messages }
|
||||||
var replyName: String?
|
var replyName: String?
|
||||||
var replyText: String?
|
var replyText: String?
|
||||||
|
var replyMessageId: String?
|
||||||
var forwardSenderName: String?
|
var forwardSenderName: String?
|
||||||
|
|
||||||
if let att = replyAtt {
|
if let att = replyAtt {
|
||||||
@@ -287,6 +297,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// Reply quote
|
// Reply quote
|
||||||
replyName = name
|
replyName = name
|
||||||
replyText = first.message.isEmpty ? "Photo" : first.message
|
replyText = first.message.isEmpty ? "Photo" : first.message
|
||||||
|
replyMessageId = first.message_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +311,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
actions: self.config.actions,
|
actions: self.config.actions,
|
||||||
replyName: replyName,
|
replyName: replyName,
|
||||||
replyText: replyText,
|
replyText: replyText,
|
||||||
|
replyMessageId: replyMessageId,
|
||||||
forwardSenderName: forwardSenderName
|
forwardSenderName: forwardSenderName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -390,7 +402,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
let container = UIView(frame: .zero)
|
let container = UIView(frame: .zero)
|
||||||
container.translatesAutoresizingMaskIntoConstraints = false
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.backgroundColor = .clear
|
container.backgroundColor = .clear
|
||||||
container.clipsToBounds = true
|
container.clipsToBounds = false
|
||||||
container.isUserInteractionEnabled = true
|
container.isUserInteractionEnabled = true
|
||||||
view.addSubview(container)
|
view.addSubview(container)
|
||||||
|
|
||||||
@@ -417,7 +429,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// transform matrix during interactive keyboard dismiss.
|
// transform matrix during interactive keyboard dismiss.
|
||||||
let button = UIButton(type: .custom)
|
let button = UIButton(type: .custom)
|
||||||
button.frame = rect
|
button.frame = rect
|
||||||
button.clipsToBounds = true
|
button.clipsToBounds = false
|
||||||
button.alpha = 0
|
button.alpha = 0
|
||||||
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
|
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
|
||||||
button.layer.allowsEdgeAntialiasing = true
|
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)
|
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||||
|
|
||||||
func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
|
func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
|
||||||
@@ -957,6 +1173,8 @@ final class NativeMessageListController: UIViewController {
|
|||||||
extension NativeMessageListController: UICollectionViewDelegate {
|
extension NativeMessageListController: UICollectionViewDelegate {
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
updateFloatingDateHeader()
|
||||||
|
|
||||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||||
let isAtBottom = offsetFromBottom < 50
|
let isAtBottom = offsetFromBottom < 50
|
||||||
updateScrollToBottomBadge()
|
updateScrollToBottomBadge()
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ enum TelegramContextMenuBuilder {
|
|||||||
) -> [TelegramContextMenuItem] {
|
) -> [TelegramContextMenuItem] {
|
||||||
var items: [TelegramContextMenuItem] = []
|
var items: [TelegramContextMenuItem] = []
|
||||||
|
|
||||||
let isAvatarOrForwarded = message.attachments.contains(where: {
|
let isAvatar = message.attachments.contains(where: { $0.type == .avatar })
|
||||||
$0.type == .avatar || $0.type == .messages
|
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatar
|
||||||
})
|
|
||||||
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
|
|
||||||
|
|
||||||
if canReplyForward {
|
if canReplyForward {
|
||||||
items.append(TelegramContextMenuItem(
|
items.append(TelegramContextMenuItem(
|
||||||
|
|||||||
@@ -154,7 +154,13 @@ private extension ChatRowView {
|
|||||||
if isTyping && !dialog.isSavedMessages {
|
if isTyping && !dialog.isSavedMessages {
|
||||||
return "typing..."
|
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"
|
return "No messages yet"
|
||||||
}
|
}
|
||||||
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
if let cached = Self.messageTextCache[dialog.lastMessage] {
|
||||||
@@ -170,6 +176,27 @@ private extension ChatRowView {
|
|||||||
Self.messageTextCache[dialog.lastMessage] = result
|
Self.messageTextCache[dialog.lastMessage] = result
|
||||||
return 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
|
// MARK: - Trailing Column
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||||
|
|
||||||
// MARK: Sender identification
|
// MARK: Sender identification
|
||||||
// Server sends `from` = sender public key (personal_message) or group ID (group_message).
|
// Server sends `dialog` = sender public key (personal_message) or group ID (group_message).
|
||||||
let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo)
|
let senderKey = userInfo["dialog"] as? String
|
||||||
|
?? Self.extractSenderKey(from: userInfo)
|
||||||
|
|
||||||
// Resolve sender display name from App Group cache (synced by DialogRepository).
|
// Resolve sender display name from App Group cache (synced by DialogRepository).
|
||||||
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
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") ?? []
|
?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
|
||||||
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
|
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
|
||||||
|
|
||||||
// If server sent visible alert, NSE handles sound+badge. Just sync badge.
|
// If server sent visible alert, NSE handles sound+badge — don't double-count.
|
||||||
// If muted, wake app but don't show notification.
|
// If muted, wake app but don't show notification (NSE also suppresses muted).
|
||||||
if hasVisibleAlert || isMuted {
|
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)
|
completionHandler(.newData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,7 +269,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
userInfo: [AnyHashable: Any],
|
userInfo: [AnyHashable: Any],
|
||||||
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
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 {
|
guard !callerKey.isEmpty else {
|
||||||
completionHandler(.noData)
|
completionHandler(.noData)
|
||||||
return
|
return
|
||||||
@@ -314,7 +308,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
|||||||
/// this helper is a fallback for other contexts (notification tap, etc.).
|
/// this helper is a fallback for other contexts (notification tap, etc.).
|
||||||
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
|
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
|
||||||
firstNonBlank(userInfo, keys: [
|
firstNonBlank(userInfo, keys: [
|
||||||
"from", "sender_public_key", "from_public_key", "fromPublicKey",
|
"dialog", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||||
"public_key", "publicKey"
|
"public_key", "publicKey"
|
||||||
]) ?? ""
|
]) ?? ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
private static let badgeKey = "app_badge_count"
|
private static let badgeKey = "app_badge_count"
|
||||||
|
|
||||||
/// Android parity: multiple key names for sender public key extraction.
|
/// 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 = [
|
private static let senderKeyNames = [
|
||||||
"from", "sender_public_key", "from_public_key", "fromPublicKey",
|
"dialog", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||||
"public_key", "publicKey"
|
"public_key", "publicKey"
|
||||||
]
|
]
|
||||||
private static let senderNameKeyNames = [
|
private static let senderNameKeyNames = [
|
||||||
@@ -86,7 +86,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.categoryIdentifier = "call"
|
content.categoryIdentifier = "call"
|
||||||
|
|
||||||
let callerKey = content.userInfo["from"] as? String
|
let callerKey = content.userInfo["dialog"] as? String
|
||||||
?? Self.extractSenderKey(from: content.userInfo)
|
?? Self.extractSenderKey(from: content.userInfo)
|
||||||
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
||||||
let callerName = contactNames[callerKey]
|
let callerName = contactNames[callerKey]
|
||||||
@@ -116,8 +116,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
// 1. Add sound for vibration — server APNs payload has no sound field.
|
// 1. Add sound for vibration — server APNs payload has no sound field.
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
|
||||||
// 2. Extract sender key — server sends `from` field.
|
// 2. Extract sender key — server sends `dialog` field (was `from`).
|
||||||
let senderKey = content.userInfo["from"] as? String
|
let senderKey = content.userInfo["dialog"] as? String
|
||||||
?? Self.extractSenderKey(from: content.userInfo)
|
?? Self.extractSenderKey(from: content.userInfo)
|
||||||
|
|
||||||
// 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge.
|
// 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge.
|
||||||
@@ -151,9 +151,9 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
|
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
|
||||||
if let resolvedName, !resolvedName.isEmpty {
|
if let resolvedName, !resolvedName.isEmpty {
|
||||||
updatedInfo["sender_name"] = resolvedName
|
updatedInfo["sender_name"] = resolvedName
|
||||||
if content.title.isEmpty {
|
// Always prefer local name — server sends title at push time,
|
||||||
content.title = resolvedName
|
// but user may have a custom contact name in App Group cache.
|
||||||
}
|
content.title = resolvedName
|
||||||
}
|
}
|
||||||
content.userInfo = updatedInfo
|
content.userInfo = updatedInfo
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user