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
23 changes: 23 additions & 0 deletions .github/actions/ci-guard/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Guard
description: >
Loads the current PR title (not the stale event payload) and cancels the workflow when it
contains skip-ci, case-insensitive. No-op when the event is not pull_request.
inputs:
github_token:
description: Token with actions:write and pull-requests:read
required: true
runs:
using: composite
steps:
- run: |
set -euo pipefail
if [ "${{ github.event_name }}" != "pull_request" ]; then
exit 0
fi
PR_TITLE=$(gh pr view ${{ github.event.pull_request.number }} --repo "${{ github.repository }}" --json title --jq .title)
if printf '%s' "$PR_TITLE" | grep -Fqi 'skip-ci'; then
gh run cancel "${{ github.run_id }}" --repo "${{ github.repository }}"
fi
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
9 changes: 7 additions & 2 deletions .github/workflows/sdk-size-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,22 @@ jobs:
sdk_size:
name: Metrics
runs-on: macos-15
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_GITHUB_TOKEN }}'
GITHUB_PR_NUM: ${{ github.event.pull_request.number }}
steps:
- uses: actions/checkout@v3.1.0

- uses: ./.github/actions/ci-guard
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Connect Bot
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}

- uses: actions/checkout@v3.1.0

- uses: ./.github/actions/bootstrap

- name: Run General SDK Size Metrics
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/smoke-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,19 @@ env:
GITHUB_PR_NUM: ${{ github.event.pull_request.number }}

jobs:
guard:
name: Guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
- uses: ./.github/actions/ci-guard
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

build-test-app-and-frameworks:
name: Build Test App and Frameworks
runs-on: macos-15
needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
steps:
- uses: actions/checkout@v4.1.1
Expand All @@ -49,6 +59,7 @@ jobs:
automated-code-review:
name: Automated Code Review
runs-on: macos-14
needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
XCODE_VERSION: "16.1"
Expand All @@ -65,6 +76,7 @@ jobs:
build-old-xcode:
name: Build SDKs (Old Xcode)
runs-on: macos-14
needs: guard
if: ${{ github.event.inputs.record_snapshots != 'true' }}
env:
XCODE_VERSION: "16.1"
Expand All @@ -83,6 +95,7 @@ jobs:
test-ui-debug:
name: Test SwiftUI (Debug)
runs-on: macos-15
needs: guard
env:
GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} # to open a PR
steps:
Expand Down Expand Up @@ -124,7 +137,9 @@ jobs:
name: Launch Allure TestOps
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
needs: build-test-app-and-frameworks
needs:
- guard
- build-test-app-and-frameworks
outputs:
launch_id: ${{ steps.get_launch_id.outputs.launch_id }}
steps:
Expand All @@ -144,6 +159,7 @@ jobs:
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
needs:
- guard
- allure_testops_launch
- build-test-app-and-frameworks
env:
Expand Down Expand Up @@ -198,7 +214,9 @@ jobs:
name: Build Demo App
runs-on: macos-15
if: ${{ github.event.inputs.record_snapshots != 'true' }}
needs: build-test-app-and-frameworks
needs:
- guard
- build-test-app-and-frameworks
steps:
- uses: actions/checkout@v4.1.1
- uses: actions/download-artifact@v4
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🔄 Changed

# [5.1.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.1.0)
_April 23, 2026_

