Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -73,6 +73,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
},
onJumpToMessage: viewModel.jumpToMessage(messageId:)
)
.environment(\.highlightedMessageId, viewModel.highlightedMessageId)
.dismissKeyboardOnTap(enabled: true) {
hideComposerCommandsAndAttachmentsPicker()
}
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 @@ -9,7 +9,8 @@ import SwiftUI
public struct MessageContainerView<Factory: ViewFactory>: View {
@StateObject var messageViewModel: MessageViewModel
@Environment(\.channelTranslationLanguage) var translationLanguage

@Environment(\.highlightedMessageId) var highlightedMessageId

@Injected(\.fonts) private var fonts
@Injected(\.colors) private var colors
@Injected(\.images) private var images
Expand Down Expand Up @@ -284,7 +285,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 Expand Up @@ -398,6 +407,18 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
}
}

// Environment plumbing colocated to avoid adding new files to the package list.
private struct HighlightedMessageIdKey: EnvironmentKey {
static let defaultValue: String? = nil
}

extension EnvironmentValues {
var highlightedMessageId: String? {
get { self[HighlightedMessageIdKey.self] }
set { self[HighlightedMessageIdKey.self] = newValue }
}
}

struct SendFailureIndicator: View {
@Injected(\.colors) private var colors
@Injected(\.images) private var images
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
3 changes: 2 additions & 1 deletion Sources/StreamChatSwiftUI/DefaultViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,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
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