Фикс: сделал subtitle в списке чатов и текст in-app баннера в одну строку с truncate

This commit is contained in:
2026-04-14 18:36:13 +05:00
parent 69ac9cd270
commit 400538bf2a
40 changed files with 2482 additions and 1409 deletions

View File

@@ -45,8 +45,8 @@ final class ChatListBottomInsetTests: XCTestCase {
)
}
/// Test 2: Verify automatic safe area adjustment is enabled
func testContentInsetAdjustmentBehaviorIsAutomatic() {
/// Test 2: Verify manual inset mode is enabled (UIKit auto-adjust disabled).
func testContentInsetAdjustmentBehaviorIsNever() {
_ = controller.view
let collectionView = controller.value(forKey: "collectionView") as? UICollectionView
@@ -54,8 +54,8 @@ final class ChatListBottomInsetTests: XCTestCase {
XCTAssertEqual(
collectionView?.contentInsetAdjustmentBehavior,
.automatic,
"Should use automatic adjustment (respects tab bar safe area)"
.never,
"Should use manual inset mode for custom tab-bar safe area handling"
)
}

View File

@@ -19,35 +19,90 @@ final class ReplyPreviewTextTests: XCTestCase {
/// Standalone replica of `ChatDetailView.replyPreviewText(for:)`.
/// Must match the real implementation exactly.
private func replyPreviewText(for message: ChatMessage) -> String {
if message.attachments.contains(where: { $0.type == .image }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
return caption.isEmpty ? "Photo" : caption
}
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
if message.attachments.contains(where: { $0.type == .call }) { return "Call" }
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return message.text }
if !message.attachments.isEmpty { return "Attachment" }
let attachmentLabel: String? = {
for att in message.attachments {
switch att.type {
case .image:
return "Photo"
case .file:
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return att.id.isEmpty ? "File" : att.id
case .avatar:
return "Avatar"
case .messages:
return "Forwarded message"
case .call:
return "Call"
case .voice:
return "Voice message"
}
}
return nil
}()
let visibleText: String = {
let stripped = message.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { !$0.isASCII || $0.asciiValue! >= 0x20 }
if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
return stripped
}()
let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText)
if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded }
if let label = attachmentLabel { return label }
if !visibleTextDecoded.isEmpty { return visibleTextDecoded }
return ""
}
/// Standalone replica of SessionManager in-app banner preview logic.
/// Intentionally mirrors current behavior for regression coverage.
private func inAppBannerPreviewText(text: String, attachments: [MessageAttachment]) -> String {
if !text.isEmpty { return EmojiParser.replaceShortcodes(in: text) }
if let firstAtt = attachments.first {
switch firstAtt.type {
case .image: return "Photo"
case .file: return "File"
case .voice: return "Voice message"
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
default: return "Attachment"
}
}
return "New message"
}
/// Standalone replica of NativeMessageList reply text cache logic for UIKit cells.
/// When outer message is empty, it is treated as forwarded wrapper (no quote text shown).
private func nativeReplyCacheText(outerMessageText: String, reply: ReplyMessageData) -> String? {
let displayText = MessageCellLayout.isGarbageOrEncrypted(outerMessageText) ? "" : outerMessageText
guard !displayText.isEmpty else { return nil }
let trimmed = reply.message.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return EmojiParser.replaceShortcodes(in: reply.message)
}
if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" }
if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" }
if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" }
if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" }
if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" }
return "Attachment"
}
/// Standalone replica of `MessageCellView.replyQuoteView` preview logic.
/// Uses `ReplyMessageData` + `ReplyAttachmentData` (Int type), not `MessageAttachment`.
private func replyQuotePreviewText(for reply: ReplyMessageData) -> String {
let trimmed = reply.message.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { return reply.message }
if !trimmed.isEmpty { return EmojiParser.replaceShortcodes(in: reply.message) }
if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" }
if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" }
if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" }
if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" }
if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" }
return "Attachment"
}
@@ -63,6 +118,7 @@ final class ReplyPreviewTextTests: XCTestCase {
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
case .voice: return "Voice message"
}
} else if textIsEmpty {
return ""
@@ -195,6 +251,11 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyPreviewText(for: msg), "Avatar")
}
func testReplyBar_AvatarWithCaptionUsesCaption() {
let msg = makeMessage(text: "Profile pic", attachments: [makeAttachment(type: .avatar)])
XCTAssertEqual(replyPreviewText(for: msg), "Profile pic")
}
// MARK: Forward
func testReplyBar_Forward() {
@@ -202,6 +263,11 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message")
}
func testReplyBar_ForwardWithCaptionUsesCaption() {
let msg = makeMessage(text: "See this", attachments: [makeAttachment(type: .messages)])
XCTAssertEqual(replyPreviewText(for: msg), "See this")
}
// MARK: Call (Android/Desktop parity)
func testReplyBar_Call() {
@@ -209,6 +275,21 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyPreviewText(for: msg), "Call")
}
func testReplyBar_CallWithCaptionUsesCaption() {
let msg = makeMessage(text: "Missed you", attachments: [makeAttachment(type: .call)])
XCTAssertEqual(replyPreviewText(for: msg), "Missed you")
}
func testReplyBar_VoiceMessage() {
let msg = makeMessage(attachments: [makeAttachment(type: .voice)])
XCTAssertEqual(replyPreviewText(for: msg), "Voice message")
}
func testReplyBar_VoiceWithCaptionUsesCaption() {
let msg = makeMessage(text: "Listen", attachments: [makeAttachment(type: .voice)])
XCTAssertEqual(replyPreviewText(for: msg), "Listen")
}
// MARK: Text
func testReplyBar_TextOnly() {
@@ -216,6 +297,13 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyPreviewText(for: msg), "Hello world")
}
func testReplyBar_TextShortcodesAreDecodedToEmoji() {
let msg = makeMessage(text: "ok :emoji_1f44d:")
let preview = replyPreviewText(for: msg)
XCTAssertFalse(preview.contains(":emoji_"))
XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d")))
}
func testReplyBar_Empty() {
let msg = makeMessage()
XCTAssertEqual(replyPreviewText(for: msg), "")
@@ -281,6 +369,11 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyQuotePreviewText(for: reply), "Call")
}
func testQuote_VoiceMessage() {
let reply = makeReply(attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)])
XCTAssertEqual(replyQuotePreviewText(for: reply), "Voice message")
}
func testQuote_UnknownType() {
let reply = makeReply(attachments: [makeReplyAttachment(type: 99)])
XCTAssertEqual(replyQuotePreviewText(for: reply), "Attachment")
@@ -291,6 +384,13 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(replyQuotePreviewText(for: reply), "Some text")
}
func testQuote_TextShortcodesAreDecodedToEmoji() {
let reply = makeReply(message: "ok :emoji_1f44d:", attachments: [])
let preview = replyQuotePreviewText(for: reply)
XCTAssertFalse(preview.contains(":emoji_"))
XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d")))
}
func testQuote_WhitespaceText() {
let reply = makeReply(message: " ", attachments: [makeReplyAttachment(type: 0)])
XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo")
@@ -320,6 +420,10 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .call)]), "Call")
}
func testChatList_Voice() {
XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .voice)]), "Voice message")
}
func testChatList_TextWins() {
XCTAssertEqual(chatListPreviewText(text: "Hello", attachments: [makeAttachment(type: .image)]), "Hello")
}
@@ -339,6 +443,41 @@ final class ReplyPreviewTextTests: XCTestCase {
XCTAssertEqual(chatListPreviewText(text: control, attachments: [makeAttachment(type: .call)]), "Call")
}
// =========================================================================
// MARK: - In-App Banner Preview (SessionManager)
// =========================================================================
func testInAppBanner_TextShortcodesAreDecodedToEmoji() {
let input = "ok :emoji_1f44d:"
let preview = inAppBannerPreviewText(text: input, attachments: [])
XCTAssertFalse(preview.contains(":emoji_"))
XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d")))
}
func testInAppBanner_VoiceAttachmentLabel() {
let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .voice)])
XCTAssertEqual(preview, "Voice message")
}
func testInAppBanner_AvatarAttachmentLabel() {
let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .avatar)])
XCTAssertEqual(preview, "Avatar")
}
// =========================================================================
// MARK: - Native Reply Cache (NativeMessageList UIKit path)
// =========================================================================
func testNativeReplyCache_VoiceAttachmentLabel() {
let reply = makeReply(message: "", attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)])
XCTAssertEqual(nativeReplyCacheText(outerMessageText: "fix", reply: reply), "Voice message")
}
func testNativeReplyCache_DecodesEmojiShortcodes() {
let reply = makeReply(message: "ok :emoji_1f44d:", attachments: [])
XCTAssertEqual(nativeReplyCacheText(outerMessageText: "text", reply: reply), "ok 👍")
}
// =========================================================================
// MARK: - AttachmentType JSON Decode Fault Tolerance
// =========================================================================