Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Add message highlighting on jumping to a quoted message [#1032](https://github.com/GetStream/stream-chat-swiftui/pull/1030)

### 🐞 Fixed
- Fix composer deleting newly entered text after deleting draft text [#1030](https://github.com/GetStream/stream-chat-swiftui/pull/1030)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
messages: viewModel.messages,
messagesGroupingInfo: viewModel.messagesGroupingInfo,
scrolledId: $viewModel.scrolledId,
highlightedMessageId: $viewModel.highlightedMessageId,
showScrollToLatestButton: $viewModel.showScrollToLatestButton,
quotedMessage: $viewModel.quotedMessage,
currentDateString: viewModel.currentDateString,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
public var messageController: ChatMessageController?

@Published public var scrolledId: String?
@Published public var highlightedMessageId: String?
@Published public var listId = UUID().uuidString

@Published public var showScrollToLatestButton = false
Expand Down Expand Up @@ -172,6 +173,18 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
self?.messageCachingUtils.jumpToReplyId = scrollToMessage.messageId
} else if messageController != nil, let jumpToReplyId = self?.messageCachingUtils.jumpToReplyId {
self?.scrolledId = jumpToReplyId
// Trigger highlight when jumping to reply in thread
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.highlightedMessageId = jumpToReplyId
}
// Clear scroll ID after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.scrolledId = nil
}
// Clear highlight after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
self?.highlightedMessageId = nil
}
self?.messageCachingUtils.jumpToReplyId = nil
} else if messageController == nil {
self?.scrolledId = scrollToMessage?.messageId
Expand Down Expand Up @@ -232,6 +245,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if let message = notification.userInfo?[MessageRepliesConstants.selectedMessage] as? ChatMessage {
threadMessage = message
threadMessageShown = true

// Only set jumpToReplyId if there's a specific reply message to highlight
// (for showReplyInChannel messages). The parent message should never be highlighted.
if let replyMessage = notification.userInfo?[MessageRepliesConstants.threadReplyMessage] as? ChatMessage {
messageCachingUtils.jumpToReplyId = replyMessage.messageId
}
}
}

Expand Down Expand Up @@ -297,9 +316,18 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if scrolledId == nil {
scrolledId = messageId
}
// Trigger highlight after a short delay to allow scroll animation to start
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.highlightedMessageId = messageId
}
// Clear scroll ID after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.scrolledId = nil
}
// Clear highlight after animation completes (0.6s delay from StreamChatUI implementation)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
self?.highlightedMessageId = nil
}
return true
} else {
let message = channelController.dataStore.message(id: baseId)
Expand All @@ -325,9 +353,17 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if toJumpId == baseId, let message = self?.channelController.dataStore.message(id: toJumpId) {
toJumpId = message.messageId
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.scrolledId = toJumpId
self?.loadingMessagesAround = false
// Trigger highlight after scroll starts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.highlightedMessageId = toJumpId
}
// Clear highlight after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
self?.highlightedMessageId = nil
}
}
}
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
var isInThread: Bool
var isLast: Bool
@Binding var scrolledId: String?
@Binding var highlightedMessageId: String?
@Binding var quotedMessage: ChatMessage?
var onLongPress: (MessageDisplayInfo) -> Void

Expand Down Expand Up @@ -51,6 +52,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
isInThread: Bool,
isLast: Bool,
scrolledId: Binding<String?>,
highlightedMessageId: Binding<String?>,
quotedMessage: Binding<ChatMessage?>,
onLongPress: @escaping (MessageDisplayInfo) -> Void,
viewModel: MessageViewModel? = nil
Expand All @@ -70,6 +72,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
)
)
_scrolledId = scrolledId
_highlightedMessageId = highlightedMessageId
_quotedMessage = quotedMessage
}

