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
41 changes: 41 additions & 0 deletions .github/workflows/backend-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Backend Checks

on:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI
IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.5)"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
test-backend-integration:
name: Test Backend Integration
runs-on: macos-15
steps:
- uses: actions/[email protected]
- uses: ./.github/actions/bootstrap
env:
INSTALL_YEETD: true
SKIP_SWIFT_BOOTSTRAP: true
- name: Run UI Tests (Debug)
run: bundle exec fastlane test_e2e device:"${{ env.IOS_SIMULATOR_DEVICE }}"
timeout-minutes: 100
- name: Parse xcresult
if: failure()
run: |
brew install chargepoint/xcparse/xcparse
xcparse logs fastlane/test_output/StreamChatUITestsApp.xcresult fastlane/test_output/logs/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: Test Data Backend Integration
path: |
fastlane/recordings
fastlane/sinatra_log.txt
fastlane/test_output/logs/*/Diagnostics/**/*.txt
fastlane/test_output/logs/*/Diagnostics/simctl_diagnostics/DiagnosticReports/*
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.93.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.93.0)
_November 18, 2025_

## StreamChat
### ✅ Added
- Add `ChatClientConfig.isAutomaticSyncOnReconnectEnabled` for toggling automatic syncing [#3879](https://github.com/GetStream/stream-chat-swift/pull/3879)
### ⚡️ Performance
- Reduce SDK size by converting Events from structs to classes [#3878](https://github.com/GetStream/stream-chat-swift/pull/3878)
### 🔄 Changed
- Change Events from structs to classes [#3878](https://github.com/GetStream/stream-chat-swift/pull/3878)

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

Expand Down
142 changes: 140 additions & 2 deletions DemoApp/Screens/UserProfile/UserProfileViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import StreamChat
import SwiftUI
import UIKit

class UserProfileViewController: UITableViewController, CurrentChatUserControllerDelegate {
class UserProfileViewController: UITableViewController, CurrentChatUserControllerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let imageView = UIImageView()
private let updateButton = UIButton()
private let loadingSpinner = UIActivityIndicatorView(style: .medium)

var name: String?
let properties = UserProperty.allCases
Expand Down Expand Up @@ -42,28 +43,39 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
tableView.allowsSelection = false
view.backgroundColor = .systemBackground

[imageView, updateButton].forEach {
[imageView, updateButton, loadingSpinner].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}

tableView.tableHeaderView = UIView(frame: .init(origin: .zero, size: .init(width: .zero, height: 80)))
tableView.tableHeaderView?.addSubview(imageView)
tableView.tableHeaderView?.addSubview(loadingSpinner)
tableView.tableFooterView = UIView(frame: .init(origin: .zero, size: .init(width: .zero, height: 80)))
tableView.tableFooterView?.addSubview(updateButton)

imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = 30
imageView.layer.masksToBounds = true
imageView.isUserInteractionEnabled = true

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapImageView))
imageView.addGestureRecognizer(tapGesture)

updateButton.setTitle("Update", for: .normal)
updateButton.layer.cornerRadius = 4
updateButton.backgroundColor = .systemBlue
updateButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 15, bottom: 0.0, right: 15)
updateButton.addTarget(self, action: #selector(didTapUpdateButton), for: .touchUpInside)

loadingSpinner.hidesWhenStopped = true
loadingSpinner.color = .systemGray

NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 60),
imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingSpinner.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
loadingSpinner.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
updateButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
updateButton.heightAnchor.constraint(equalToConstant: 35),
updateButton.centerYAnchor.constraint(equalTo: updateButton.superview!.centerYAnchor)
Expand Down Expand Up @@ -240,4 +252,130 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
label.sizeToFit()
return label
}

// MARK: - Avatar Change

@objc private func didTapImageView() {
let alertController = UIAlertController(title: "Change Avatar", message: nil, preferredStyle: .actionSheet)

if UIImagePickerController.isSourceTypeAvailable(.camera) {
alertController.addAction(UIAlertAction(title: "Take Photo", style: .default) { [weak self] _ in
self?.presentImagePicker(sourceType: .camera)
})
}

alertController.addAction(UIAlertAction(title: "Choose from Library", style: .default) { [weak self] _ in
self?.presentImagePicker(sourceType: .photoLibrary)
})

alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))

if let popover = alertController.popoverPresentationController {
popover.sourceView = imageView
popover.sourceRect = imageView.bounds
}

present(alertController, animated: true)
}

private func presentImagePicker(sourceType: UIImagePickerController.SourceType) {
let picker = UIImagePickerController()
picker.sourceType = sourceType
picker.delegate = self
picker.allowsEditing = true
present(picker, animated: true)
}

// MARK: - UIImagePickerControllerDelegate

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)

guard let selectedImage = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage) else {
return
}

loadingSpinner.startAnimating()

uploadImageAndUpdateProfile(selectedImage) { [weak self] error in
self?.loadingSpinner.stopAnimating()
if let error = error {
self?.showError(error)
} else {
self?.showSuccess()
}
}
}

func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}

private func uploadImageAndUpdateProfile(_ image: UIImage, completion: @escaping (Error?) -> Void) {
guard let imageData = image.pngData() else {
completion(NSError(domain: "UserProfile", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to PNG data"]))
return
}

// Create temporary file
let imageURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar_\(UUID().uuidString).png")

do {
try imageData.write(to: imageURL)
} catch {
completion(error)
return
}

let uploadingState = AttachmentUploadingState(
localFileURL: imageURL,
state: .pendingUpload,
file: .init(type: .png, size: Int64(imageData.count), mimeType: "image/png")
)

let attachment = StreamAttachment(
type: .image,
payload: imageData,
downloadingState: nil,
uploadingState: uploadingState
)

// Upload the image
currentUserController.client.upload(attachment, progress: { progress in
print("Upload progress: \(progress)")
}, completion: { [weak self] result in
// Clean up temporary file
try? FileManager.default.removeItem(at: imageURL)

switch result {
case .success(let file):
// Update user profile with new image URL
self?.currentUserController.updateUserData(imageURL: file.fileURL) { error in
completion(error)
}
case .failure(let error):
completion(error)
}
})
}

private func showError(_ error: Error) {
let alert = UIAlertController(
title: "Upload Failed",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}

private func showSuccess() {
let alert = UIAlertController(
title: "Success",
message: "Avatar updated successfully!",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ struct DemoMessageReadsInfoView: View {
ForEach(deliveredUsers, id: \.id) { user in
UserReadInfoRow(
user: user,
status: .delivered,
timestamp: getDeliveredTimestamp(for: user)
status: .delivered
)
}
}
Expand All @@ -41,8 +40,7 @@ struct DemoMessageReadsInfoView: View {
ForEach(readUsers, id: \.id) { user in
UserReadInfoRow(
user: user,
status: .read,
timestamp: getReadTimestamp(for: user)
status: .read
)
}
}
Expand Down Expand Up @@ -135,7 +133,6 @@ struct DemoMessageReadsInfoView: View {
struct UserReadInfoRow: View {
let user: ChatUser
let status: ReadStatus
let timestamp: Date?

enum ReadStatus {
case delivered
Expand Down Expand Up @@ -184,12 +181,6 @@ struct UserReadInfoRow: View {
Text(user.name ?? user.id)
.font(.headline)
.foregroundColor(.primary)

if let timestamp = timestamp {
Text(formatTimestamp(timestamp))
.font(.caption)
.foregroundColor(.secondary)
}
}

Spacer()
Expand All @@ -201,20 +192,4 @@ struct UserReadInfoRow: View {
}
.padding(.vertical, 4)
}

private func formatTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short

let calendar = Calendar.current
if calendar.isDateInToday(date) {
return "Today at \(formatter.string(from: date))"
} else if calendar.isDateInYesterday(date) {
return "Yesterday at \(formatter.string(from: date))"
} else {
formatter.dateStyle = .short
return formatter.string(from: date)
}
}
}
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ GEM
fastlane-plugin-stream_actions (0.3.101)
xctest_list (= 1.2.1)
fastlane-plugin-versioning (0.7.1)
fastlane-plugin-xcsize (1.1.0)
xcsize (= 1.1.0)
fastlane-plugin-xcsize (1.2.0)
xcsize (= 1.2.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
faye-websocket (0.12.0)
Expand Down Expand Up @@ -433,7 +433,7 @@ GEM
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
xcsize (1.1.0)
xcsize (1.2.0)
commander (>= 4.6, < 6.0)
xctest_list (1.2.1)

Expand All @@ -451,7 +451,7 @@ DEPENDENCIES
fastlane-plugin-sonarcloud_metric_kit
fastlane-plugin-stream_actions (= 0.3.101)
fastlane-plugin-versioning
fastlane-plugin-xcsize (= 1.1.0)
fastlane-plugin-xcsize (= 1.2.0)
faye-websocket
json
lefthook
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://sonarcloud.io/summary/new_code?id=GetStream_stream-chat-swift"><img src="https://sonarcloud.io/api/project_badges/measure?project=GetStream_stream-chat-swift&metric=coverage" /></a>
</p>
<p align="center">
<img id="stream-chat-label" alt="StreamChat" src="https://img.shields.io/badge/StreamChat-7.88%20MB-blue"/>
<img id="stream-chat-label" alt="StreamChat" src="https://img.shields.io/badge/StreamChat-7.25%20MB-blue"/>
<img id="stream-chat-ui-label" alt="StreamChatUI" src="https://img.shields.io/badge/StreamChatUI-4.89%20MB-blue"/>
</p>

Expand Down
24 changes: 24 additions & 0 deletions Sources/StreamChat/Config/ChatClientConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,30 @@ public struct ChatClientConfig {

/// How many hours the unsent actions should be queued for sending when the internet connection is available.
public var queuedActionsMaxHoursThreshold: Int = 12

/// Specifies, if local data is updated with subscribing to web-socket events after reconnection.
///
/// When turning this off it is up for SDK user to observe web-socket connection state for reconnection
/// and syncing data by calling, for example, controller's `synchronize` or state-layer's `get` methods.
/// Note that, these calls fetch the latest state and also subscribe to web-socket events if query's `watch` is true.
///
/// Web-socket connection status can be updated either with subscribe method or through ``ChatConnectionController``.
/// ```swift
/// connectionCancellable = chatClient.subscribe(
/// toEvent: ConnectionStatusUpdated.self,
/// handler: { connectionEvent in
/// switch connectionEvent.connectionStatus {
/// case .connected:
/// // Data can be updated and watching can be resumed
/// default:
/// break
/// }
/// }
/// )
/// ```
///
/// - SeeAlso: Query option``QueryOptions/watch`` used by ``ChannelListQuery`` and ``ChannelQuery``.
public var isAutomaticSyncOnReconnectEnabled = true

public init(
apiKey: APIKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import Foundation

extension SystemEnvironment {
/// A Stream Chat version.
public static let version: String = "4.92.0"
public static let version: String = "4.93.0"
}
2 changes: 1 addition & 1 deletion Sources/StreamChat/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>4.92.0</string>
<string>4.93.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
Expand Down
Loading
Loading