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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### πŸ”„ Changed

# [4.92.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.92.0)
_November 07, 2025_

### βœ… Added
- Add message highlighting on jumping to a quoted message [#1032](https://github.com/GetStream/stream-chat-swiftui/pull/1032)
- Display double grey checkmark when delivery events are enabled [#1038](https://github.com/GetStream/stream-chat-swiftui/pull/1038)

### 🐞 Fixed
- Fix composer deleting newly entered text after deleting draft text [#1030](https://github.com/GetStream/stream-chat-swiftui/pull/1030)
- Fix mark unread action not shown for messages that are root of a thread in the channel view [#1041](https://github.com/GetStream/stream-chat-swiftui/pull/1041)

# [4.91.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.91.0)
_October 22, 2025_

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.91.0")
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "4.92.0")
],
targets: [
.target(
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<p align="center">
<a href="https://sonarcloud.io/summary/new_code?id=GetStream_stream-chat-swiftui"><img src="https://sonarcloud.io/api/project_badges/measure?project=GetStream_stream-chat-swiftui&metric=coverage" /></a>

<img id="stream-chat-swiftui-label" alt="StreamChatSwiftUI" src="https://img.shields.io/badge/StreamChatSwiftUI-9.58%20MB-blue"/>
<img id="stream-chat-swiftui-label" alt="StreamChatSwiftUI" src="https://img.shields.io/badge/StreamChatSwiftUI-9.63%20MB-blue"/>
</p>

## SwiftUI StreamChat SDK
Expand Down Expand Up @@ -39,6 +39,17 @@ The SwiftUI SDK offers three types of components:
- Stateful components - Offer more customization options and possibility to inject custom views. Also fairly simple to integrate, if the extension points are suitable for your chat use-case. These components come with view models.
- Stateless components - These are the building blocks for the other two types of components. In order to use them, you would have to provide the state and data. Using these components only make sense if you want to implement completely custom chat experience.

## Documentation Generation

To generate the documentation for SwiftUI StreamChat SDK, run the following command:

```bash
xcodebuild docbuild -skipMacroValidation -skipPackagePluginValidation -derivedDataPath .derivedData -scheme StreamChatSwiftUI -destination generic/platform=iOS | xcpretty
open .derivedData/Build/Products/Debug-iphoneos/StreamChatSwiftUI.doccarchive
```

This will build the documentation archive and automatically open it in Xcode.

## Free for Makers

Stream is free for most side and hobby projects. You can use Stream Chat for free if you have less than five team members and no more than $10,000 in monthly revenue.
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,7 +56,11 @@ 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
// A boolean to skip highlighting of a message when scrolling to it.
// This is used for scenarios when scrolling to message Id should not highlight it.
var skipHighlightMessageId: String?

@Published public var showScrollToLatestButton = false
@Published var showAlertBanner = false
Expand Down Expand Up @@ -172,6 +176,11 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
self?.messageCachingUtils.jumpToReplyId = scrollToMessage.messageId
} else if messageController != nil, let jumpToReplyId = self?.messageCachingUtils.jumpToReplyId {
self?.scrolledId = jumpToReplyId
// Clear scroll ID after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.scrolledId = nil
}
self?.highlightMessage(withId: jumpToReplyId)
self?.messageCachingUtils.jumpToReplyId = nil
} else if messageController == nil {
self?.scrolledId = scrollToMessage?.messageId
Expand Down Expand Up @@ -232,6 +241,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 +312,11 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if scrolledId == nil {
scrolledId = messageId
}
// Clear scroll ID after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
self?.scrolledId = nil
}
highlightMessage(withId: messageId)
return true
} else {
let message = channelController.dataStore.message(id: baseId)
Expand All @@ -325,16 +342,36 @@ 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
self?.highlightMessage(withId: toJumpId)
}
}
return false
}
}
}


/// Highlights the message background.
///
/// - Parameter messageId: The ID of the message to highlight.
public func highlightMessage(withId messageId: MessageId) {
if skipHighlightMessageId == messageId {
skipHighlightMessageId = nil
return
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.highlightedMessageId = messageId
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
withAnimation {
self?.highlightedMessageId = nil
}
}
}