Expand Down Expand Up @@ -284,7 +287,15 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
.padding(.horizontal, messageListConfig.messagePaddings.horizontal)
.padding(.bottom, showsAllInfo || messageViewModel.isPinned ? paddingValue : groupMessageInterItemSpacing)
.padding(.top, isLast ? paddingValue : 0)
.background(messageViewModel.isPinned ? Color(colors.pinnedBackground) : nil)
.background(
Group {
if let highlightedMessageId = highlightedMessageId, highlightedMessageId == message.messageId {
Color(colors.messageCellHighlightBackground)
} else if messageViewModel.isPinned {
Color(colors.pinnedBackground)
}
}
)
.padding(.bottom, messageViewModel.isPinned ? paddingValue / 2 : 0)
.transition(
message.isSentByCurrentUser ?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
var messages: LazyCachedMapCollection<ChatMessage>
var messagesGroupingInfo: [String: [String]]
@Binding var scrolledId: String?
@Binding var highlightedMessageId: String?
@Binding var showScrollToLatestButton: Bool
@Binding var quotedMessage: ChatMessage?
@Binding var scrollPosition: String?
Expand Down Expand Up @@ -68,6 +69,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
messages: LazyCachedMapCollection<ChatMessage>,
messagesGroupingInfo: [String: [String]],
scrolledId: Binding<String?>,
highlightedMessageId: Binding<String?>,
showScrollToLatestButton: Binding<Bool>,
quotedMessage: Binding<ChatMessage?>,
currentDateString: String? = nil,
Expand Down Expand Up @@ -96,6 +98,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
self.shouldShowTypingIndicator = shouldShowTypingIndicator
self.loadingNextMessages = loadingNextMessages
_scrolledId = scrolledId
_highlightedMessageId = highlightedMessageId
_showScrollToLatestButton = showScrollToLatestButton
_quotedMessage = quotedMessage
_scrollPosition = scrollPosition
Expand Down Expand Up @@ -143,6 +146,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
showsAllInfo: showsAllData(for: message),
isInThread: isMessageThread,
scrolledId: $scrolledId,
highlightedMessageId: $highlightedMessageId,
quotedMessage: $quotedMessage,
onLongPress: handleLongPress(messageDisplayInfo:),
isLast: !showsLastInGroupInfo && message == messages.last
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SwiftUI
enum MessageRepliesConstants {
static let selectedMessageThread = "selectedMessageThread"
static let selectedMessage = "selectedMessage"
static let threadReplyMessage = "threadReplyMessage"
}

/// View shown below a message, when there are replies to it.
Expand All @@ -21,21 +22,24 @@ public struct MessageRepliesView<Factory: ViewFactory>: View {
var replyCount: Int
var isRightAligned: Bool
var showReplyCount: Bool
var threadReplyMessage: ChatMessage? // The actual reply message (for showReplyInChannel messages)

public init(
factory: Factory,
channel: ChatChannel,
message: ChatMessage,
replyCount: Int,
showReplyCount: Bool = true,
isRightAligned: Bool? = nil
isRightAligned: Bool? = nil,
threadReplyMessage: ChatMessage? = nil
) {
self.factory = factory
self.channel = channel
self.message = message
self.replyCount = replyCount
self.isRightAligned = isRightAligned ?? message.isRightAligned
self.showReplyCount = showReplyCount
self.threadReplyMessage = threadReplyMessage
}

public var body: some View {
Expand All @@ -44,10 +48,14 @@ public struct MessageRepliesView<Factory: ViewFactory>: View {
resignFirstResponder()
// NOTE: this is used to avoid breaking changes.
// Will be updated in a major release.
var userInfo: [String: Any] = [MessageRepliesConstants.selectedMessage: message]
if let threadReplyMessage = threadReplyMessage {
userInfo[MessageRepliesConstants.threadReplyMessage] = threadReplyMessage
}
NotificationCenter.default.post(
name: NSNotification.Name(MessageRepliesConstants.selectedMessageThread),
object: nil,
userInfo: [MessageRepliesConstants.selectedMessage: message]
userInfo: userInfo
)
} label: {
HStack {
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChatSwiftUI/ColorPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public struct ColorPalette {
public var highlightedAccentBackground: UIColor = .streamAccentBlue
public var highlightedAccentBackground1: UIColor = .streamBlueAlice
public var pinnedBackground: UIColor = .streamHighlight
public var messageCellHighlightBackground: UIColor = .streamYellowBackground

// MARK: - Borders and shadows

Expand Down Expand Up @@ -167,6 +168,7 @@ private extension UIColor {
static let streamGrayDisabledText = mode(0x72767e, 0x72767e)
static let streamInnerBorder = mode(0xdbdde1, 0x272a30)
static let streamHighlight = mode(0xfbf4dd, 0x333024)
static let streamYellowBackground = mode(0xfff2a1, 0x4a3d00)
static let streamDisabled = mode(0xb4b7bb, 0x4c525c)

// Currently we are not using the correct shadow color from figma's color palette. This is to avoid
Expand Down
5 changes: 4 additions & 1 deletion Sources/StreamChatSwiftUI/DefaultViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ extension ViewFactory {
showsAllInfo: Bool,
isInThread: Bool,
scrolledId: Binding<String?>,
highlightedMessageId: Binding<String?>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change, we can't do this :/ Is there any alternative?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated the PR, now using Environment instead of factory param 👍

quotedMessage: Binding<ChatMessage?>,
onLongPress: @escaping (MessageDisplayInfo) -> Void,
isLast: Bool
Expand All @@ -342,6 +343,7 @@ extension ViewFactory {
isInThread: isInThread,
isLast: isLast,
scrolledId: scrolledId,
highlightedMessageId: highlightedMessageId,
quotedMessage: quotedMessage,
onLongPress: onLongPress
)
Expand Down Expand Up @@ -608,7 +610,8 @@ extension ViewFactory {
message: parentMessage,
replyCount: replyCount,
showReplyCount: false,
isRightAligned: message.isRightAligned
isRightAligned: message.isRightAligned,
threadReplyMessage: message // Pass the actual reply message (shown in channel)
)
}

Expand Down
1 change: 1 addition & 0 deletions Sources/StreamChatSwiftUI/ViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ public protocol ViewFactory: AnyObject {
showsAllInfo: Bool,
isInThread: Bool,
scrolledId: Binding<String?>,
highlightedMessageId: Binding<String?>,
quotedMessage: Binding<ChatMessage?>,
onLongPress: @escaping (MessageDisplayInfo) -> Void,
isLast: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,14 +534,126 @@ class ChatChannelViewModel_Tests: StreamChatTestCase {
let message2 = ChatMessage.mock()
let channelController = makeChannelController(messages: [message1, message2])
let viewModel = ChatChannelViewModel(channelController: channelController)

// When
let shouldJump = viewModel.jumpToMessage(messageId: .unknownMessageId)

// Then
XCTAssert(shouldJump == false)
}


func test_chatChannelVM_jumpToMessage_setsHighlightedMessageId() {
// Given
let message1 = ChatMessage.mock()
let message2 = ChatMessage.mock()
let channelController = makeChannelController(messages: [message1, message2])
let viewModel = ChatChannelViewModel(channelController: channelController)
let testExpectation = XCTestExpectation(description: "Highlight should be set")
testExpectation.assertForOverFulfill = false

// When
let shouldJump = viewModel.jumpToMessage(messageId: message2.messageId)

// Then
XCTAssert(shouldJump == true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
XCTAssertEqual(viewModel.highlightedMessageId, message2.messageId)
testExpectation.fulfill()
}

wait(for: [testExpectation], timeout: 1.0)
}

func test_chatChannelVM_jumpToMessage_clearsHighlightedMessageId() {
// Given
let message1 = ChatMessage.mock()
let message2 = ChatMessage.mock()
let channelController = makeChannelController(messages: [message1, message2])
let viewModel = ChatChannelViewModel(channelController: channelController)
let testExpectation = XCTestExpectation(description: "Highlight should be cleared")

// When
_ = viewModel.jumpToMessage(messageId: message2.messageId)

// Then
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
XCTAssertNil(viewModel.highlightedMessageId)
testExpectation.fulfill()
}

wait(for: [testExpectation], timeout: 1.5)
}

func test_chatChannelVM_jumpToMessage_setsScrolledId() {
// Given
let message1 = ChatMessage.mock()
let message2 = ChatMessage.mock()
let channelController = makeChannelController(messages: [message1, message2])
let viewModel = ChatChannelViewModel(channelController: channelController)

// When
_ = viewModel.jumpToMessage(messageId: message2.messageId)

// Then
XCTAssertEqual(viewModel.scrolledId, message2.messageId)
}

func test_chatChannelVM_selectedMessageThread_opensThread() {
// Given
let channelController = makeChannelController()
let viewModel = ChatChannelViewModel(channelController: channelController)
let message = ChatMessage.mock(
id: .unique,
cid: .unique,
text: "Test message",
author: .mock(id: .unique)
)

// When
NotificationCenter.default.post(
name: NSNotification.Name(MessageRepliesConstants.selectedMessageThread),
object: nil,
userInfo: [MessageRepliesConstants.selectedMessage: message]
)

// Then
XCTAssertEqual(viewModel.threadMessage, message)
XCTAssertTrue(viewModel.threadMessageShown)
}

func test_chatChannelVM_selectedMessageThread_withThreadReplyMessage_opensThread() {
// Given
let channelController = makeChannelController()
let viewModel = ChatChannelViewModel(channelController: channelController)
let parentMessage = ChatMessage.mock(
id: .unique,
cid: .unique,
text: "Parent message",
author: .mock(id: .unique)
)
let replyMessage = ChatMessage.mock(
id: .unique,
cid: .unique,
text: "Reply message",
author: .mock(id: .unique),
parentMessageId: parentMessage.id
)

// When
NotificationCenter.default.post(
name: NSNotification.Name(MessageRepliesConstants.selectedMessageThread),
object: nil,
userInfo: [
MessageRepliesConstants.selectedMessage: parentMessage,
MessageRepliesConstants.threadReplyMessage: replyMessage
]
)

// Then
XCTAssertEqual(viewModel.threadMessage, parentMessage)
XCTAssertTrue(viewModel.threadMessageShown)
}

func test_chatChannelVM_crashWhenIndexAccess() {
// Given
let message1 = ChatMessage.mock()
Expand Down
Loading
Loading