### 🔄 Changed
- `CDNRequester` is now passed in the constructor of `StreamMediaLoader` instead of `Utils` [#1425](https://github.com/GetStream/stream-chat-swiftui/pull/1425)

### 🐞 Fixed
- Fix voice recording gesture and "hold to record" tip firing while the mic button is hidden [#1433](https://github.com/GetStream/stream-chat-swiftui/pull/1433)
- Fix swipe-to-reply gesture conflicting with message list scrolling [#1431](https://github.com/GetStream/stream-chat-swiftui/pull/1431)
- Fix double grey checkmarks not showing for delivered messages in the message list [#1432](https://github.com/GetStream/stream-chat-swiftui/pull/1432)
- Fix SDK not compiling with Xcode 16 [#1430](https://github.com/GetStream/stream-chat-swiftui/pull/1430)
- Fix show/hide message translation animation [#1426](https://github.com/GetStream/stream-chat-swiftui/pull/1426)
- Fix tapping a media attachment in the reactions overlay opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
- Fix empty space around the previewed message in the reactions overlay not dismissing the overlay [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
- Fix long-pressing a message with attachments occasionally opening the fullscreen gallery [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)
- Fix image attachments briefly showing a loading indicator when reopening a cached image [#1424](https://github.com/GetStream/stream-chat-swiftui/pull/1424)

# [5.0.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/5.0.0)
_April 16, 2026_

Expand Down
77 changes: 71 additions & 6 deletions DemoAppSwiftUI/ChannelHeader/NewChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import Dispatch
import StreamChat
import StreamChatCommonUI
import StreamChatSwiftUI
Expand All @@ -12,7 +13,7 @@ import SwiftUI

@Published var searchText: String = "" {
didSet {
searchUsers(with: searchText)
scheduleDebouncedUserSearch()
}
}

Expand Down Expand Up @@ -49,11 +50,29 @@ import SwiftUI
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
private let lastSeenDateFormatter = DateUtils.timeAgo

/// Matches UIKit demo `CreateChatViewController.throttleTime` / `CreateGroupViewController.throttleTime`.
private let userSearchDebounceMilliseconds = 1000
private var userSearchDebounceWorkItem: DispatchWorkItem?
private var userSearchRequestGeneration: UInt64 = 0

private struct ActiveUserSearch: Equatable {
var apiTerm: String?
}

/// Last user-list query that finished successfully (`apiTerm == nil` is “all users”).
private enum UserListFetchCursor: Equatable {
case notYetFetched
case fetched(apiTerm: String?)
}

private var activeUserSearch: ActiveUserSearch?
private var userListFetchCursor: UserListFetchCursor = .notYetFetched

init() {
chatUsers = searchController.userArray
searchController.delegate = self
// Empty initial search to get all users
searchUsers(with: nil)
// Empty initial search to get all users (immediate — not debounced; same as UIKit `viewDidLoad`)
performUserSearch(term: nil)
}

func userTapped(_ user: ChatUser) {
Expand Down Expand Up @@ -117,17 +136,63 @@ import SwiftUI

// MARK: - private

private func searchUsers(with term: String?) {
private func scheduleDebouncedUserSearch() {
let nextTerm = normalizedUserSearchTerm(searchText)
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
return
}
if case let .fetched(prev) = userListFetchCursor,
matchesUserSearchTerm(prev, nextTerm),
state != .error {
return
}

state = .loading
userSearchDebounceWorkItem?.cancel()
let query = searchText
let work = DispatchWorkItem { [weak self] in
self?.performUserSearch(term: query.isEmpty ? nil : query)
}
userSearchDebounceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
execute: work
)
}

private func performUserSearch(term: String?) {
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
return
}
activeUserSearch = ActiveUserSearch(apiTerm: term)
userSearchRequestGeneration += 1
let generation = userSearchRequestGeneration
state = .loading
searchController.search(term: term) { [weak self] error in
guard let self else { return }
guard generation == self.userSearchRequestGeneration else { return }
self.activeUserSearch = nil
if error != nil {
self?.state = .error
self.state = .error
} else {
self?.state = .loaded
self.userListFetchCursor = .fetched(apiTerm: term)
self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
}
}
}

private func normalizedUserSearchTerm(_ text: String) -> String? {
text.isEmpty ? nil : text
}

private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
switch (lhs, rhs) {
case (nil, nil): true
case let (l?, r?): l == r
default: false
}
}

private func makeChannelController() throws {
let selectedUserIds = Set(selectedUsers.map(\.id))
channelController = try chatClient.channelController(
Expand Down
76 changes: 70 additions & 6 deletions DemoAppSwiftUI/CreateGroupViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import Dispatch
import StreamChat
import StreamChatCommonUI
import StreamChatSwiftUI
Expand All @@ -14,7 +15,7 @@ import SwiftUI

@Published var searchText = "" {
didSet {
searchUsers(with: searchText)
scheduleDebouncedUserSearch()
}
}

Expand All @@ -28,11 +29,28 @@ import SwiftUI
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
private let lastSeenDateFormatter = DateUtils.timeAgo

/// Matches UIKit demo `CreateGroupViewController.throttleTime`.
private let userSearchDebounceMilliseconds = 1000
private var userSearchDebounceWorkItem: DispatchWorkItem?
private var userSearchRequestGeneration: UInt64 = 0

private struct ActiveUserSearch: Equatable {
var apiTerm: String?
}

private enum UserListFetchCursor: Equatable {
case notYetFetched
case fetched(apiTerm: String?)
}

private var activeUserSearch: ActiveUserSearch?
private var userListFetchCursor: UserListFetchCursor = .notYetFetched

init() {
chatUsers = searchController.userArray
searchController.delegate = self
// Empty initial search to get all users
searchUsers(with: nil)
// Empty initial search to get all users (immediate — not debounced)
performUserSearch(term: nil)
}

var canCreateGroup: Bool {
Expand Down Expand Up @@ -98,14 +116,60 @@ import SwiftUI

// MARK: - private

private func searchUsers(with term: String?) {
private func scheduleDebouncedUserSearch() {
let nextTerm = normalizedUserSearchTerm(searchText)
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, nextTerm) {
return
}
if case let .fetched(prev) = userListFetchCursor,
matchesUserSearchTerm(prev, nextTerm),
state != .error {
return
}

state = .loading
userSearchDebounceWorkItem?.cancel()
let query = searchText
let work = DispatchWorkItem { [weak self] in
self?.performUserSearch(term: query.isEmpty ? nil : query)
}
userSearchDebounceWorkItem = work
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(userSearchDebounceMilliseconds),
execute: work
)
}

private func performUserSearch(term: String?) {
if let active = activeUserSearch, matchesUserSearchTerm(active.apiTerm, term) {
return
}
activeUserSearch = ActiveUserSearch(apiTerm: term)
userSearchRequestGeneration += 1
let generation = userSearchRequestGeneration
state = .loading
searchController.search(term: term) { [weak self] error in
guard let self else { return }
guard generation == self.userSearchRequestGeneration else { return }
self.activeUserSearch = nil
if error != nil {
self?.state = .error
self.state = .error
} else {
self?.state = .loaded
self.userListFetchCursor = .fetched(apiTerm: term)
self.state = self.chatUsers.isEmpty ? .noUsers : .loaded
}
}
}

private func normalizedUserSearchTerm(_ text: String) -> String? {
text.isEmpty ? nil : text
}

private func matchesUserSearchTerm(_ lhs: String?, _ rhs: String?) -> Bool {
switch (lhs, rhs) {
case (nil, nil): true
case let (l?, r?): l == r
default: false
}
}
}
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: "5.0.0")
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.1.0")
],
targets: [
.target(
Expand Down
Loading
Loading