Reply rendering (#861)

* Cleanup how we use message timeline items in preparation for rich reply rendering

* Expose reply details up to the UI level

* Render replies in the timeline (textual for now). Remove now unused AttributedStringBuilderComponent isReply flag.

* Move reply detail loading to when the item appears instead of when it's created.

* Fix unit tests

* Fetch event details also when the reply details are in the error state

* Tweaks following code review

* Localise emote bodies

* Fix replies not loading sometimes, use the current event identifier instead of the one being replied to
This commit is contained in:
Stefan Ceriu
2023-05-09 16:09:38 +03:00
committed by GitHub
parent 483d261504
commit 1bd6e461ed
48 changed files with 889 additions and 733 deletions
@@ -341,7 +341,6 @@ class AttributedStringBuilderTests: XCTestCase {
}
XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.")
XCTAssertTrue(component.isReply, "The reply quote should be detected as a reply")
}
func testMultipleGroupedBlockquotes() {
@@ -388,9 +387,6 @@ class AttributedStringBuilderTests: XCTestCase {
let coalescedComponents = attributedString.formattedComponents
XCTAssertEqual(coalescedComponents.count, 6)
for component in coalescedComponents where component.isReply {
XCTFail("None of the blockquotes should be detected as replies.")
}
var numberOfBlockquotes = 0
for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil {
+37 -19
View File
@@ -186,37 +186,55 @@ class LoggingTests: XCTestCase {
XCTFail(Constants.genericFailure)
return
}
let content = try String(contentsOf: logFile)
XCTAssertTrue(content.contains(roomSummary.id))
XCTAssertFalse(content.contains(roomName))
XCTAssertFalse(content.contains(lastMessage))
}
// swiftlint:disable:next function_body_length
func testTimelineContentIsRedacted() throws {
// Given timeline items that contain text
let textAttributedString = "TextAttributed"
let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString",
formattedBody: AttributedString(textAttributedString),
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let textMessage = TextRoomTimelineItem(id: "mytextmessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
content: .init(body: "TextString", formattedBody: AttributedString(textAttributedString)))
let noticeAttributedString = "NoticeAttributed"
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString",
formattedBody: AttributedString(noticeAttributedString),
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
content: .init(body: "NoticeString", formattedBody: AttributedString(noticeAttributedString)))
let emoteAttributedString = "EmoteAttributed"
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString",
formattedBody: AttributedString(emoteAttributedString),
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString",
timestamp: "", isOutgoing: false, isEditable: false,
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"))
let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString",
timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil)
let fileMessage = FileRoomTimelineItem(id: "myfilemessage", body: "FileString",
timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), source: nil, thumbnailSource: nil)
content: .init(body: "EmoteString", formattedBody: AttributedString(emoteAttributedString)))
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
content: .init(body: "ImageString", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), thumbnailSource: nil))
let videoMessage = VideoRoomTimelineItem(id: "myvideomessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
content: .init(body: "VideoString", duration: 0, source: nil, thumbnailSource: nil))
let fileMessage = FileRoomTimelineItem(id: "myfilemessage",
timestamp: "",
isOutgoing: false,
isEditable: false,
sender: .init(id: "sender"),
content: .init(body: "FileString", source: nil, thumbnailSource: nil, contentType: nil))
// When logging that value
XCTAssert(MXLogger.logFiles.isEmpty)
@@ -1,168 +0,0 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
@testable import ElementX
import MatrixRustSDK
import XCTest
class MessageTimelineItemTests: XCTestCase {
// MARK: Image
func testImageContentType() {
let mimetype = "image/gif"
let imageContent = ImageMessageContent(body: "amazing.gif",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeImageInfo(mimetype: mimetype))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .gif)
}
func testImageContentTypeWithoutMimetype() {
let imageContent = ImageMessageContent(body: "amazing.jpeg",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeImageInfo(mimetype: nil))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .jpeg)
}
func makeImageInfo(mimetype: String?) -> ImageInfo {
ImageInfo(height: nil,
width: nil,
mimetype: mimetype,
size: nil,
thumbnailInfo: nil,
thumbnailSource: nil,
blurhash: nil)
}
// MARK: Video
func testVideoContentType() {
let mimetype = "video/x-msvideo"
let imageContent = VideoMessageContent(body: "amazing.avi",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeVideoInfo(mimetype: mimetype))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .avi)
}
func testVideoContentTypeWithoutMimetype() {
let imageContent = VideoMessageContent(body: "amazing.mp4",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeVideoInfo(mimetype: nil))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .mpeg4Movie)
}
func makeVideoInfo(mimetype: String?) -> VideoInfo {
VideoInfo(duration: nil,
height: nil,
width: nil,
mimetype: mimetype,
size: nil,
thumbnailInfo: nil,
thumbnailSource: nil,
blurhash: nil)
}
// MARK: Audio
func testAudioContentType() {
let mimetype = "audio/mp3"
let imageContent = AudioMessageContent(body: "amazing.mp3",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeAudioInfo(mimetype: mimetype))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .mp3)
}
func testAudioContentTypeWithoutMimetype() {
let imageContent = AudioMessageContent(body: "amazing.m4a",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeAudioInfo(mimetype: nil))
let messageItem = MockEventTimelineItem.message(from: imageContent)
// swiftlint:disable:next force_unwrapping
XCTAssertTrue(messageItem.contentType!.conforms(to: .mpeg4Audio))
}
func makeAudioInfo(mimetype: String?) -> AudioInfo {
AudioInfo(duration: nil, size: nil, mimetype: mimetype)
}
// MARK: File
func testFileContentType() {
let mimetype = "text"
let imageContent = FileMessageContent(body: "amazing.txt",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeFileInfo(mimetype: mimetype))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .plainText)
}
func testFileContentTypeWithoutMimetype() {
let imageContent = FileMessageContent(body: "amazing.rtf",
source: mediaSourceFromUrl(url: "mxc://doesnt/matter"),
info: makeFileInfo(mimetype: nil))
let messageItem = MockEventTimelineItem.message(from: imageContent)
XCTAssertEqual(messageItem.contentType, .rtf)
}
func makeFileInfo(mimetype: String?) -> FileInfo {
FileInfo(mimetype: mimetype, size: nil, thumbnailInfo: nil, thumbnailSource: nil)
}
}
// MARK: - Mocks
private struct MockEventTimelineItem: EventTimelineItemProtocol {
static func message<Content: MessageContentProtocol>(from content: Content) -> MessageTimelineItem<Content> {
let item = MockEventTimelineItem(underlyingContent: content)
return MessageTimelineItem(item: item, content: content)
}
let underlyingContent: MessageContentProtocol
// swiftlint:disable:next force_cast
func content() -> MatrixRustSDK.TimelineItemContent { underlyingContent as! TimelineItemContent }
func eventId() -> String? { UUID().uuidString }
func debugInfo() -> MatrixRustSDK.EventTimelineItemDebugInfo { .init(model: "MockEvent", originalJson: nil, latestEditJson: nil) }
func isEditable() -> Bool { false }
func isLocal() -> Bool { false }
func isOwn() -> Bool { false }
func isRemote() -> Bool { true }
func localSendState() -> MatrixRustSDK.EventSendState? { nil }
func raw() -> String? { nil }
func reactions() -> [MatrixRustSDK.Reaction] { [] }
func sender() -> String { "@user:server.com" }
func senderProfile() -> MatrixRustSDK.ProfileDetails { .unavailable }
func timestamp() -> UInt64 { 0 }
func uniqueIdentifier() -> String { eventId() ?? "" }
}
@@ -155,11 +155,11 @@ class RoomScreenViewModelTests: XCTestCase {
private extension TextRoomTimelineItem {
init(text: String, sender: String, addReactions: Bool = false) {
self.init(id: UUID().uuidString,
body: text,
timestamp: "10:47 am",
isOutgoing: sender == "bob",
isEditable: sender == "bob",
sender: .init(id: "@\(sender):server.com", displayName: sender),
content: .init(body: text),
properties: RoomTimelineItemProperties(reactions: addReactions ? [
AggregatedReaction(key: "🦄", count: 1, isHighlighted: false)
] : []))