Реплай: исправлен отступ от бара до текста (6pt → 8pt, Telegram parity)

This commit is contained in:
2026-03-31 14:51:00 +05:00
parent 876e541006
commit e5179b11ea
23 changed files with 450 additions and 348 deletions

View File

@@ -278,6 +278,20 @@ struct ChatDetailView: View {
// Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Re-evaluate read eligibility after app returns from background.
// readEligibleDialogs is cleared on didEnterBackground (SessionManager);
// this restores eligibility for the currently-visible chat.
// 600ms delay lets notification-tap navigation settle if user tapped
// a notification for a DIFFERENT chat, isViewActive becomes false.
guard isViewActive else { return }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(600))
guard isViewActive else { return }
updateReadEligibility()
markDialogAsRead()
}
}
}
var body: some View {
@@ -931,9 +945,6 @@ private extension ChatDetailView {
},
onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in
#if DEBUG
print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
#endif
withAnimation(.easeInOut(duration: 0.2)) {
isMultilineInput = multiline
}
@@ -1276,18 +1287,6 @@ private extension ChatDetailView {
// MARK: - Forward
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
#if DEBUG
print("═══════════════════════════════════════════════")
print("📤 FORWARD START")
print("📤 Original message: id=\(message.id.prefix(16)), text='\(message.text.prefix(30))'")
print("📤 Original attachments (\(message.attachments.count)):")
for att in message.attachments {
print("📤 - type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))' blob=\(att.blob.isEmpty ? "(empty)" : "(\(att.blob.count) chars, starts: \(att.blob.prefix(30)))")")
}
print("📤 Attachment password: \(message.attachmentPassword?.prefix(20) ?? "nil")")
print("📤 Target: \(targetRoute.publicKey.prefix(16))")
#endif
// Android parity: unwrap nested forwards.
// If the message being forwarded is itself a forward, extract the inner
// forwarded messages and re-forward them directly (flatten).
@@ -1296,43 +1295,15 @@ private extension ChatDetailView {
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
#if DEBUG
if let att = replyAttachment {
let blobParsed = parseReplyBlob(att.blob)
let previewParsed = parseReplyBlob(att.preview)
print("📤 Unwrap check: isForward=\(isForward)")
print("📤 blob parse: \(blobParsed == nil ? "FAILED" : "OK (\(blobParsed!.count) msgs, atts: \(blobParsed!.map { $0.attachments.count }))")")
print("📤 preview parse: \(previewParsed == nil ? "FAILED (preview='\(att.preview.prefix(20))')" : "OK (\(previewParsed!.count) msgs)")")
}
#endif
if isForward,
let att = replyAttachment,
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
!innerMessages.isEmpty {
// Unwrap: forward the original messages, not the wrapper
forwardDataList = innerMessages
#if DEBUG
print("📤 ✅ UNWRAP path: \(innerMessages.count) inner message(s)")
for (i, msg) in innerMessages.enumerated() {
print("📤 msg[\(i)]: publicKey=\(msg.publicKey.prefix(12)), text='\(msg.message.prefix(30))', attachments=\(msg.attachments.count)")
for att in msg.attachments {
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
}
}
#endif
} else {
// Regular message forward as-is
forwardDataList = [buildReplyData(from: message)]
#if DEBUG
print("📤 ⚠️ BUILD_REPLY_DATA path (unwrap failed or not a forward)")
if let first = forwardDataList.first {
print("📤 result: publicKey=\(first.publicKey.prefix(12)), text='\(first.message.prefix(30))', attachments=\(first.attachments.count)")
for att in first.attachments {
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
}
}
#endif
}
// Desktop commit aaa4b42: no re-upload needed.
@@ -1343,14 +1314,6 @@ private extension ChatDetailView {
let targetUsername = targetRoute.username
Task { @MainActor in
#if DEBUG
print("📤 ── SEND SUMMARY ──")
print("📤 forwardDataList: \(forwardDataList.count) message(s)")
for (i, msg) in forwardDataList.enumerated() {
print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) chacha_key_plain=\(msg.chacha_key_plain.prefix(16))")
}
#endif
do {
try await SessionManager.shared.sendMessageWithReply(
text: "",
@@ -1359,15 +1322,7 @@ private extension ChatDetailView {
opponentTitle: targetTitle,
opponentUsername: targetUsername
)
#if DEBUG
print("📤 ✅ FORWARD SENT OK")
print("═══════════════════════════════════════════════")
#endif
} catch {
#if DEBUG
print("📤 ❌ FORWARD FAILED: \(error)")
print("═══════════════════════════════════════════════")
#endif
sendError = "Failed to forward message"
}
}
@@ -1400,9 +1355,6 @@ private extension ChatDetailView {
let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob),
let firstInner = innerMessages.first {
#if DEBUG
print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments")
#endif
return firstInner
}

View File

@@ -15,7 +15,6 @@ struct ForwardChatPickerView: View {
}
var body: some View {
let _ = print("[ForwardPicker] body — dialogs count: \(dialogs.count)")
NavigationStack {
List(dialogs) { dialog in
Button {

View File

@@ -235,17 +235,11 @@ struct MessageAvatarView: View {
let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else {
#if DEBUG
print("🖼️ AVATAR FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)")
#endif
downloadError = true
return
}
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
#if DEBUG
print("🖼️ AVATAR FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)")
#endif
downloadError = true
return
}
@@ -254,23 +248,12 @@ struct MessageAvatarView: View {
downloadError = false
let server = attachment.transportServer
#if DEBUG
print("🖼️ AVATAR START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
#endif
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
#if DEBUG
let hasColon = encryptedString.contains(":")
print("🖼️ AVATAR DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColon), first50=\(encryptedString.prefix(50))")
#endif
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
#if DEBUG
print("🖼️ AVATAR CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })")
#endif
let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
)
@@ -285,21 +268,12 @@ struct MessageAvatarView: View {
let base64 = jpegData.base64EncodedString()
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
}
#if DEBUG
print("🖼️ AVATAR DECRYPT OK: attId=\(attachment.id)")
#endif
} else {
#if DEBUG
print("🖼️ AVATAR DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
#endif
downloadError = true
}
isDownloading = false
}
} catch {
#if DEBUG
print("🖼️ AVATAR ERROR: \(error) for tag=\(tag)")
#endif
await MainActor.run {
downloadError = true
isDownloading = false
@@ -311,41 +285,25 @@ struct MessageAvatarView: View {
/// Tries each password candidate and validates the decrypted content is a real image.
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for (i, password) in passwords.enumerated() {
for password in passwords {
do {
let data = try crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
)
#if DEBUG
print("🖼️ PASS1 candidate[\(i)] decrypted \(data.count) bytes")
#endif
if let img = parseImageData(data) { return img }
#if DEBUG
print("🖼️ PASS1 candidate[\(i)] parseImageData FAILED")
#endif
} catch {
#if DEBUG
print("🖼️ PASS1 candidate[\(i)] FAILED: \(error)")
#endif
continue
}
}
// Fallback: try without requireCompression (legacy uncompressed payloads)
for (i, password) in passwords.enumerated() {
for password in passwords {
do {
let data = try crypto.decryptWithPassword(
encryptedString, password: password
)
#if DEBUG
print("🖼️ PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())")
#endif
if let img = parseImageData(data) { return img }
#if DEBUG
print("🖼️ PASS2 candidate[\(i)] parseImageData FAILED")
#endif
} catch {
#if DEBUG
print("🖼️ PASS2 candidate[\(i)] FAILED: \(error)")
#endif
continue
}
}
return nil

View File

@@ -125,7 +125,7 @@ struct MessageCellView: View, Equatable {
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
isOutgoing: outgoing,
replyQuoteHeight: replyData != nil ? 46 : 0,
replyQuoteHeight: replyData != nil ? 49 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in actions.onScrollToMessage(reply.message_id) }
}
@@ -154,21 +154,6 @@ struct MessageCellView: View, Equatable {
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
&& !Self.isGarbageText(reply.message)
#if DEBUG
let _ = {
if reply.attachments.isEmpty {
print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))")
if let att = message.attachments.first(where: { $0.type == .messages }) {
let blobPrefix = att.blob.prefix(60)
let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[")
print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)")
}
} else {
print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)")
}
}()
#endif
let fallbackText: String = {
if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
@@ -561,7 +546,7 @@ struct MessageCellView: View, Equatable {
if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" }
return "Attachment"
}()
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
let accentColor = outgoing ? Color.white : RosettaColors.figmaBlue
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
@@ -570,40 +555,37 @@ struct MessageCellView: View, Equatable {
}()
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
RoundedRectangle(cornerRadius: 4)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 4)
if let att = imageAttachment {
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 2) {
Text(senderName)
.font(.system(size: 15, weight: .semibold))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
.lineLimit(1)
Text(previewText)
.font(.system(size: 15, weight: .regular))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.leading, 6)
.padding(.leading, 8)
Spacer(minLength: 0)
}
.frame(height: 41)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
.fill(outgoing ? Color.white.opacity(0.12) : RosettaColors.figmaBlue.opacity(0.12))
)
.padding(.horizontal, 5)
.padding(.top, 5)
.padding(.bottom, 0)
.padding(.bottom, 2)
}
// MARK: - Forwarded File Preview

View File

@@ -253,17 +253,11 @@ struct MessageImageView: View {
let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else {
#if DEBUG
print("📸 DOWNLOAD FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)")
#endif
downloadError = true
return
}
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
#if DEBUG
print("📸 DOWNLOAD FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)")
#endif
downloadError = true
return
}
@@ -272,24 +266,12 @@ struct MessageImageView: View {
downloadError = false
let server = attachment.transportServer
#if DEBUG
print("📸 DOWNLOAD START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
#endif
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
#if DEBUG
let colonIdx = encryptedString.firstIndex(of: ":")
let hasColonSeparator = colonIdx != nil
print("📸 DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColonSeparator), first50=\(encryptedString.prefix(50))")
#endif
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
#if DEBUG
print("📸 CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })")
#endif
let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
)
@@ -298,21 +280,12 @@ struct MessageImageView: View {
if let downloadedImage {
image = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
#if DEBUG
print("📸 DECRYPT OK: attId=\(attachment.id) imageSize=\(downloadedImage.size)")
#endif
} else {
#if DEBUG
print("📸 DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
#endif
downloadError = true
}
isDownloading = false
}
} catch {
#if DEBUG
print("📸 DOWNLOAD ERROR: \(error) for tag=\(tag)")
#endif
await MainActor.run {
downloadError = true
isDownloading = false
@@ -323,40 +296,24 @@ struct MessageImageView: View {
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for (i, password) in passwords.enumerated() {
for password in passwords {
do {
let data = try crypto.decryptWithPassword(
encryptedString, password: password, requireCompression: true
)
#if DEBUG
print("📸 PASS1 candidate[\(i)] decrypted \(data.count) bytes")
#endif
if let img = parseImageData(data) { return img }
#if DEBUG
print("📸 PASS1 candidate[\(i)] parseImageData FAILED (data prefix: \(data.prefix(30).map { String(format: "%02x", $0) }.joined()))")
#endif
} catch {
#if DEBUG
print("📸 PASS1 candidate[\(i)] decrypt FAILED: \(error)")
#endif
continue
}
}
for (i, password) in passwords.enumerated() {
for password in passwords {
do {
let data = try crypto.decryptWithPassword(
encryptedString, password: password
)
#if DEBUG
print("📸 PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())")
#endif
if let img = parseImageData(data) { return img }
#if DEBUG
print("📸 PASS2 candidate[\(i)] parseImageData FAILED")
#endif
} catch {
#if DEBUG
print("📸 PASS2 candidate[\(i)] decrypt FAILED: \(error)")
#endif
continue
}
}
return nil

View File

@@ -244,7 +244,9 @@ final class NativeMessageCell: UICollectionViewCell {
bubbleView.addSubview(clockFrameView)
bubbleView.addSubview(clockMinView)
// Reply quote
// Reply quote Telegram parity: accent-tinted bg + 4pt radius bar
replyContainer.layer.cornerRadius = 4.0
replyContainer.clipsToBounds = true
replyBar.layer.cornerRadius = 4.0
replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont
@@ -519,16 +521,17 @@ final class NativeMessageCell: UICollectionViewCell {
// Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView)
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
// Reply quote
// Reply quote Telegram parity colors
if let replyName {
replyContainer.isHidden = false
replyContainer.backgroundColor = isOutgoing
? UIColor.white.withAlphaComponent(0.12)
: Self.outgoingColor.withAlphaComponent(0.12)
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.8)
: UIColor.white.withAlphaComponent(0.6)
replyTextLabel.textColor = .white
} else {
replyContainer.isHidden = true
}

View File

@@ -614,12 +614,9 @@ final class NativeMessageListController: UIViewController {
// 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 }
guard let nativeCell = cell as? NativeMessageCell 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)
@@ -696,16 +693,6 @@ final class NativeMessageListController: UIViewController {
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()
@@ -937,10 +924,6 @@ final class NativeMessageListController: UIViewController {
textLayoutCache.removeAll()
return
}
#if DEBUG
let start = CFAbsoluteTimeGetCurrent()
#endif
let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
messages: messages,
maxBubbleWidth: config.maxBubbleWidth,
@@ -950,11 +933,6 @@ final class NativeMessageListController: UIViewController {
)
layoutCache = layouts
textLayoutCache = textLayouts
#if DEBUG
let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000
print("⚡ PERF_LAYOUT | \(messages.count) msgs | \(String(format: "%.1f", elapsed))ms | textLayouts cached: \(textLayouts.count)")
#endif
}
// MARK: - Inset Management
@@ -1095,12 +1073,6 @@ final class NativeMessageListController: UIViewController {
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
#if DEBUG
let screenH = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height
let kbH = max(0, screenH - endFrame.minY)
print("⌨️ keyboardWillChange | kbHeight=\(kbH) endFrame=\(endFrame) composerH=\(lastComposerHeight) currentInset=\(collectionView?.contentInset.top ?? 0)")
#endif
let screenHeight = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height
let keyboardHeight = max(0, screenHeight - endFrame.minY)
let safeBottom = view.safeAreaInsets.bottom
@@ -1173,9 +1145,6 @@ final class NativeMessageListController: UIViewController {
}
@objc private func keyboardDidHide() {
#if DEBUG
print("⌨️ didHide | cv.frame=\(collectionView?.frame ?? .zero) composer.frame=\(composerView?.frame ?? .zero) offset=\(collectionView?.contentOffset ?? .zero) composerConst=\(composerBottomConstraint?.constant ?? 0)")
#endif
currentKeyboardHeight = 0
isKeyboardAnimating = false
onKeyboardDidHide?()

View File

@@ -8,12 +8,7 @@ struct SearchView: View {
@State private var searchText = ""
@State private var navigationPath: [ChatRoute] = []
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
#endif
NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
@@ -142,13 +137,8 @@ private extension SearchView {
/// does NOT propagate to `SearchView`'s NavigationStack.
private struct FavoriteContactsRow: View {
@Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
#endif
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
@@ -192,13 +182,7 @@ private struct FavoriteContactsRow: View {
private struct RecentSection: View {
@ObservedObject var viewModel: SearchViewModel
@Binding var navigationPath: [ChatRoute]
@MainActor static var _bodyCount = 0
var body: some View {
#if DEBUG
let _ = Self._bodyCount += 1
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
#endif
if viewModel.recentSearches.isEmpty {
emptyState
} else {