open func handleMessageAppear(index: Int, scrollDirection: ScrollDirection) {
if index >= channelDataSource.messages.count || loadingMessagesAround {
return
Expand Down Expand Up @@ -519,7 +556,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

// MARK: - private

private func checkForOlderMessages(index: Int) {
guard index >= channelDataSource.messages.count - 25 else { return }
guard !loadingPreviousMessages else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -908,14 +908,6 @@ extension MessageComposerViewModel: EventsControllerDelegate {
fillDraftMessage()
}
}

if let event = event as? DraftDeletedEvent {
let isFromSameThread = messageController?.messageId == event.threadId
let isFromSameChannel = channelController.cid == event.cid && messageController == nil
if isFromSameThread || isFromSameChannel {
clearInputData()
}
}
}
}

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,17 @@ 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 utils.messageListConfig.highlightMessageWhenJumping,
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 +409,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 @@ -26,6 +26,7 @@ public struct MessageListConfig {
iPadSplitViewEnabled: Bool = true,
scrollingAnchor: UnitPoint = .center,
showNewMessagesSeparator: Bool = true,
highlightMessageWhenJumping: Bool = true,
handleTabBarVisibility: Bool = true,
messageListAlignment: MessageListAlignment = .standard,
uniqueReactionsEnabled: Bool = false,
Expand Down Expand Up @@ -57,6 +58,7 @@ public struct MessageListConfig {
self.iPadSplitViewEnabled = iPadSplitViewEnabled
self.scrollingAnchor = scrollingAnchor
self.showNewMessagesSeparator = showNewMessagesSeparator
self.highlightMessageWhenJumping = highlightMessageWhenJumping
self.handleTabBarVisibility = handleTabBarVisibility
self.messageListAlignment = messageListAlignment
self.uniqueReactionsEnabled = uniqueReactionsEnabled
Expand Down Expand Up @@ -121,6 +123,11 @@ public struct MessageListConfig {

/// A boolean value that determines if download action is shown for file attachments.
public let downloadFileAttachmentsEnabled: Bool

/// Highlights the message background when jumping to a message.
///
/// By default it is enabled and it uses the color from `ColorPalette.messageCellHighlightBackground`.
public let highlightMessageWhenJumping: Bool
}

/// Contains information about the message paddings.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,18 @@ public struct MessageReadIndicatorView: View {

var readUsers: [ChatUser]
var showReadCount: Bool
var showDelivered: Bool
var localState: LocalMessageState?

public init(readUsers: [ChatUser], showReadCount: Bool, localState: LocalMessageState? = nil) {
public init(
readUsers: [ChatUser],
showReadCount: Bool,
showDelivered: Bool = false,
localState: LocalMessageState? = nil
) {
self.readUsers = readUsers
self.showReadCount = showReadCount
self.showDelivered = showDelivered
self.localState = localState
}

Expand Down Expand Up @@ -135,7 +142,7 @@ public struct MessageReadIndicatorView: View {
}

private var image: UIImage {
shouldShowReads ? images.readByAll : (isMessageSending ? images.messageReceiptSending : images.messageSent)
shouldShowReads || showDelivered ? images.readByAll : (isMessageSending ? images.messageReceiptSending : images.messageSent)
}

private var isMessageSending: Bool {
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
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,14 @@ public extension MessageAction {
messageActions.append(copyAction)
}

if message.isRootOfThread {
if isInsideThreadView {
let markThreadUnreadAction = markThreadAsUnreadAction(
messageController: messageController,
message: message,
onFinish: onFinish,
onError: onError
)
messageActions.append(markThreadUnreadAction)
}
if message.isRootOfThread && isInsideThreadView {
let markThreadUnreadAction = markThreadAsUnreadAction(
messageController: messageController,
message: message,
onFinish: onFinish,
onError: onError
)
messageActions.append(markThreadUnreadAction)
} else if !message.isSentByCurrentUser && channel.canReceiveReadEvents {
if !message.isPartOfThread || message.showReplyInChannel {
let markUnreadAction = markAsUnreadAction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public class MessageActionsResolver: MessageActionsResolving {
} else if info.identifier == MessageActionId.markUnread {
viewModel.firstUnreadMessageId = info.message.messageId
viewModel.currentUserMarkedMessageUnread = true
viewModel.skipHighlightMessageId = info.message.messageId
viewModel.scrolledId = info.message.messageId
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ public struct ChatChannelListItem<Factory: ViewFactory>: View {
MessageReadIndicatorView(
readUsers: channel.readUsers(
currentUserId: chatClient.currentUserId,
message: channel.latestMessages.first
message: channel.previewMessage
),
showReadCount: false
showReadCount: false,
showDelivered: channel.previewMessage?.deliveryStatus(for: channel) == .delivered
)
}
SubtitleText(text: injectedChannelInfo?.timestamp ?? channel.timestampText)
Expand Down Expand Up @@ -159,9 +160,8 @@ public struct ChatChannelListItem<Factory: ViewFactory>: View {
}

private var shouldShowReadEvents: Bool {
if let message = channel.latestMessages.first,
message.isSentByCurrentUser,
!message.isDeleted {
if let message = channel.previewMessage,
message.isSentByCurrentUser {
return channel.config.readEventsEnabled
}

Expand Down
Loading
Loading