diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml
index a6015f88f6d..ccb73f2574c 100644
--- a/.github/actions/bootstrap/action.yml
+++ b/.github/actions/bootstrap/action.yml
@@ -22,6 +22,7 @@ runs:
~/Library/Caches/Homebrew/mint*
~/Library/Caches/Homebrew/xcparse*
~/Library/Caches/Homebrew/sonar-scanner*
+ ~/Library/Caches/Homebrew/google-cloud-sdk*
key: ${{ env.IMAGE }}-brew-${{ hashFiles('**/Brewfile.lock.json') }}
restore-keys: ${{ env.IMAGE }}-brew-
- uses: ./.github/actions/ruby-cache
diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml
index 1130177282c..caec8a8caca 100644
--- a/.github/workflows/cron-checks.yml
+++ b/.github/workflows/cron-checks.yml
@@ -71,11 +71,11 @@ jobs:
runs-on: ${{ matrix.os }}
env:
GITHUB_EVENT: ${{ toJson(github.event) }}
- GITHUB_PR_NUM: ${{ github.event.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }}
STREAM_DEMO_APP_SECRET: ${{ secrets.STREAM_DEMO_APP_SECRET }}
XCODE_VERSION: ${{ matrix.xcode }}
+ IOS_SIMULATOR_DEVICE: "${{ matrix.device }} (${{ matrix.ios }})" # For the Allure report
steps:
- uses: actions/checkout@v4.1.1
- uses: actions/download-artifact@v3
@@ -166,7 +166,6 @@ jobs:
runs-on: ${{ matrix.os }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_PR_NUM: ${{ github.event.number }}
XCODE_VERSION: ${{ matrix.xcode }}
steps:
- uses: actions/checkout@v4.1.1
diff --git a/.github/workflows/xcmetrics.yml b/.github/workflows/xcmetrics.yml
new file mode 100644
index 00000000000..a58e026eb6d
--- /dev/null
+++ b/.github/workflows/xcmetrics.yml
@@ -0,0 +1,58 @@
+name: Performance Benchmarks
+
+on:
+ schedule:
+ # Runs "At 03:00 every night"
+ - cron: '0 3 * * *'
+
+ pull_request:
+ types:
+ - opened
+ - ready_for_review
+
+ workflow_dispatch:
+
+env:
+ HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI
+
+jobs:
+ xcmetrics:
+ name: XCMetrics
+ runs-on: macos-14
+ env:
+ GITHUB_TOKEN: '${{ secrets.CI_BOT_GITHUB_TOKEN }}'
+ steps:
+ - name: Install Bot SSH Key
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
+ uses: webfactory/ssh-agent@v0.7.0
+ with:
+ ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}
+
+ - uses: actions/checkout@v3.1.0
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
+ with:
+ fetch-depth: 0 # to fetch git tags
+
+ - uses: ./.github/actions/bootstrap
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
+ env:
+ GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
+ INSTALL_GCLOUD: true
+
+ - name: Run Performance Metrics
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
+ run: bundle exec fastlane xcmetrics
+ timeout-minutes: 120
+ env:
+ GITHUB_PR_NUM: ${{ github.event.pull_request.number }}
+ BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
+ MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
+ APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
+
+ - uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: Test Data
+ path: |
+ derived_data/Build/Products/xcodebuild_output.log
+ fastlane/performance/stream-chat-swift.json
diff --git a/.gitignore b/.gitignore
index 63442a9c928..b5308d116cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,6 +72,7 @@ fastlane/test_output
fastlane/allurectl
fastlane/xcresults
fastlane/recordings
+fastlane/performance
StreamChatCore.framework.coverage.txt
StreamChatCoreTests.xctest.coverage.txt
vendor/bundle/
@@ -90,6 +91,11 @@ spm_cache/
.buildcache
buildcache
+# gcloud
+google-cloud-sdk
+gcloud.tar.gz
+gcloud-service-account-key.json
+
# Ignore Products folder
Products/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43faec01aef..06dc538e47b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### š Changed
+# [4.50.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.50.0)
+_March 11, 2024_
+
+## StreamChat
+### ā
Added
+- Add new `ChatMessage.textUpdatedAt` for when the message text is edited [#3059](https://github.com/GetStream/stream-chat-swift/pull/3059)
+- Expose `ClientError.errorPayload` to easily check for server error details [#3061](https://github.com/GetStream/stream-chat-swift/pull/3061)
+### š Fixed
+- Fix token provider retrying after calling disconnect [#3052](https://github.com/GetStream/stream-chat-swift/pull/3052)
+- Fix connect user never completing when disconnecting after token provider fails [#3052](https://github.com/GetStream/stream-chat-swift/pull/3052)
+- Fix current user cache not deleted on logout causing unread count issues after switching users [#3055](https://github.com/GetStream/stream-chat-swift/pull/3055)
+- Fix rare crash in `startObserver()` on logout when converting DTO to model in `itemCreator` [#3053](https://github.com/GetStream/stream-chat-swift/pull/3053)
+- Fix invalid token triggering token refresh in an infinite loop [#3056](https://github.com/GetStream/stream-chat-swift/pull/3056)
+- Do not mark a message as failed when the server returns duplicated message error [#3061](https://github.com/GetStream/stream-chat-swift/pull/3061)
+
+## StreamChatUI
+### ā
Added
+- Add new `Components.isMessageEditedLabelEnabled` [#3059](https://github.com/GetStream/stream-chat-swift/pull/3059)
+- Add "Edited" label when a message is edited [#3059](https://github.com/GetStream/stream-chat-swift/pull/3059)
+ - Note: For now, only when the text changes it is marked as edited.
+ - Add `message.edited` localization key [#3059](https://github.com/GetStream/stream-chat-swift/pull/3059)
+
# [4.49.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.49.0)
_February 27, 2024_
diff --git a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift
index 652fb512db2..9c7bcf7b281 100644
--- a/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift
+++ b/DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift
@@ -23,10 +23,14 @@ struct DemoAppConfig {
/// The details to generate expirable tokens in the demo app.
struct TokenRefreshDetails {
- // The app secret from the dashboard.
+ /// The app secret from the dashboard.
let appSecret: String
- // The duration in seconds until the token is expired.
+ /// The duration in seconds until the token is expired.
let duration: TimeInterval
+ /// In order to test token refresh fails, we can set a value of how
+ /// many token refresh will succeed before it starts failing.
+ /// By default it is 0. Which means it will always succeed.
+ let numberOfSuccessfulRefreshesBeforeFailing: Int
}
}
@@ -564,18 +568,33 @@ class AppConfigViewController: UITableViewController {
textField.placeholder = "App Secret"
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
+ if let appSecret = self.demoAppConfig.tokenRefreshDetails?.appSecret {
+ textField.text = appSecret
+ }
}
alert.addTextField { textField in
textField.placeholder = "Duration (Seconds)"
textField.keyboardType = .numberPad
+ if let duration = self.demoAppConfig.tokenRefreshDetails?.duration {
+ textField.text = "\(duration)"
+ }
+ }
+ alert.addTextField { textField in
+ textField.placeholder = "Number of Refreshes Before Failing"
+ textField.keyboardType = .numberPad
+ if let numberOfRefreshes = self.demoAppConfig.tokenRefreshDetails?.numberOfSuccessfulRefreshesBeforeFailing {
+ textField.text = "\(numberOfRefreshes)"
+ }
}
alert.addAction(.init(title: "Enable", style: .default, handler: { _ in
guard let appSecret = alert.textFields?[0].text else { return }
guard let duration = alert.textFields?[1].text else { return }
+ guard let successfulRetries = alert.textFields?[2].text else { return }
self.demoAppConfig.tokenRefreshDetails = .init(
appSecret: appSecret,
- duration: TimeInterval(duration)!
+ duration: TimeInterval(duration) ?? 60,
+ numberOfSuccessfulRefreshesBeforeFailing: Int(successfulRetries) ?? 0
)
}))
diff --git a/DemoApp/Shared/StreamChatWrapper.swift b/DemoApp/Shared/StreamChatWrapper.swift
index 7e6314d5e35..d50eb4b26fe 100644
--- a/DemoApp/Shared/StreamChatWrapper.swift
+++ b/DemoApp/Shared/StreamChatWrapper.swift
@@ -10,6 +10,10 @@ import UserNotifications
final class StreamChatWrapper {
static let shared = StreamChatWrapper()
+ /// How many times the token has been refreshed. This is mostly used
+ /// to fake token refresh fails.
+ var numberOfRefreshTokens = 0
+
// This closure is called once the SDK is ready to register for remote push notifications
var onRemotePushRegistration: (() -> Void)?
@@ -60,8 +64,7 @@ extension StreamChatWrapper {
userInfo: userInfo,
tokenProvider: refreshingTokenProvider(
initialToken: userCredentials.token,
- appSecret: tokenRefreshDetails.appSecret,
- tokenDuration: tokenRefreshDetails.duration
+ refreshDetails: tokenRefreshDetails
),
completion: completion
)
@@ -84,6 +87,9 @@ extension StreamChatWrapper {
// Setup Stream Chat
setUpChat()
+ // Reset number of refresh tokens
+ numberOfRefreshTokens = 0
+
// We connect from a background thread to make sure it works without issues/crashes.
// This is for testing purposes only. As a customer you can connect directly without dispatching to any queue.
DispatchQueue.global().async {
@@ -115,8 +121,6 @@ extension StreamChatWrapper {
}
client.logout(completion: completion)
-
- self.client = nil
}
}
diff --git a/DemoApp/Shared/Token+Development.swift b/DemoApp/Shared/Token+Development.swift
index 7dc081b10ff..38cd9c0bda9 100644
--- a/DemoApp/Shared/Token+Development.swift
+++ b/DemoApp/Shared/Token+Development.swift
@@ -9,8 +9,7 @@ import StreamChat
extension StreamChatWrapper {
func refreshingTokenProvider(
initialToken: Token,
- appSecret: String,
- tokenDuration: TimeInterval
+ refreshDetails: DemoAppConfig.TokenRefreshDetails
) -> TokenProvider {
{ completion in
// Simulate API call delay
@@ -19,20 +18,27 @@ extension StreamChatWrapper {
if #available(iOS 13.0, *) {
generatedToken = _generateUserToken(
- secret: appSecret,
+ secret: refreshDetails.appSecret,
userID: initialToken.userId,
- expirationDate: Date().addingTimeInterval(tokenDuration)
+ expirationDate: Date().addingTimeInterval(refreshDetails.duration)
)
}
if generatedToken == nil {
- print("Demo App Token Refreshing: Unable to generate token.")
- } else {
- print("Demo App Token Refreshing: New token generated.")
+ log.error("Demo App Token Refreshing: Unable to generate token. Using initialToken instead.")
}
- let newToken = generatedToken ?? initialToken
- completion(.success(newToken))
+ let numberOfSuccessfulRefreshes = refreshDetails.numberOfSuccessfulRefreshesBeforeFailing
+ let shouldNotFail = numberOfSuccessfulRefreshes == 0
+ if shouldNotFail || self.numberOfRefreshTokens <= numberOfSuccessfulRefreshes {
+ print("Demo App Token Refreshing: New token generated successfully.")
+ let newToken = generatedToken ?? initialToken
+ completion(.success(newToken))
+ } else {
+ print("Demo App Token Refreshing: Token refresh failed.")
+ completion(.failure(ClientError("Token Refresh Failed")))
+ }
+ self.numberOfRefreshTokens += 1
}
}
}
diff --git a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
index 55b303a9508..cff434a5a16 100644
--- a/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
+++ b/DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
@@ -15,6 +15,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
var channelPresentingStyle: ChannelPresentingStyle = .push
var onLogout: (() -> Void)?
+ var onDisconnect: (() -> Void)?
lazy var streamModalTransitioningDelegate = StreamModalTransitioningDelegate()
@@ -36,6 +37,9 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}),
.init(title: "Logout", style: .destructive, handler: { [weak self] _ in
self?.onLogout?()
+ }),
+ .init(title: "Disconnect", style: .destructive, handler: { [weak self] _ in
+ self?.onDisconnect?()
})
])
}
diff --git a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift
index 0fed6793a07..c29a8efd118 100644
--- a/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift
+++ b/DemoApp/StreamChat/DemoAppCoordinator+DemoApp.swift
@@ -20,10 +20,16 @@ extension DemoAppCoordinator {
func showChat(for user: DemoUserType, cid: ChannelId?, animated: Bool, completion: @escaping (Error?) -> Void) {
logIn(as: user, completion: completion)
- let chatVC = makeChatVC(for: user, startOn: cid) { [weak self] in
- guard let self = self else { return }
- self.logOut()
- }
+ let chatVC = makeChatVC(
+ for: user,
+ startOn: cid,
+ onLogout: { [weak self] in
+ self?.logOut()
+ },
+ onDisconnect: { [weak self] in
+ self?.disconnect()
+ }
+ )
set(rootViewController: chatVC, animated: animated)
DemoAppConfiguration.showPerformanceTracker()
@@ -58,7 +64,12 @@ extension DemoAppCoordinator {
return nil
}
- func makeChatVC(for user: DemoUserType, startOn cid: ChannelId?, onLogout: @escaping () -> Void) -> UIViewController {
+ func makeChatVC(
+ for user: DemoUserType,
+ startOn cid: ChannelId?,
+ onLogout: @escaping () -> Void,
+ onDisconnect: @escaping () -> Void
+ ) -> UIViewController {
// Construct channel list query
let channelListQuery: ChannelListQuery
switch user {
@@ -90,7 +101,8 @@ extension DemoAppCoordinator {
let channelListVC = makeChannelListVC(
controller: channelListController,
selectedChannel: selectedChannel,
- onLogout: onLogout
+ onLogout: onLogout,
+ onDisconnect: onDisconnect
)
let channelListNVC = UINavigationController(rootViewController: channelListVC)
@@ -109,10 +121,12 @@ extension DemoAppCoordinator {
func makeChannelListVC(
controller: ChatChannelListController,
selectedChannel: ChatChannel?,
- onLogout: @escaping () -> Void
+ onLogout: @escaping () -> Void,
+ onDisconnect: @escaping () -> Void
) -> UIViewController {
let channelListVC = DemoChatChannelListVC.make(with: controller)
channelListVC.demoRouter?.onLogout = onLogout
+ channelListVC.demoRouter?.onDisconnect = onDisconnect
channelListVC.selectedChannel = selectedChannel
channelListVC.components.isChatChannelListStatesEnabled = true
return channelListVC
@@ -155,15 +169,19 @@ private extension DemoAppCoordinator {
}
func logOut() {
- // logout client
chat.logOut { [weak self] in
- // clean user id
UserDefaults.shared.currentUserId = nil
-
- // show login screen
self?.showLogin(animated: true)
}
}
+
+ func disconnect() {
+ chat.client?.disconnect { [weak self] in
+ DispatchQueue.main.async {
+ self?.showLogin(animated: true)
+ }
+ }
+ }
}
extension ChatChannel {
diff --git a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift
index e7a5cb981ae..65377c04eca 100644
--- a/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift
+++ b/DemoApp/StreamChat/StreamChatWrapper+DemoApp.swift
@@ -13,11 +13,6 @@ extension StreamChatWrapper {
Components.default.mixedAttachmentInjector.register(.location, with: LocationAttachmentViewInjector.self)
}
- guard client == nil else {
- log.error("Client was already instantiated")
- return
- }
-
// Set the log level
LogConfig.level = .warning
LogConfig.formatters = [
@@ -25,7 +20,9 @@ extension StreamChatWrapper {
]
// Create Client
- client = ChatClient(config: config)
+ if client == nil {
+ client = ChatClient(config: config)
+ }
client?.registerAttachment(LocationAttachmentPayload.self)
// L10N
@@ -44,6 +41,7 @@ extension StreamChatWrapper {
Components.default.messageListDateSeparatorEnabled = true
Components.default.messageListDateOverlayEnabled = true
Components.default.messageAutoTranslationEnabled = true
+ Components.default.isMessageEditedLabelEnabled = true
Components.default.isVoiceRecordingEnabled = true
Components.default.isJumpToUnreadEnabled = true
Components.default.messageSwipeToReplyEnabled = true
diff --git a/Gemfile.lock b/Gemfile.lock
index 9e4a59ea650..791100c9af7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -310,7 +310,7 @@ GEM
puma (6.4.2)
nio4r (~> 2.0)
racc (1.7.3)
- rack (3.0.9)
+ rack (3.0.9.1)
rack-protection (4.0.0)
base64 (>= 0.1.0)
rack (>= 3.0.0, < 4)
diff --git a/Githubfile b/Githubfile
index ddabd91c4aa..15496f60a56 100644
--- a/Githubfile
+++ b/Githubfile
@@ -3,3 +3,4 @@
export ALLURECTL_VERSION='2.15.1'
export XCRESULTS_VERSION='1.16.3'
export YEETD_VERSION='1.0'
+export GCLOUD_VERSION='464.0.0'
diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh
index 7f46b6973dd..355ca37eb2e 100755
--- a/Scripts/bootstrap.sh
+++ b/Scripts/bootstrap.sh
@@ -68,6 +68,17 @@ if [[ ${INSTALL_YEETD-default} == true ]]; then
yeetd &
fi
+if [[ ${INSTALL_GCLOUD-default} == true ]]; then
+ puts "Install gcloud"
+ brew install --cask google-cloud-sdk
+
+ # Editor access required: https://console.cloud.google.com/iam-admin/iam
+ printf "%s" "$GOOGLE_APPLICATION_CREDENTIALS" > ./fastlane/gcloud-service-account-key.json
+ gcloud auth activate-service-account --key-file="./fastlane/gcloud-service-account-key.json"
+ gcloud config set project stream-chat-swift
+ gcloud services enable toolresults.googleapis.com
+fi
+
# Vale should not be installed on CI
if [ "$GITHUB_ACTIONS" != true ]; then
brew install vale
diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift
index 28513b9d230..95cdf13ea03 100644
--- a/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift
+++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/MessagePayloads.swift
@@ -40,6 +40,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable {
case imageLabels = "image_labels"
case shadowed
case moderationDetails = "moderation_details"
+ case messageTextUpdatedAt = "message_text_updated_at"
}
extension MessagePayload {
@@ -65,6 +66,7 @@ class MessagePayload: Decodable {
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
+ let messageTextUpdatedAt: Date?
let text: String
let command: String?
let args: String?
@@ -152,6 +154,7 @@ class MessagePayload: Decodable {
translations = i18n?.translated
originalLanguage = i18n?.originalLanguage
moderationDetails = try container.decodeIfPresent(MessageModerationDetailsPayload.self, forKey: .moderationDetails)
+ messageTextUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .messageTextUpdatedAt)
}
init(
@@ -187,7 +190,8 @@ class MessagePayload: Decodable {
pinExpires: Date? = nil,
translations: [TranslationLanguage: String]? = nil,
originalLanguage: String? = nil,
- moderationDetails: MessageModerationDetailsPayload? = nil
+ moderationDetails: MessageModerationDetailsPayload? = nil,
+ messageTextUpdatedAt: Date? = nil
) {
self.id = id
self.cid = cid
@@ -222,6 +226,7 @@ class MessagePayload: Decodable {
self.translations = translations
self.originalLanguage = originalLanguage
self.moderationDetails = moderationDetails
+ self.messageTextUpdatedAt = messageTextUpdatedAt
}
}
diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift
index 21b976a427b..106c6a87ce2 100644
--- a/Sources/StreamChat/ChatClient.swift
+++ b/Sources/StreamChat/ChatClient.swift
@@ -341,6 +341,7 @@ public class ChatClient {
completion()
}
authenticationRepository.clearTokenProvider()
+ authenticationRepository.cancelTimers()
}
/// Disconnects the chat client form the chat servers and removes all the local data related.
@@ -450,9 +451,12 @@ extension ChatClient: AuthenticationRepositoryDelegate {
extension ChatClient: ConnectionStateDelegate {
func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) {
- connectionRepository.handleConnectionUpdate(state: state, onInvalidToken: { [weak self] in
- self?.refreshToken(completion: nil)
- })
+ connectionRepository.handleConnectionUpdate(
+ state: state,
+ onExpiredToken: { [weak self] in
+ self?.refreshToken(completion: nil)
+ }
+ )
connectionRecoveryHandler?.webSocketClient(client, didUpdateConnectionState: state)
}
}
diff --git a/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift
index afc7971efe1..5ac6b5ce2b5 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelConfigDTO.swift
@@ -24,8 +24,7 @@ final class ChannelConfigDTO: NSManagedObject {
@NSManaged var commands: NSOrderedSet
func asModel() throws -> ChannelConfig {
- guard isValid else { throw InvalidModel(self) }
- return .init(
+ .init(
reactionsEnabled: reactionsEnabled,
typingEventsEnabled: typingEventsEnabled,
readEventsEnabled: readEventsEnabled,
diff --git a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
index fa52a8f3cda..77f2dd83e48 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelDTO.swift
@@ -396,7 +396,7 @@ extension ChatChannel {
guard StreamRuntimeCheck._canFetchRelationship(currentDepth: depth) else {
throw RecursionLimitError()
}
- guard dto.isValid, let cid = try? ChannelId(cid: dto.cid), let context = dto.managedObjectContext else {
+ guard let cid = try? ChannelId(cid: dto.cid), let context = dto.managedObjectContext else {
throw InvalidModel(dto)
}
diff --git a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
index d4acd69a7ec..ab9d0bbc168 100644
--- a/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/ChannelReadDTO.swift
@@ -193,8 +193,7 @@ extension NSManagedObjectContext {
extension ChatChannelRead {
fileprivate static func create(fromDTO dto: ChannelReadDTO) throws -> ChatChannelRead {
- guard dto.isValid else { throw InvalidModel(dto) }
- return try .init(
+ try .init(
lastReadAt: dto.lastReadAt.bridgeDate,
lastReadMessageId: dto.lastReadMessageId,
unreadMessagesCount: Int(dto.unreadMessageCount),
diff --git a/Sources/StreamChat/Database/DTOs/CommandDTO.swift b/Sources/StreamChat/Database/DTOs/CommandDTO.swift
index b31bc92a905..5d15fa7ce6d 100644
--- a/Sources/StreamChat/Database/DTOs/CommandDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/CommandDTO.swift
@@ -13,8 +13,7 @@ final class CommandDTO: NSManagedObject {
@NSManaged var args: String
func asModel() throws -> Command {
- guard isValid else { throw InvalidModel(self) }
- return .init(
+ .init(
name: name,
description: desc,
set: set,
diff --git a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
index 284d76b0f44..c8c238c6721 100644
--- a/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/CurrentUserDTO.swift
@@ -178,7 +178,7 @@ extension CurrentUserDTO {
extension CurrentChatUser {
fileprivate static func create(fromDTO dto: CurrentUserDTO) throws -> CurrentChatUser {
- guard dto.isValid, let context = dto.managedObjectContext else { throw InvalidModel(dto) }
+ guard let context = dto.managedObjectContext else { throw InvalidModel(dto) }
let user = dto.user
let extraData: [String: RawJSON]
diff --git a/Sources/StreamChat/Database/DTOs/DeviceDTO.swift b/Sources/StreamChat/Database/DTOs/DeviceDTO.swift
index 38fd5b5f7aa..004bce6f439 100644
--- a/Sources/StreamChat/Database/DTOs/DeviceDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/DeviceDTO.swift
@@ -45,7 +45,6 @@ extension DeviceDTO {
extension DeviceDTO {
func asModel() throws -> Device {
- guard isValid else { throw InvalidModel(self) }
- return Device(id: id, createdAt: createdAt?.bridgeDate)
+ Device(id: id, createdAt: createdAt?.bridgeDate)
}
}
diff --git a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
index ccc2c0f38ac..2117e5cb93a 100644
--- a/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
@@ -162,7 +162,6 @@ extension MemberDTO {
extension ChatChannelMember {
fileprivate static func create(fromDTO dto: MemberDTO) throws -> ChatChannelMember {
- guard dto.isValid else { throw InvalidModel(dto) }
let extraData: [String: RawJSON]
do {
extraData = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.user.extraData)
diff --git a/Sources/StreamChat/Database/DTOs/MessageDTO.swift b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
index be1bbe69872..57298c08a61 100644
--- a/Sources/StreamChat/Database/DTOs/MessageDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/MessageDTO.swift
@@ -29,6 +29,7 @@ class MessageDTO: NSManagedObject {
@NSManaged var createdAt: DBDate
@NSManaged var updatedAt: DBDate
@NSManaged var deletedAt: DBDate?
+ @NSManaged var textUpdatedAt: DBDate?
@NSManaged var isHardDeleted: Bool
@NSManaged var args: String?
@NSManaged var parentMessageId: MessageId?
@@ -658,6 +659,7 @@ extension NSManagedObjectContext: MessageDatabaseSession {
dto.createdAt = payload.createdAt.bridgeDate
dto.updatedAt = payload.updatedAt.bridgeDate
dto.deletedAt = payload.deletedAt?.bridgeDate
+ dto.textUpdatedAt = payload.messageTextUpdatedAt?.bridgeDate
dto.type = payload.type.rawValue
dto.command = payload.command
dto.args = payload.args
@@ -1081,6 +1083,17 @@ extension MessageDTO {
extraData: decodedExtraData
)
}
+
+ /// The message has been successfully sent to the server.
+ func markMessageAsSent() {
+ locallyCreatedAt = nil
+ localMessageState = nil
+ }
+
+ /// The message failed to be sent to the server.
+ func markMessageAsFailed() {
+ localMessageState = .sendingFailed
+ }
}
private extension ChatMessage {
@@ -1088,7 +1101,7 @@ private extension ChatMessage {
guard StreamRuntimeCheck._canFetchRelationship(currentDepth: depth) else {
throw RecursionLimitError()
}
- guard dto.isValid, let context = dto.managedObjectContext else {
+ guard let context = dto.managedObjectContext else {
throw InvalidModel(dto)
}
@@ -1118,6 +1131,7 @@ private extension ChatMessage {
action: MessageModerationAction(rawValue: $0.action)
)
}
+ textUpdatedAt = dto.textUpdatedAt?.bridgeDate
if let extraData = dto.extraData, !extraData.isEmpty {
do {
diff --git a/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift b/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift
index d80e67f5308..e47b6bc4f87 100644
--- a/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/MessageReactionDTO.swift
@@ -152,7 +152,6 @@ extension MessageReactionDTO {
/// Snapshots the current state of `MessageReactionDTO` and returns an immutable model object from it.
func asModel() throws -> ChatMessageReaction {
- guard isValid else { throw InvalidModel(self) }
let decodedExtraData: [String: RawJSON]
if let extraData = self.extraData, !extraData.isEmpty {
diff --git a/Sources/StreamChat/Database/DTOs/UserDTO.swift b/Sources/StreamChat/Database/DTOs/UserDTO.swift
index fdb4c341a8d..2d6430c30ad 100644
--- a/Sources/StreamChat/Database/DTOs/UserDTO.swift
+++ b/Sources/StreamChat/Database/DTOs/UserDTO.swift
@@ -232,8 +232,6 @@ extension UserDTO {
extension ChatUser {
fileprivate static func create(fromDTO dto: UserDTO) throws -> ChatUser {
- guard dto.isValid else { throw InvalidModel(dto) }
-
let extraData: [String: RawJSON]
do {
extraData = try JSONDecoder.default.decode([String: RawJSON].self, from: dto.extraData)
diff --git a/Sources/StreamChat/Database/DatabaseContainer.swift b/Sources/StreamChat/Database/DatabaseContainer.swift
index 666175062ed..19da9003564 100644
--- a/Sources/StreamChat/Database/DatabaseContainer.swift
+++ b/Sources/StreamChat/Database/DatabaseContainer.swift
@@ -59,7 +59,7 @@ class DatabaseContainer: NSPersistentContainer {
static var cachedModel: NSManagedObjectModel?
/// All `NSManagedObjectContext`s this container owns.
- private lazy var allContext: [NSManagedObjectContext] = [viewContext, backgroundReadOnlyContext, writableContext]
+ private(set) lazy var allContext: [NSManagedObjectContext] = [viewContext, backgroundReadOnlyContext, writableContext]
/// Creates a new `DatabaseContainer` instance.
///
@@ -224,8 +224,14 @@ class DatabaseContainer: NSPersistentContainer {
/// Removes all data from the local storage.
func removeAllData(completion: ((Error?) -> Void)? = nil) {
+ /// Cleanup the current user cache for all manage object contexts.
+ allContext.forEach { context in
+ context.perform {
+ context.invalidateCurrentUserCache()
+ }
+ }
+
writableContext.performAndWait { [weak self] in
- self?.writableContext.invalidateCurrentUserCache()
let entityNames = self?.managedObjectModel.entities.compactMap(\.name)
var deleteError: Error?
entityNames?.forEach { [weak self] entityName in
diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
index f14e954f2d2..52c655a932b 100644
--- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
+++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents
@@ -215,6 +215,7 @@
+
diff --git a/Sources/StreamChat/Errors/ClientError.swift b/Sources/StreamChat/Errors/ClientError.swift
index d5786ef5e1e..304e2aeb417 100644
--- a/Sources/StreamChat/Errors/ClientError.swift
+++ b/Sources/StreamChat/Errors/ClientError.swift
@@ -19,7 +19,10 @@ public class ClientError: Error, CustomStringConvertible {
/// An underlying error.
public let underlyingError: Error?
- var errorDescription: String? { underlyingError.map(String.init(describing:)) }
+ public var errorDescription: String? { underlyingError.map(String.init(describing:)) }
+
+ /// The error payload if the underlying error comes from a server error.
+ public var errorPayload: ErrorPayload? { underlyingError as? ErrorPayload }
/// Retrieve the localized description for this error.
public var localizedDescription: String { message ?? errorDescription ?? "" }
diff --git a/Sources/StreamChat/Errors/ErrorPayload.swift b/Sources/StreamChat/Errors/ErrorPayload.swift
index 73c4d58ed9c..57ef782327a 100644
--- a/Sources/StreamChat/Errors/ErrorPayload.swift
+++ b/Sources/StreamChat/Errors/ErrorPayload.swift
@@ -24,12 +24,12 @@ public struct ErrorPayload: LocalizedError, Codable, CustomDebugStringConvertibl
}
public var debugDescription: String {
- "ServerErrorPayload(code: \(code), message: \"\(message)\", statusCode: \(statusCode)))."
+ "\(String(describing: Self.self))(code: \(code), message: \"\(message)\", statusCode: \(statusCode)))."
}
}
/// https://getstream.io/chat/docs/ios-swift/api_errors_response/
-private enum StreamErrorCode {
+enum StreamErrorCode {
/// Usually returned when trying to perform an API call without a token.
static let accessKeyInvalid = 2
static let expiredToken = 40
diff --git a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
index 7bba4f8f637..f2938623d31 100644
--- a/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
+++ b/Sources/StreamChat/Generated/SystemEnvironment+Version.swift
@@ -7,5 +7,5 @@ import Foundation
extension SystemEnvironment {
/// A Stream Chat version.
- public static let version: String = "4.49.0"
+ public static let version: String = "4.50.0"
}
diff --git a/Sources/StreamChat/Info.plist b/Sources/StreamChat/Info.plist
index 95cd750b8b1..6431f07adbe 100644
--- a/Sources/StreamChat/Info.plist
+++ b/Sources/StreamChat/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 4.49.0
+ 4.50.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift
index 8fcce6e1da7..e9220dc50b8 100644
--- a/Sources/StreamChat/Models/ChatMessage.swift
+++ b/Sources/StreamChat/Models/ChatMessage.swift
@@ -32,12 +32,15 @@ public struct ChatMessage {
/// Date when the message was created locally and scheduled to be send. Applies only for the messages of the current user.
public let locallyCreatedAt: Date?
- /// A date when the message was updated last time.
+ /// A date when the message was updated last time. This includes any action to the message, like reactions.
public let updatedAt: Date
/// If the message was deleted, this variable contains a timestamp of that event, otherwise `nil`.
public let deletedAt: Date?
+ /// The date when the message text, and only the text, was edited. `Nil` if it was not edited.
+ public let textUpdatedAt: Date?
+
/// If the message was created by a specific `/` command, the arguments of the command are stored in this variable.
public let arguments: String?
@@ -236,7 +239,8 @@ public struct ChatMessage {
moderationDetails: MessageModerationDetails?,
readBy: @escaping () -> Set,
readByCount: @escaping () -> Int,
- underlyingContext: NSManagedObjectContext?
+ underlyingContext: NSManagedObjectContext?,
+ textUpdatedAt: Date?
) {
self.id = id
self.cid = cid
@@ -264,6 +268,7 @@ public struct ChatMessage {
self.translations = translations
self.originalLanguage = originalLanguage
self.moderationDetails = moderationDetails
+ self.textUpdatedAt = textUpdatedAt
$_author = (author, underlyingContext)
$_mentionedUsers = (mentionedUsers, underlyingContext)
diff --git a/Sources/StreamChat/Repositories/AuthenticationRepository.swift b/Sources/StreamChat/Repositories/AuthenticationRepository.swift
index 1edd5f4b644..2268c45cd5a 100644
--- a/Sources/StreamChat/Repositories/AuthenticationRepository.swift
+++ b/Sources/StreamChat/Repositories/AuthenticationRepository.swift
@@ -44,13 +44,14 @@ class AuthenticationRepository {
private var _consecutiveRefreshFailures: Int = 0
private var _currentUserId: UserId?
private var _currentToken: Token?
- /// Retry timing strategy for refreshing an expired token
private var _tokenExpirationRetryStrategy: RetryStrategy
private var _tokenProvider: TokenProvider?
private var _tokenRequestCompletions: [(Error?) -> Void] = []
private var _tokenWaiters: [String: (Result) -> Void] = [:]
+ private var _tokenProviderTimer: TimerControl?
+ private var _connectionProviderTimer: TimerControl?
- private var isGettingToken: Bool {
+ private(set) var isGettingToken: Bool {
get { tokenQueue.sync { _isGettingToken } }
set { tokenQueue.async(flags: .barrier) { self._isGettingToken = newValue }}
}
@@ -76,7 +77,21 @@ class AuthenticationRepository {
get { tokenQueue.sync { _tokenProvider } }
set { tokenQueue.async(flags: .barrier) { self._tokenProvider = newValue }}
}
+
+ private var tokenProviderTimer: TimerControl? {
+ get { tokenQueue.sync { _tokenProviderTimer } }
+ set { tokenQueue.async(flags: .barrier) {
+ self._tokenProviderTimer = newValue
+ }}
+ }
+ private var connectionProviderTimer: TimerControl? {
+ get { tokenQueue.sync { _connectionProviderTimer } }
+ set { tokenQueue.async(flags: .barrier) {
+ self._connectionProviderTimer = newValue
+ }}
+ }
+
weak var delegate: AuthenticationRepositoryDelegate?
private let apiClient: APIClient
@@ -183,6 +198,12 @@ class AuthenticationRepository {
func clearTokenProvider() {
tokenProvider = nil
+ isGettingToken = false
+ }
+
+ func cancelTimers() {
+ connectionProviderTimer?.cancel()
+ tokenProviderTimer?.cancel()
}
func logOutUser() {
@@ -244,7 +265,7 @@ class AuthenticationRepository {
}
let globalQueue = DispatchQueue.global()
- timerType.schedule(timeInterval: timeout, queue: globalQueue) { [weak self] in
+ connectionProviderTimer = timerType.schedule(timeInterval: timeout, queue: globalQueue) { [weak self] in
guard let self = self else { return }
// Not the nicest, but we need to ensure the read and write below are treated as an atomic operation,
// in a queue that is concurrent, whilst the completion needs to be called outside of the barrier'ed operation.
@@ -294,7 +315,7 @@ class AuthenticationRepository {
let interval = tokenQueue.sync(flags: .barrier) {
_tokenExpirationRetryStrategy.getDelayAfterTheFailure()
}
- timerType.schedule(
+ tokenProviderTimer = timerType.schedule(
timeInterval: interval,
queue: .main
) { [weak self] in
diff --git a/Sources/StreamChat/Repositories/ConnectionRepository.swift b/Sources/StreamChat/Repositories/ConnectionRepository.swift
index dda4066b30b..cc32d79f6f1 100644
--- a/Sources/StreamChat/Repositories/ConnectionRepository.swift
+++ b/Sources/StreamChat/Repositories/ConnectionRepository.swift
@@ -128,7 +128,7 @@ class ConnectionRepository {
func handleConnectionUpdate(
state: WebSocketConnectionState,
- onInvalidToken: () -> Void
+ onExpiredToken: () -> Void
) {
connectionStatus = .init(webSocketConnectionState: state)
@@ -140,9 +140,10 @@ class ConnectionRepository {
case let .connected(connectionId: id):
shouldNotifyConnectionIdWaiters = true
connectionId = id
-
case let .disconnected(source) where source.serverError?.isInvalidTokenError == true:
- onInvalidToken()
+ if source.serverError?.isExpiredTokenError == true {
+ onExpiredToken()
+ }
shouldNotifyConnectionIdWaiters = false
connectionId = nil
case .disconnected:
diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift
index 6a020b5102f..037f3a60e2b 100644
--- a/Sources/StreamChat/Repositories/MessageRepository.swift
+++ b/Sources/StreamChat/Repositories/MessageRepository.swift
@@ -96,8 +96,7 @@ class MessageRepository {
database.write({
let messageDTO = try $0.saveMessage(payload: message, for: cid, syncOwnReactions: false, cache: nil)
if messageDTO.localMessageState == .sending || messageDTO.localMessageState == .sendingFailed {
- messageDTO.locallyCreatedAt = nil
- messageDTO.localMessageState = nil
+ messageDTO.markMessageAsSent()
}
let messageModel = try messageDTO.asModel()
@@ -117,6 +116,23 @@ class MessageRepository {
) {
log.error("Sending the message with id \(messageId) failed with error: \(error)")
+ if let clientError = error as? ClientError, let errorPayload = clientError.errorPayload {
+ // If the message already exists on the server we do not want to mark it as failed,
+ // since this will cause an unrecoverable state, where the user will keep resending
+ // the message and it will always fail. Right now, the only way to check this error is
+ // by checking a combination of the error code and description, since there is no special
+ // error code for duplicated messages.
+ let isDuplicatedMessageError = errorPayload.code == 4 && errorPayload.message.contains("already exists")
+ if isDuplicatedMessageError {
+ database.write {
+ let messageDTO = $0.message(id: messageId)
+ messageDTO?.markMessageAsSent()
+ completion(.failure(.failedToSendMessage(error)))
+ }
+ return
+ }
+ }
+
markMessageAsFailedToSend(id: messageId) {
completion(.failure(.failedToSendMessage(error)))
}
@@ -126,7 +142,7 @@ class MessageRepository {
database.write({
let dto = $0.message(id: id)
if dto?.localMessageState == .sending {
- dto?.localMessageState = .sendingFailed
+ dto?.markMessageAsFailed()
}
}, completion: {
if let error = $0 {
diff --git a/Sources/StreamChat/Utils/Operations/AsyncOperation.swift b/Sources/StreamChat/Utils/Operations/AsyncOperation.swift
index 585bdd4aed8..c66a23e6bfe 100644
--- a/Sources/StreamChat/Utils/Operations/AsyncOperation.swift
+++ b/Sources/StreamChat/Utils/Operations/AsyncOperation.swift
@@ -21,6 +21,7 @@ class AsyncOperation: BaseOperation {
init(maxRetries: Int = 0, executionBlock: @escaping (AsyncOperation, @escaping (_ output: Output) -> Void) -> Void) {
self.maxRetries = maxRetries
self.executionBlock = executionBlock
+ super.init()
}
override func start() {
diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift
index cf1e82fcef4..341299711fc 100644
--- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift
+++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift
@@ -150,6 +150,11 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate {
return appearance.colorPalette.text
}
+ /// The character separator of the "Edited" label.
+ open var editedLabelSeparator: String {
+ " ā¢ "
+ }
+
// MARK: - Content views
/// Shows the bubble around message content.
@@ -670,7 +675,13 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate {
authorNameLabel?.text = content?.author.name
if let createdAt = content?.createdAt {
- timestampLabel?.text = timestampFormatter.format(createdAt)
+ let timestamp = timestampFormatter.format(createdAt)
+ var text = timestamp
+ let messageWasEdited = components.isMessageEditedLabelEnabled && content?.textUpdatedAt != nil
+ if messageWasEdited && content?.isDeleted == false {
+ text = timestamp + editedLabelSeparator + L10n.Message.edited
+ }
+ timestampLabel?.text = text
} else {
timestampLabel?.text = nil
}
diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptionsResolver.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptionsResolver.swift
index 2170dd39dc0..998309b2e78 100644
--- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptionsResolver.swift
+++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageLayoutOptionsResolver.swift
@@ -150,6 +150,11 @@ open class ChatMessageLayoutOptionsResolver {
return true
}
+ // If message was edited, always break the grouping of messages.
+ if components?.isMessageEditedLabelEnabled == true && message.textUpdatedAt != nil {
+ return true
+ }
+
// The message after the current one has different author so the current message
// is either a standalone or last in sequence.
guard nextMessage.author == message.author else { return true }
diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift
index 0abccf7973b..411f7dcd1fa 100644
--- a/Sources/StreamChatUI/Components.swift
+++ b/Sources/StreamChatUI/Components.swift
@@ -298,6 +298,10 @@ public struct Components {
/// The view that displays the number of unread messages in the chat.
public var unreadMessagesCounterDecorationView: ChatUnreadMessagesCountDecorationView.Type = ChatUnreadMessagesCountDecorationView.self
+ /// A flag which determines if edited messages should show a "Edited" label.
+ /// It is disabled by default.
+ public var isMessageEditedLabelEnabled = false
+
/// The view that displays the number of unread messages in the chat.
public var messageHeaderDecorationView: ChatChannelMessageHeaderDecoratorView.Type = ChatChannelMessageHeaderDecoratorView.self
diff --git a/Sources/StreamChatUI/Generated/L10n.swift b/Sources/StreamChatUI/Generated/L10n.swift
index d8823197222..c709b096d3c 100644
--- a/Sources/StreamChatUI/Generated/L10n.swift
+++ b/Sources/StreamChatUI/Generated/L10n.swift
@@ -205,6 +205,8 @@ internal enum L10n {
internal enum Message {
/// Message deleted
internal static var deletedMessagePlaceholder: String { L10n.tr("Localizable", "message.deleted-message-placeholder") }
+ /// Edited
+ internal static var edited: String { L10n.tr("Localizable", "message.edited") }
/// Only visible to you
internal static var onlyVisibleToYou: String { L10n.tr("Localizable", "message.only-visible-to-you") }
/// Translated to %@
diff --git a/Sources/StreamChatUI/Info.plist b/Sources/StreamChatUI/Info.plist
index 95cd750b8b1..6431f07adbe 100644
--- a/Sources/StreamChatUI/Info.plist
+++ b/Sources/StreamChatUI/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 4.49.0
+ 4.50.0
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
diff --git a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings
index dda9dd44285..6e228e56ca6 100644
--- a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings
+++ b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings
@@ -87,6 +87,8 @@
"message.moderation.edit" = "Edit Message";
/// Shown as a delete message action in the popup shown when a message was bounced due to moderation.
"message.moderation.delete" = "Delete Message";
+/// Shown as a label below the message, to notify that a message has been edited.
+"message.edited" = "Edited";
/// Shown when a user is online.
"message.title.online" = "Online";
diff --git a/StreamChat-XCFramework.podspec b/StreamChat-XCFramework.podspec
index 1f2119115d4..eee60430756 100644
--- a/StreamChat-XCFramework.podspec
+++ b/StreamChat-XCFramework.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = "StreamChat-XCFramework"
- spec.version = "4.49.0"
+ spec.version = "4.50.0"
spec.summary = "StreamChat iOS Client"
spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications."
diff --git a/StreamChat.podspec b/StreamChat.podspec
index 9cc17bf86e4..d0ad860400e 100644
--- a/StreamChat.podspec
+++ b/StreamChat.podspec
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = "StreamChat"
- spec.version = "4.49.0"
+ spec.version = "4.50.0"
spec.summary = "StreamChat iOS Chat Client"
spec.description = "stream-chat-swift is the official Swift client for Stream Chat, a service for building chat applications."
diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj
index c7c7e36527a..18778527f20 100644
--- a/StreamChat.xcodeproj/project.pbxproj
+++ b/StreamChat.xcodeproj/project.pbxproj
@@ -483,6 +483,10 @@
79FA4A84263BFD1100EC33DA /* GalleryAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FA4A83263BFD1100EC33DA /* GalleryAttachmentViewInjector.swift */; };
79FC85E724ACCBC500A665ED /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FC85E624ACCBC500A665ED /* Token.swift */; };
8210AA2827FC916B005F0B32 /* ChannelList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8210AA2727FC916B005F0B32 /* ChannelList.swift */; };
+ 82120C302B6AB3B400347A35 /* StreamChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 799C941B247D2F80001F1104 /* StreamChat.framework */; };
+ 82120C312B6AB3B400347A35 /* StreamChatUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 790881FD25432B7200896F03 /* StreamChatUI.framework */; };
+ 82120C342B6AB41100347A35 /* StreamChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 799C941B247D2F80001F1104 /* StreamChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 82120C352B6AB41100347A35 /* StreamChatUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 790881FD25432B7200896F03 /* StreamChatUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
8223EE7A2937A099006138B9 /* http_add_member.json in Resources */ = {isa = PBXBuildFile; fileRef = 8223EE792937A099006138B9 /* http_add_member.json */; };
8223EE7C2937A0E9006138B9 /* http_channel_creation.json in Resources */ = {isa = PBXBuildFile; fileRef = 8223EE7B2937A0E9006138B9 /* http_channel_creation.json */; };
8223EE7E2937A1F5006138B9 /* http_channel_removal.json in Resources */ = {isa = PBXBuildFile; fileRef = 8223EE7D2937A1F5006138B9 /* http_channel_removal.json */; };
@@ -524,11 +528,12 @@
826B1C3728895BB5005DDF13 /* http_unsplash_link.json in Resources */ = {isa = PBXBuildFile; fileRef = 826B1C3628895BB5005DDF13 /* http_unsplash_link.json */; };
826B1C39288FD756005DDF13 /* http_truncate.json in Resources */ = {isa = PBXBuildFile; fileRef = 826B1C38288FD756005DDF13 /* http_truncate.json */; };
826EF2B1291C01C1005A9EEF /* Authentication_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826EF2B0291C01C1005A9EEF /* Authentication_Tests.swift */; };
- 827414272ACDE941009CD13C /* StreamChatTestMockServer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3A0C999283E952900B18DA4 /* StreamChatTestMockServer.framework */; platformFilter = ios; };
+ 827414272ACDE941009CD13C /* StreamChatTestMockServer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3A0C999283E952900B18DA4 /* StreamChatTestMockServer.framework */; };
827414412ACDF6C2009CD13C /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827414402ACDF6C2009CD13C /* String.swift */; };
827414432ACDF76C009CD13C /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827414422ACDF76C009CD13C /* Dictionary.swift */; };
8274181F2ACDE85E004A23DA /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8274181E2ACDE85E004A23DA /* StreamSwiftTestHelpers */; };
827418212ACDE86F004A23DA /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 827418202ACDE86F004A23DA /* StreamSwiftTestHelpers */; };
+ 8274A7962B7FAC3900D8696B /* ChannelListScrollTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8274A7952B7FAC3900D8696B /* ChannelListScrollTime.swift */; };
8279706F29689680006741A3 /* UserDetails_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8279706E29689680006741A3 /* UserDetails_Tests.swift */; };
827DD1A0289D5B3300910AC5 /* MessageActionsVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827DD19F289D5B3300910AC5 /* MessageActionsVC.swift */; };
8292D6DB29B78476007A17D1 /* QuotedReply_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8292D6DA29B78476007A17D1 /* QuotedReply_Tests.swift */; };
@@ -556,12 +561,14 @@
82E655432B067C3600D64906 /* AssertAsync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E655422B067C3600D64906 /* AssertAsync.swift */; };
82E655452B067CAE00D64906 /* AssertResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E655442B067CAE00D64906 /* AssertResult.swift */; };
82E6554B2B067ED700D64906 /* WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E6554A2B067ED700D64906 /* WaitUntil.swift */; };
+ 82EBA1862B30AD0600B3A048 /* MessageListScrollTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82EBA1852B30AD0600B3A048 /* MessageListScrollTime.swift */; };
82F714A12B077F3300442A74 /* XCTestCase+iOS13.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F714A02B077F3300442A74 /* XCTestCase+iOS13.swift */; };
82F714A32B077FDE00442A74 /* XCTestCase+StressTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F714A22B077FDE00442A74 /* XCTestCase+StressTest.swift */; };
82F714A52B07831700442A74 /* AssertDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F714A42B07831700442A74 /* AssertDate.swift */; };
82F714A72B0784D900442A74 /* UnwrapAsync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F714A62B0784D900442A74 /* UnwrapAsync.swift */; };
82F714A92B0785D900442A74 /* XCTest+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F714A82B0785D900442A74 /* XCTest+Helpers.swift */; };
82F714AB2B078AE800442A74 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 82F714AA2B078AE800442A74 /* StreamSwiftTestHelpers */; };
+ 82FF61E82B6AB5B3007185B6 /* StreamChatTestMockServer.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A3A0C999283E952900B18DA4 /* StreamChatTestMockServer.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
840B4FCF26A9E53100D5EFAB /* CustomEventRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840B4FCE26A9E53100D5EFAB /* CustomEventRequestBody.swift */; };
84196FA32805892500185E99 /* LocalMessageState+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84196FA22805892500185E99 /* LocalMessageState+Extensions.swift */; };
842F9745277A09B10060A489 /* PinnedMessagesQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842F9744277A09B10060A489 /* PinnedMessagesQuery.swift */; };
@@ -1356,6 +1363,7 @@
ADA5A0F9276790C100E1C465 /* ChatMessageListDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */; };
ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */; };
ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */; };
+ ADAA10EC2B90D58B007AB03F /* FakeTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADAA10EA2B90D589007AB03F /* FakeTimer.swift */; };
ADAA377125E43C3700C31528 /* ChatSuggestionsVC_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD5A9E725DE8AF6006DC88A /* ChatSuggestionsVC_Tests.swift */; };
ADAA9F412B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADAA9F402B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift */; };
ADAC47AA275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADAC47A9275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift */; };
@@ -2395,20 +2403,27 @@
remoteGlobalIDString = 793060E525778896005CF846;
remoteInfo = StreamChatTestTools;
};
- 82DCB3AB2A4AE8FB00738933 /* PBXContainerItemProxy */ = {
+ 82120C282B6AB39C00347A35 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 799C941A247D2F80001F1104;
remoteInfo = StreamChat;
};
- 82DCB3AF2A4AE8FB00738933 /* PBXContainerItemProxy */ = {
+ 82120C2A2B6AB39C00347A35 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 790881FC25432B7200896F03;
remoteInfo = StreamChatUI;
};
+ 82FF61E92B6AB5E4007185B6 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A3A0C998283E952900B18DA4;
+ remoteInfo = StreamChatTestMockServer;
+ };
84748F8B2AC37F40007E3285 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
@@ -2458,27 +2473,6 @@
remoteGlobalIDString = A34407B927D8C33F0044F150;
remoteInfo = StreamChatUITestsApp;
};
- A34407F027D8C85E0044F150 /* PBXContainerItemProxy */ = {
- isa = PBXContainerItemProxy;
- containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
- proxyType = 1;
- remoteGlobalIDString = 799C941A247D2F80001F1104;
- remoteInfo = StreamChat;
- };
- A34407F227D8C85E0044F150 /* PBXContainerItemProxy */ = {
- isa = PBXContainerItemProxy;
- containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
- proxyType = 1;
- remoteGlobalIDString = 790881FC25432B7200896F03;
- remoteInfo = StreamChatUI;
- };
- A35715F7283E98D10014E3B0 /* PBXContainerItemProxy */ = {
- isa = PBXContainerItemProxy;
- containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
- proxyType = 1;
- remoteGlobalIDString = A3A0C998283E952900B18DA4;
- remoteInfo = StreamChatUITestTools;
- };
A35715FC283E9A940014E3B0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 8AD5EC8622E9A3E8005CFAC9 /* Project object */;
@@ -2613,6 +2607,19 @@
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
+ 82120C332B6AB3FD00347A35 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ 82FF61E82B6AB5B3007185B6 /* StreamChatTestMockServer.framework in Embed Frameworks */,
+ 82120C342B6AB41100347A35 /* StreamChat.framework in Embed Frameworks */,
+ 82120C352B6AB41100347A35 /* StreamChatUI.framework in Embed Frameworks */,
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
82DCB3B12A4AE8FB00738933 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -3192,6 +3199,7 @@
826EF2B0291C01C1005A9EEF /* Authentication_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Authentication_Tests.swift; sourceTree = ""; };
827414402ACDF6C2009CD13C /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; };
827414422ACDF76C009CD13C /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; };
+ 8274A7952B7FAC3900D8696B /* ChannelListScrollTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListScrollTime.swift; sourceTree = ""; };
8279706E29689680006741A3 /* UserDetails_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetails_Tests.swift; sourceTree = ""; };
827DD19F289D5B3300910AC5 /* MessageActionsVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActionsVC.swift; sourceTree = ""; };
8292D6DA29B78476007A17D1 /* QuotedReply_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReply_Tests.swift; sourceTree = ""; };
@@ -3226,6 +3234,8 @@
82E655422B067C3600D64906 /* AssertAsync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertAsync.swift; sourceTree = ""; };
82E655442B067CAE00D64906 /* AssertResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertResult.swift; sourceTree = ""; };
82E6554A2B067ED700D64906 /* WaitUntil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitUntil.swift; sourceTree = ""; };
+ 82EBA1852B30AD0600B3A048 /* MessageListScrollTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListScrollTime.swift; sourceTree = ""; };
+ 82EBA18A2B30C67B00B3A048 /* Performance.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Performance.xctestplan; sourceTree = ""; };
82F714A02B077F3300442A74 /* XCTestCase+iOS13.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+iOS13.swift"; sourceTree = ""; };
82F714A22B077FDE00442A74 /* XCTestCase+StressTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+StressTest.swift"; sourceTree = ""; };
82F714A42B07831700442A74 /* AssertDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertDate.swift; sourceTree = ""; };
@@ -3864,6 +3874,7 @@
ADA5A0F7276790C100E1C465 /* ChatMessageListDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListDateSeparatorView.swift; sourceTree = ""; };
ADA8EBE828CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewUserMentionsHandler_Mock.swift; sourceTree = ""; };
ADA8EBEA28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentViewDelegate_Mock.swift; sourceTree = ""; };
+ ADAA10EA2B90D589007AB03F /* FakeTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTimer.swift; sourceTree = ""; };
ADAA9F402B2240300078C3D4 /* TextViewMentionedUsersHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewMentionedUsersHandler_Tests.swift; sourceTree = ""; };
ADAC47A9275A7C960027B672 /* ChatMessageContentView_Documentation_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContentView_Documentation_Tests.swift; sourceTree = ""; };
ADB22F7A25F1626200853C92 /* OnlineIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnlineIndicatorView.swift; sourceTree = ""; };
@@ -4428,6 +4439,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 82120C302B6AB3B400347A35 /* StreamChat.framework in Frameworks */,
+ 82120C312B6AB3B400347A35 /* StreamChatUI.framework in Frameworks */,
827414272ACDE941009CD13C /* StreamChatTestMockServer.framework in Frameworks */,
827418212ACDE86F004A23DA /* StreamSwiftTestHelpers in Frameworks */,
);
@@ -5509,6 +5522,7 @@
82AD02BE27D8E453000611B7 /* Tests */ = {
isa = PBXGroup;
children = (
+ 82EBA1822B30A63800B3A048 /* Performance */,
A3600B3D283F63C700E1C930 /* Base TestCase */,
A3B78F16282A670600348AD1 /* Message Delivery Status */,
82BA52EE27E1EF7B00951B87 /* MessageList_Tests.swift */,
@@ -5549,6 +5563,15 @@
path = Wait;
sourceTree = "";
};
+ 82EBA1822B30A63800B3A048 /* Performance */ = {
+ isa = PBXGroup;
+ children = (
+ 82EBA1852B30AD0600B3A048 /* MessageListScrollTime.swift */,
+ 8274A7952B7FAC3900D8696B /* ChannelListScrollTime.swift */,
+ );
+ path = Performance;
+ sourceTree = "";
+ };
842F9747277A1CBE0060A489 /* PinnedMessages */ = {
isa = PBXGroup;
children = (
@@ -6127,6 +6150,7 @@
A3C3BC8127E8AB1E00224761 /* SpyPattern */,
A3C7BA7A27E3785500BBF4FA /* TestData */,
A3D15D8227E9D4B5006B34D7 /* VirtualTime */,
+ ADAA10E92B90D554007AB03F /* FakeTimer */,
8263464A2B0BACC600122D0E /* Difference */,
);
path = StreamChatTestTools;
@@ -6221,6 +6245,7 @@
isa = PBXGroup;
children = (
8261340927F20B7A0034AC37 /* StreamChatUITestsApp.xctestplan */,
+ 82EBA18A2B30C67B00B3A048 /* Performance.xctestplan */,
A39B040A27F196F200D6B18A /* StreamChatUITests.swift */,
82AD02B727D8E3DB000611B7 /* Extensions */,
825A32C927DBB44D000402A9 /* Pages */,
@@ -8032,6 +8057,14 @@
path = TitleContainerView;
sourceTree = "";
};
+ ADAA10E92B90D554007AB03F /* FakeTimer */ = {
+ isa = PBXGroup;
+ children = (
+ ADAA10EA2B90D589007AB03F /* FakeTimer.swift */,
+ );
+ path = FakeTimer;
+ sourceTree = "";
+ };
ADB22F7925F1626200853C92 /* ChatPresenceAvatarView */ = {
isa = PBXGroup;
children = (
@@ -9073,10 +9106,8 @@
buildRules = (
);
dependencies = (
- A34407F127D8C85E0044F150 /* PBXTargetDependency */,
- A34407F327D8C85E0044F150 /* PBXTargetDependency */,
- 82DCB3AC2A4AE8FB00738933 /* PBXTargetDependency */,
- 82DCB3B02A4AE8FB00738933 /* PBXTargetDependency */,
+ 82120C292B6AB39C00347A35 /* PBXTargetDependency */,
+ 82120C2B2B6AB39C00347A35 /* PBXTargetDependency */,
);
name = StreamChatUITestsApp;
packageProductDependencies = (
@@ -9093,11 +9124,12 @@
A34407D827D8C3400044F150 /* Sources */,
A34407D927D8C3400044F150 /* Frameworks */,
A34407DA27D8C3400044F150 /* Resources */,
+ 82120C332B6AB3FD00347A35 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
- A35715F8283E98D10014E3B0 /* PBXTargetDependency */,
+ 82FF61EA2B6AB5E4007185B6 /* PBXTargetDependency */,
A34407DE27D8C3400044F150 /* PBXTargetDependency */,
);
name = StreamChatUITestsAppUITests;
@@ -10307,6 +10339,7 @@
A3C3BC3327E87F2900224761 /* OfflineRequestsRepository_Mock.swift in Sources */,
A344078027D753530044F150 /* ChatChannelMember_Mock.swift in Sources */,
C1C5345A29AFDDAE006F9AF4 /* ChannelRepository_Mock.swift in Sources */,
+ ADAA10EC2B90D58B007AB03F /* FakeTimer.swift in Sources */,
82F714A32B077FDE00442A74 /* XCTestCase+StressTest.swift in Sources */,
A3C3BC1B27E87EFE00224761 /* ChatClient_Mock.swift in Sources */,
A3C3BC6727E8AA0A00224761 /* ChatMessage+Unique.swift in Sources */,
@@ -11164,6 +11197,7 @@
A39B040B27F196F200D6B18A /* StreamChatUITests.swift in Sources */,
825A32CF27DBB48D000402A9 /* StartPage.swift in Sources */,
A3BEB6AF27F3235600D6D80D /* Bundle+Target.swift in Sources */,
+ 82EBA1862B30AD0600B3A048 /* MessageListScrollTime.swift in Sources */,
A3AFEAA72816F1A200A79A6A /* MessageDeliveryStatus_Tests.swift in Sources */,
A3CB2BA02858C06B00DCAE3E /* Ephemeral_Messages_Tests.swift in Sources */,
822F265727D8F75D00E454FB /* UserRobot.swift in Sources */,
@@ -11174,6 +11208,7 @@
A3B78F18282A675700348AD1 /* MessageDeliveryStatus+ChannelList_Tests.swift in Sources */,
826EF2B1291C01C1005A9EEF /* Authentication_Tests.swift in Sources */,
829762E028C7587500B953E8 /* PushNotification_Tests.swift in Sources */,
+ 8274A7962B7FAC3900D8696B /* ChannelListScrollTime.swift in Sources */,
829CD5C72848C71B003C3877 /* Settings.swift in Sources */,
825A32CB27DBB463000402A9 /* MessageListPage.swift in Sources */,
A33FA816282D595C00DC40E8 /* ChannelList_Tests.swift in Sources */,
@@ -12059,15 +12094,20 @@
target = 793060E525778896005CF846 /* StreamChatTestTools */;
targetProxy = 79F458A825E3B52900E63D67 /* PBXContainerItemProxy */;
};
- 82DCB3AC2A4AE8FB00738933 /* PBXTargetDependency */ = {
+ 82120C292B6AB39C00347A35 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 799C941A247D2F80001F1104 /* StreamChat */;
- targetProxy = 82DCB3AB2A4AE8FB00738933 /* PBXContainerItemProxy */;
+ targetProxy = 82120C282B6AB39C00347A35 /* PBXContainerItemProxy */;
};
- 82DCB3B02A4AE8FB00738933 /* PBXTargetDependency */ = {
+ 82120C2B2B6AB39C00347A35 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 790881FC25432B7200896F03 /* StreamChatUI */;
- targetProxy = 82DCB3AF2A4AE8FB00738933 /* PBXContainerItemProxy */;
+ targetProxy = 82120C2A2B6AB39C00347A35 /* PBXContainerItemProxy */;
+ };
+ 82FF61EA2B6AB5E4007185B6 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A3A0C998283E952900B18DA4 /* StreamChatTestMockServer */;
+ targetProxy = 82FF61E92B6AB5E4007185B6 /* PBXContainerItemProxy */;
};
84748F8C2AC37F40007E3285 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
@@ -12104,21 +12144,6 @@
target = A34407B927D8C33F0044F150 /* StreamChatUITestsApp */;
targetProxy = A34407DD27D8C3400044F150 /* PBXContainerItemProxy */;
};
- A34407F127D8C85E0044F150 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = 799C941A247D2F80001F1104 /* StreamChat */;
- targetProxy = A34407F027D8C85E0044F150 /* PBXContainerItemProxy */;
- };
- A34407F327D8C85E0044F150 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = 790881FC25432B7200896F03 /* StreamChatUI */;
- targetProxy = A34407F227D8C85E0044F150 /* PBXContainerItemProxy */;
- };
- A35715F8283E98D10014E3B0 /* PBXTargetDependency */ = {
- isa = PBXTargetDependency;
- target = A3A0C998283E952900B18DA4 /* StreamChatTestMockServer */;
- targetProxy = A35715F7283E98D10014E3B0 /* PBXContainerItemProxy */;
- };
A35715FD283E9A940014E3B0 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 799C941A247D2F80001F1104 /* StreamChat */;
@@ -13143,9 +13168,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = StreamChatUITestsApp/StreamChatUITestsApp.entitlements;
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GCC_OPTIMIZATION_LEVEL = s;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StreamChatUITestsApp/Info.plist;
@@ -13162,6 +13188,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsApp;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsApp";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TESTS";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -13177,9 +13205,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = StreamChatUITestsApp/StreamChatUITestsApp.entitlements;
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StreamChatUITestsApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Chat UI Tests";
@@ -13195,6 +13224,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsApp;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsApp";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -13208,9 +13239,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = StreamChatUITestsApp/StreamChatUITestsApp.entitlements;
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StreamChatUITestsApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Chat UI Tests";
@@ -13226,6 +13258,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsApp;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsApp";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -13237,17 +13271,25 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GCC_OPTIMIZATION_LEVEL = s;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Tests/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRINCIPAL_CLASS)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
PRINCIPAL_CLASS = "$(PRODUCT_NAME).TestsEnvironmentSetup";
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsAppUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsAppUITests.xctrunner";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TESTS";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -13262,16 +13304,24 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Tests/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRINCIPAL_CLASS)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
PRINCIPAL_CLASS = "$(PRODUCT_NAME).TestsEnvironmentSetup";
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsAppUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsAppUITests.xctrunner";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -13284,16 +13334,24 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
- CODE_SIGN_STYLE = Automatic;
+ CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EHV7XZLAHA;
+ DEVELOPMENT_TEAM = "";
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Tests/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRINCIPAL_CLASS)";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.0;
PRINCIPAL_CLASS = "$(PRODUCT_NAME).TestsEnvironmentSetup";
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatUITestsAppUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatUITestsAppUITests.xctrunner";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -13339,10 +13397,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
- CODE_SIGN_STYLE = Manual;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
- DEVELOPMENT_TEAM = "";
+ DEVELOPMENT_TEAM = EHV7XZLAHA;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
diff --git a/StreamChat.xcodeproj/xcshareddata/xcschemes/StreamChatUITestsApp.xcscheme b/StreamChat.xcodeproj/xcshareddata/xcschemes/StreamChatUITestsApp.xcscheme
index 2a5cb0f2055..471c2cbed6c 100644
--- a/StreamChat.xcodeproj/xcshareddata/xcschemes/StreamChatUITestsApp.xcscheme
+++ b/StreamChat.xcodeproj/xcshareddata/xcschemes/StreamChatUITestsApp.xcscheme
@@ -24,14 +24,17 @@
+
+
Self {
- let minExpectedCount = channelCellIndex + 1
- let cells = ChannelListPage.cells.waitCount(minExpectedCount)
+ func waitForChannelListToLoad() -> Self {
+ let cells = ChannelListPage.cells.waitCount(1, timeout: 7)
// TODO: CIS-1737
if !cells.firstMatch.exists {
@@ -44,18 +43,19 @@ final class UserRobot: Robot {
sleep(1)
app.launch()
login()
- cells.waitCount(minExpectedCount)
+ cells.waitCount(1)
if cells.firstMatch.exists { break }
}
}
- XCTAssertGreaterThanOrEqual(
- cells.count,
- minExpectedCount,
- "Channel cell is not found at index #\(channelCellIndex)"
- )
+ XCTAssertGreaterThanOrEqual(cells.count, 1, "Channel list has not been loaded")
+ return self
+ }
- cells.allElementsBoundByIndex[channelCellIndex].waitForHitPoint().safeTap()
+ @discardableResult
+ func openChannel(channelCellIndex: Int = 0) -> Self {
+ waitForChannelListToLoad()
+ ChannelListPage.cells.allElementsBoundByIndex[channelCellIndex].waitForHitPoint().safeTap()
return self
}
@@ -311,10 +311,26 @@ extension UserRobot {
.tapOnBackButton()
}
+ @discardableResult
+ func scrollChannelListDown(times: Int = 1) -> Self {
+ for _ in 1...times {
+ ChannelListPage.list.swipeUp(velocity: .fast)
+ }
+ return self
+ }
+
+ @discardableResult
+ func scrollChannelListUp(times: Int = 1) -> Self {
+ for _ in 1...times {
+ ChannelListPage.list.swipeDown(velocity: .fast)
+ }
+ return self
+ }
+
@discardableResult
func scrollMessageListDown(times: Int = 1) -> Self {
for _ in 1...times {
- MessageListPage.list.swipeUp()
+ MessageListPage.list.swipeUp(velocity: .fast)
}
return self
}
@@ -322,7 +338,7 @@ extension UserRobot {
@discardableResult
func scrollMessageListUp(times: Int = 1) -> Self {
for _ in 1...times {
- MessageListPage.list.swipeDown()
+ MessageListPage.list.swipeDown(velocity: .fast)
}
return self
}
diff --git a/StreamChatUITestsAppUITests/StreamChatUITestsApp.xctestplan b/StreamChatUITestsAppUITests/StreamChatUITestsApp.xctestplan
index bb8e8a0ef4b..776301781b3 100644
--- a/StreamChatUITestsAppUITests/StreamChatUITestsApp.xctestplan
+++ b/StreamChatUITestsAppUITests/StreamChatUITestsApp.xctestplan
@@ -19,6 +19,10 @@
},
"testTargets" : [
{
+ "skippedTests" : [
+ "ChannelListScrollTime",
+ "MessageListScrollTime"
+ ],
"target" : {
"containerPath" : "container:StreamChat.xcodeproj",
"identifier" : "A34407DB27D8C3400044F150",
diff --git a/StreamChatUITestsAppUITests/Tests/Performance/ChannelListScrollTime.swift b/StreamChatUITestsAppUITests/Tests/Performance/ChannelListScrollTime.swift
new file mode 100644
index 00000000000..4340802970e
--- /dev/null
+++ b/StreamChatUITestsAppUITests/Tests/Performance/ChannelListScrollTime.swift
@@ -0,0 +1,30 @@
+//
+// Copyright Ā© 2024 Stream.io Inc. All rights reserved.
+//
+
+import XCTest
+
+@available(iOS 15.0, *)
+class ChannelListScrollTime: StreamTestCase {
+
+ override func setUpWithError() throws {
+ mockServerEnabled = false
+ try super.setUpWithError()
+ }
+
+ func testChannelListScrollTime() {
+ WHEN("user opens the channel list") {
+ backendRobot.generateChannels(count: 100, messagesCount: 1)
+ userRobot.login().waitForChannelListToLoad()
+ }
+ THEN("user scrolls the channel list") {
+ let measureOptions = XCTMeasureOptions()
+ measureOptions.invocationOptions = [.manuallyStop]
+ measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric], options: measureOptions) {
+ userRobot.scrollChannelListDown()
+ stopMeasuring()
+ userRobot.scrollChannelListUp()
+ }
+ }
+ }
+}
diff --git a/StreamChatUITestsAppUITests/Tests/Performance/MessageListScrollTime.swift b/StreamChatUITestsAppUITests/Tests/Performance/MessageListScrollTime.swift
new file mode 100644
index 00000000000..bf2802be439
--- /dev/null
+++ b/StreamChatUITestsAppUITests/Tests/Performance/MessageListScrollTime.swift
@@ -0,0 +1,26 @@
+//
+// Copyright Ā© 2024 Stream.io Inc. All rights reserved.
+//
+
+import XCTest
+
+@available(iOS 15.0, *)
+class MessageListScrollTime: StreamTestCase {
+
+ func testMessageListScrollTime() {
+ WHEN("user opens the message list") {
+ backendRobot.generateChannels(count: 1, messagesCount: 100, withAttachments: true)
+ participantRobot.addReaction(type: .like)
+ userRobot.login().openChannel()
+ }
+ THEN("user scrolls the message list") {
+ let measureOptions = XCTMeasureOptions()
+ measureOptions.invocationOptions = [.manuallyStop]
+ measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric], options: measureOptions) {
+ userRobot.scrollMessageListUp()
+ stopMeasuring()
+ userRobot.scrollMessageListDown()
+ }
+ }
+ }
+}
diff --git a/StreamChatUITestsAppUITests/Tests/QuotedReply_Tests.swift b/StreamChatUITestsAppUITests/Tests/QuotedReply_Tests.swift
index 46514316cad..28f6bb87070 100644
--- a/StreamChatUITestsAppUITests/Tests/QuotedReply_Tests.swift
+++ b/StreamChatUITestsAppUITests/Tests/QuotedReply_Tests.swift
@@ -62,7 +62,7 @@ final class QuotedReply_Tests: StreamTestCase {
func test_quotedReplyInList_whenParticipantAddsQuotedReply_Message() {
linkToScenario(withId: 1668)
- let messageCount = 20
+ let messageCount = 25
GIVEN("user opens the channel") {
backendRobot.generateChannels(count: 1, messagesCount: messageCount)
@@ -296,7 +296,7 @@ final class QuotedReply_Tests: StreamTestCase {
func test_quotedReplyInList_whenParticipantAddsQuotedReply_Message_InThread() {
linkToScenario(withId: 1932)
- let messageCount = 20
+ let messageCount = 25
GIVEN("user opens the channel") {
backendRobot.generateChannels(count: 1, messageText: parentText, messagesCount: 1, replyCount: messageCount)
diff --git a/TestTools/StreamChatTestMockServer/MockServer/ChannelResponses.swift b/TestTools/StreamChatTestMockServer/MockServer/ChannelResponses.swift
index 2badd3f1b5c..0d658f22b03 100644
--- a/TestTools/StreamChatTestMockServer/MockServer/ChannelResponses.swift
+++ b/TestTools/StreamChatTestMockServer/MockServer/ChannelResponses.swift
@@ -7,9 +7,10 @@ import XCTest
public let channelKey = ChannelCodingKeys.self
public let channelPayloadKey = ChannelPayload.CodingKeys.self
+var autogeneratedMessagesCounter = 0
public extension StreamMockServer {
-
+
private enum ChannelRequestType {
case addMembers([String])
case removeMembers([String])
@@ -290,7 +291,8 @@ public extension StreamMockServer {
replyCount: Int = 0,
author: [String: Any]?,
members: [[String: Any]],
- sampleChannel: [String: Any]
+ sampleChannel: [String: Any],
+ withAttachments: Bool = false
) -> [[String: Any]] {
var channels: [[String: Any]] = []
guard count > 0 else { return channels }
@@ -299,6 +301,7 @@ public extension StreamMockServer {
membership?[JSONKey.user] = author
for channelIndex in 1...count {
+ autogeneratedMessagesCounter = 0
var newChannel = sampleChannel
var messages: [[String: Any]?] = []
newChannel[channelPayloadKey.members.rawValue] = members
@@ -317,10 +320,13 @@ public extension StreamMockServer {
let newMessage = generateMessage(
withText: messageText,
withIndex: messageIndex,
+ withChannelIndex: channelIndex,
withId: messageId,
channelId: channelId,
author: author,
- replyCount: replyCount
+ replyCount: replyCount,
+ withAttachments: withAttachments,
+ overallMessagesCount: messagesCount
)
messages.append(newMessage)
@@ -328,6 +334,7 @@ public extension StreamMockServer {
for replyIndex in 1...replyCount {
generateMessage(
withIndex: replyIndex,
+ withChannelIndex: channelIndex,
withId: TestData.uniqueId,
parentId: messageId,
channelId: channelId,
@@ -350,16 +357,19 @@ public extension StreamMockServer {
private func generateMessage(
withText text: String? = nil,
withIndex index: Int,
+ withChannelIndex channelIndex: Int,
withId id: String?,
parentId: String? = nil,
channelId: String?,
author: [String: Any]?,
- replyCount: Int? = 0
+ replyCount: Int? = 0,
+ withAttachments: Bool = false,
+ overallMessagesCount: Int = 1
) -> [String : Any]? {
- let timeInterval = TimeInterval(index * 1000 - 123_456_789)
+ let timeInterval = TimeInterval(index + channelIndex * 1000 - 123_456_789)
let timestamp = TestData.stringTimestamp(Date(timeIntervalSinceNow: timeInterval))
let messageText = text == nil ? String(index) : text
- let message = mockMessage(
+ var message = mockMessage(
TestData.toJson(.message)[JSONKey.message] as? [String : Any],
channelId: channelId,
messageId: id,
@@ -370,6 +380,46 @@ public extension StreamMockServer {
parentId: parentId,
replyCount: replyCount
)
+
+ if withAttachments {
+ var attachments: [[String: Any]] = []
+ var file: [String: Any] = [:]
+ var type: AttachmentType?
+
+ switch autogeneratedMessagesCounter {
+ case overallMessagesCount - 1, overallMessagesCount - 9:
+ type = .image
+ file[AttachmentCodingKeys.imageURL.rawValue] = Attachments.image
+ case overallMessagesCount - 3, overallMessagesCount - 11:
+ type = .giphy
+ let json = TestData.getMockResponse(fromFile: MockFile.ephemeralMessage).json
+ let messageObj = json["message"] as? [String: Any]
+ attachments = messageObj?[messageKey.attachments.rawValue] as? [[String: Any]] ?? []
+ attachments[0][GiphyAttachmentSpecificCodingKeys.actions.rawValue] = nil
+ case overallMessagesCount - 5, overallMessagesCount - 13:
+ type = .file
+ file[AttachmentCodingKeys.assetURL.rawValue] = Attachments.file
+ file[AttachmentFile.CodingKeys.mimeType.rawValue] = "application/pdf"
+ file[AttachmentFile.CodingKeys.size.rawValue] = 123456
+ case overallMessagesCount - 7, overallMessagesCount - 15:
+ type = .video
+ file[AttachmentCodingKeys.assetURL.rawValue] = Attachments.video
+ file[AttachmentFile.CodingKeys.mimeType.rawValue] = "video/mp4"
+ file[AttachmentFile.CodingKeys.size.rawValue] = 123456
+ default:
+ break
+ }
+ if type != nil && type != .giphy {
+ for _ in 0...2 {
+ file[AttachmentCodingKeys.type.rawValue] = type?.rawValue
+ file[AttachmentCodingKeys.title.rawValue] = UUID().uuidString
+ attachments.append(file)
+ }
+ }
+ message?[messageKey.attachments.rawValue] = attachments
+ autogeneratedMessagesCounter += 1
+ }
+
parentId == nil ? saveMessage(message) : saveReply(message)
return message
}
diff --git a/TestTools/StreamChatTestMockServer/Robots/BackendRobot.swift b/TestTools/StreamChatTestMockServer/Robots/BackendRobot.swift
index 231cc92beed..dfa945afa67 100644
--- a/TestTools/StreamChatTestMockServer/Robots/BackendRobot.swift
+++ b/TestTools/StreamChatTestMockServer/Robots/BackendRobot.swift
@@ -48,7 +48,8 @@ public class BackendRobot {
UserDetails.lukeSkywalker,
UserDetails.hanSolo,
UserDetails.countDooku
- ]
+ ],
+ withAttachments: Bool = false
) -> Self {
var json = server.channelList
guard let sampleChannel = (json[JSONKey.channels] as? [[String: Any]])?.first else { return self }
@@ -69,7 +70,8 @@ public class BackendRobot {
replyCount: replyCount,
author: author,
members: members,
- sampleChannel: sampleChannel
+ sampleChannel: sampleChannel,
+ withAttachments: withAttachments
)
json[JSONKey.channels] = channels
diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift
index 854d18e32dc..6933f2f844c 100644
--- a/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift
+++ b/TestTools/StreamChatTestTools/Extensions/Unique/ChatMessage+Unique.swift
@@ -46,7 +46,8 @@ extension ChatMessage {
moderationDetails: nil,
readBy: { [] },
readByCount: { 0 },
- underlyingContext: nil
+ underlyingContext: nil,
+ textUpdatedAt: nil
)
}
}
diff --git a/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift b/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift
new file mode 100644
index 00000000000..0671368254c
--- /dev/null
+++ b/TestTools/StreamChatTestTools/FakeTimer/FakeTimer.swift
@@ -0,0 +1,39 @@
+//
+// Copyright Ā© 2024 Stream.io Inc. All rights reserved.
+//
+
+import Foundation
+@testable import StreamChat
+
+class FakeTimer: StreamChat.Timer {
+ static var mockTimer: TimerControl?
+ static var mockRepeatingTimer: RepeatingTimerControl?
+
+ static func schedule(timeInterval: TimeInterval, queue: DispatchQueue, onFire: @escaping () -> Void) -> StreamChat.TimerControl {
+ return mockTimer!
+ }
+
+ static func scheduleRepeating(timeInterval: TimeInterval, queue: DispatchQueue, onFire: @escaping () -> Void) -> StreamChat.RepeatingTimerControl {
+ mockRepeatingTimer!
+ }
+}
+
+class MockTimer: TimerControl {
+ var cancelCallCount = 0
+ func cancel() {
+ cancelCallCount += 1
+ }
+}
+
+class MockRepeatingTimer: RepeatingTimerControl {
+ var resumeCallCount = 0
+ var suspendCallCount = 0
+
+ func resume() {
+ resumeCallCount += 1
+ }
+
+ func suspend() {
+ suspendCallCount += 1
+ }
+}
diff --git a/TestTools/StreamChatTestTools/Fixtures/JSONs/Message.json b/TestTools/StreamChatTestTools/Fixtures/JSONs/Message.json
index c688e5e9ddf..3ad1fb5f11c 100644
--- a/TestTools/StreamChatTestTools/Fixtures/JSONs/Message.json
+++ b/TestTools/StreamChatTestTools/Fixtures/JSONs/Message.json
@@ -153,6 +153,7 @@
}
],
"updated_at" : "2020-08-17T13:15:39.895109Z",
+ "message_text_updated_at": "2023-08-17T13:15:39.895109Z",
"reply_count" : 0,
"user" : {
"id" : "broken-waterfall-5",
diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift
index 4256fd72c93..f709b04f4e2 100644
--- a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/ChatMessage_Mock.swift
@@ -46,7 +46,8 @@ public extension ChatMessage {
isSentByCurrentUser: Bool = false,
pinDetails: MessagePinDetails? = nil,
readBy: Set = [],
- underlyingContext: NSManagedObjectContext? = nil
+ underlyingContext: NSManagedObjectContext? = nil,
+ textUpdatedAt: Date? = nil
) -> Self {
.init(
id: id,
@@ -87,7 +88,8 @@ public extension ChatMessage {
moderationDetails: moderationsDetails,
readBy: { readBy },
readByCount: { readBy.count },
- underlyingContext: underlyingContext
+ underlyingContext: underlyingContext,
+ textUpdatedAt: textUpdatedAt
)
}
}
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift
index 7059254a056..89afc1336cb 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/ConnectionRepository_Mock.swift
@@ -15,7 +15,7 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy {
static let updateWebSocketEndpointUserId = "updateWebSocketEndpoint(with:)"
static let completeConnectionIdWaiters = "completeConnectionIdWaiters(connectionId:)"
static let provideConnectionId = "provideConnectionId(timeout:completion:)"
- static let handleConnectionUpdate = "handleConnectionUpdate(state:onInvalidToken:)"
+ static let handleConnectionUpdate = "handleConnectionUpdate(state:onExpiredToken:)"
}
var recordedFunctions: [String] = []
@@ -28,7 +28,7 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy {
var updateWebSocketEndpointUserInfo: UserInfo?
var completeWaitersConnectionId: ConnectionId?
var connectionUpdateState: WebSocketConnectionState?
- var simulateInvalidTokenOnConnectionUpdate = false
+ var simulateExpiredTokenOnConnectionUpdate = false
convenience init() {
self.init(isClientInActiveMode: true,
@@ -100,11 +100,11 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy {
record()
}
- override func handleConnectionUpdate(state: WebSocketConnectionState, onInvalidToken: () -> Void) {
+ override func handleConnectionUpdate(state: WebSocketConnectionState, onExpiredToken: () -> Void) {
record()
connectionUpdateState = state
- if simulateInvalidTokenOnConnectionUpdate {
- onInvalidToken()
+ if simulateExpiredTokenOnConnectionUpdate {
+ onExpiredToken()
}
}
@@ -117,7 +117,7 @@ final class ConnectionRepository_Mock: ConnectionRepository, Spy {
disconnectResult = nil
disconnectSource = nil
- simulateInvalidTokenOnConnectionUpdate = false
+ simulateExpiredTokenOnConnectionUpdate = false
connectionUpdateState = nil
completeWaitersConnectionId = nil
updateWebSocketEndpointToken = nil
diff --git a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift
index 93e5161bba5..d2841432127 100644
--- a/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift
+++ b/TestTools/StreamChatTestTools/Mocks/StreamChat/Repositories/AuthenticationRepository_Mock.swift
@@ -94,6 +94,11 @@ class AuthenticationRepository_Mock: AuthenticationRepository, Spy {
record()
}
+ var cancelTimersCallCount: Int = 0
+ override func cancelTimers() {
+ cancelTimersCallCount += 1
+ }
+
override func completeTokenWaiters(token: Token?) {
record()
completeWaitersToken = token
diff --git a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift
index 9d384bce9b1..9fe94074a78 100644
--- a/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift
+++ b/TestTools/StreamChatTestTools/TestData/DummyData/MessagePayload.swift
@@ -45,7 +45,8 @@ extension MessagePayload {
translations: [TranslationLanguage: String]? = nil,
originalLanguage: String? = nil,
moderationDetails: MessageModerationDetailsPayload? = nil,
- mentionedUsers: [UserPayload] = [.dummy(userId: .unique)]
+ mentionedUsers: [UserPayload] = [.dummy(userId: .unique)],
+ messageTextUpdatedAt: Date? = nil
) -> MessagePayload {
.init(
id: messageId,
@@ -81,7 +82,8 @@ extension MessagePayload {
pinExpires: pinExpires,
translations: translations,
originalLanguage: originalLanguage,
- moderationDetails: moderationDetails
+ moderationDetails: moderationDetails,
+ messageTextUpdatedAt: messageTextUpdatedAt
)
}
diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift
index 2af9448b2e3..c784308df61 100644
--- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift
+++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/MessagePayloads_Tests.swift
@@ -21,6 +21,7 @@ final class MessagePayload_Tests: XCTestCase {
XCTAssertEqual(payload.createdAt, "2020-07-16T15:39:03.010717Z".toDate())
XCTAssertEqual(payload.updatedAt, "2020-08-17T13:15:39.895109Z".toDate())
XCTAssertEqual(payload.deletedAt, "2020-07-16T15:55:03.010717Z".toDate())
+ XCTAssertEqual(payload.messageTextUpdatedAt, "2023-08-17T13:15:39.895109Z".toDate())
XCTAssertEqual(payload.text, "No, I am your father!")
XCTAssertEqual(payload.command, nil)
XCTAssertEqual(payload.args, nil)
diff --git a/Tests/StreamChatTests/ChatClient_Tests.swift b/Tests/StreamChatTests/ChatClient_Tests.swift
index 81d74fd7ca4..25e88047793 100644
--- a/Tests/StreamChatTests/ChatClient_Tests.swift
+++ b/Tests/StreamChatTests/ChatClient_Tests.swift
@@ -328,7 +328,7 @@ final class ChatClient_Tests: XCTestCase {
XCTAssertEqual(client.activeChannelControllers.count, 0)
XCTAssertEqual(client.activeChannelListControllers.count, 0)
}
-
+
func test_apiClient_usesInjectedURLSessionConfiguration() {
// configure a URLSessionConfiguration with a URLProtocol class
var urlSessionConfiguration = URLSessionConfiguration.default
@@ -645,7 +645,7 @@ final class ChatClient_Tests: XCTestCase {
// MARK: - Disconnect
- func test_disconnect_shouldCallConnectionRepository_andClearTokenProvider() throws {
+ func test_disconnect_shouldCallConnectionRepository_andClearTokenProvider_andCancelTimers() throws {
let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment)
let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock)
let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock)
@@ -659,6 +659,7 @@ final class ChatClient_Tests: XCTestCase {
XCTAssertCall(ConnectionRepository_Mock.Signature.disconnect, on: connectionRepository)
XCTAssertCall(AuthenticationRepository_Mock.Signature.clearTokenProvider, on: authenticationRepository)
+ XCTAssertEqual(client.mockAuthenticationRepository.cancelTimersCallCount, 1)
}
func test_logout_shouldDisconnect_logOut_andRemoveAllData() throws {
@@ -757,14 +758,14 @@ final class ChatClient_Tests: XCTestCase {
XCTAssertNotCall(AuthenticationRepository_Mock.Signature.refreshToken, on: authenticationRepository)
}
- func test_webSocketClientStateUpdate_calls_connectionRepository_invalidToken() throws {
+ func test_webSocketClientStateUpdate_calls_connectionRepository_expiredToken() throws {
let client = ChatClient(config: inMemoryStorageConfig, environment: testEnv.environment)
let webSocketClient = try XCTUnwrap(client.webSocketClient)
let connectionRepository = try XCTUnwrap(client.connectionRepository as? ConnectionRepository_Mock)
let authenticationRepository = try XCTUnwrap(client.authenticationRepository as? AuthenticationRepository_Mock)
let state = WebSocketConnectionState.disconnected(source: .systemInitiated)
- connectionRepository.simulateInvalidTokenOnConnectionUpdate = true
+ connectionRepository.simulateExpiredTokenOnConnectionUpdate = true
client.webSocketClient(webSocketClient, didUpdateConnectionState: state)
XCTAssertCall(ConnectionRepository_Mock.Signature.handleConnectionUpdate, on: connectionRepository)
diff --git a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
index 8abc52d2d2f..c890409d1bb 100644
--- a/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
+++ b/Tests/StreamChatTests/Database/DTOs/MessageDTO_Tests.swift
@@ -513,7 +513,8 @@ final class MessageDTO_Tests: XCTestCase {
moderationDetails: .init(
originalText: "Original",
action: "BOUNCE"
- )
+ ),
+ messageTextUpdatedAt: .unique
)
try! database.writeSynchronously { session in
@@ -584,6 +585,8 @@ final class MessageDTO_Tests: XCTestCase {
XCTAssertNearlySameDate(messagePayload.createdAt, loadedMessage?.createdAt.bridgeDate)
XCTAssertNearlySameDate(messagePayload.updatedAt, loadedMessage?.updatedAt.bridgeDate)
XCTAssertNearlySameDate(messagePayload.deletedAt, loadedMessage?.deletedAt?.bridgeDate)
+ XCTAssertNearlySameDate(messagePayload.messageTextUpdatedAt, loadedMessage?.textUpdatedAt?.bridgeDate)
+ XCTAssertNotNil(messagePayload.messageTextUpdatedAt)
XCTAssertEqual(messagePayload.text, loadedMessage?.text)
XCTAssertEqual(loadedMessage?.command, messagePayload.command)
XCTAssertEqual(loadedMessage?.args, messagePayload.args)
diff --git a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift
index 7a58581abc9..7f711778ecf 100644
--- a/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift
+++ b/Tests/StreamChatTests/Database/DatabaseContainer_Tests.swift
@@ -152,6 +152,13 @@ final class DatabaseContainer_Tests: XCTestCase {
let fetchedObjects = try container.viewContext.fetch(fetchRequrest)
XCTAssertTrue(fetchedObjects.isEmpty)
}
+
+ // Assert that currentUser cache has been deleted
+ container.allContext.forEach { context in
+ context.performAndWait {
+ XCTAssertNil(context.currentUser)
+ }
+ }
}
func test_databaseContainer_callsResetEphemeralValues_onAllEphemeralValuesContainerEntities() throws {
diff --git a/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift b/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift
index 5faed9c651b..3cdbd7e6588 100644
--- a/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/AuthenticationRepository_Tests.swift
@@ -798,6 +798,19 @@ final class AuthenticationRepository_Tests: XCTestCase {
XCTAssertNotNil(repository.currentUserId)
}
+ func test_clearTokenProvider_thenIsGettingTokenFalse() {
+ repository.connectUser(
+ userInfo: .init(id: .newUniqueId),
+ tokenProvider: { _ in },
+ completion: { _ in }
+ )
+ AssertAsync.willBeTrue(repository.isGettingToken)
+
+ repository.clearTokenProvider()
+
+ XCTAssertFalse(repository.isGettingToken)
+ }
+
// MARK: Log out
func test_logOut_clearsUserData() {
@@ -1026,6 +1039,32 @@ final class AuthenticationRepository_Tests: XCTestCase {
XCTAssertEqual(state, .newToken)
}
+ // MARK: Cancel Timers
+
+ func test_cancelTimers() {
+ let mockTimer = MockTimer()
+ FakeTimer.mockTimer = mockTimer
+ let repository = AuthenticationRepository(
+ apiClient: apiClient,
+ databaseContainer: database,
+ connectionRepository: connectionRepository,
+ tokenExpirationRetryStrategy: retryStrategy,
+ timerType: FakeTimer.self
+ )
+
+ repository.provideToken(completion: { _ in })
+ repository.connectUser(
+ userInfo: .init(id: .newUniqueId),
+ tokenProvider: { _ in },
+ completion: { _ in }
+ )
+
+ repository.cancelTimers()
+ // should cancel the connection provider timer and the
+ // the token provider timer
+ XCTAssertEqual(mockTimer.cancelCallCount, 2)
+ }
+
// MARK: Helpers
private func testPrepareEnvironmentAfterConnect(
diff --git a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift
index d71aed7ba42..a1b5ca9fcaa 100644
--- a/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift
@@ -280,7 +280,7 @@ final class ConnectionRepository_Tests: XCTestCase {
]
for (webSocketState, connectionStatus) in pairs {
- repository.handleConnectionUpdate(state: webSocketState, onInvalidToken: {})
+ repository.handleConnectionUpdate(state: webSocketState, onExpiredToken: {})
XCTAssertEqual(repository.connectionStatus, connectionStatus)
}
}
@@ -326,7 +326,7 @@ final class ConnectionRepository_Tests: XCTestCase {
}
}
- repository.handleConnectionUpdate(state: webSocketState, onInvalidToken: {})
+ repository.handleConnectionUpdate(state: webSocketState, onExpiredToken: {})
if shouldNotify {
waitForExpectations(timeout: defaultTimeout)
@@ -357,28 +357,44 @@ final class ConnectionRepository_Tests: XCTestCase {
repository.completeConnectionIdWaiters(connectionId: originalConnectionId)
XCTAssertEqual(repository.connectionId, originalConnectionId)
- repository.handleConnectionUpdate(state: webSocketState, onInvalidToken: {})
+ repository.handleConnectionUpdate(state: webSocketState, onExpiredToken: {})
XCTAssertEqual(repository.connectionId, newConnectionIdValue)
}
}
- func test_handleConnectionUpdate_whenInvalidToken_shouldExecuteInvalidTokenBlock() {
- let expectation = self.expectation(description: "Invalid Token Block Executed")
+ func test_handleConnectionUpdate_whenExpiredToken_shouldExecuteExpiredTokenBlock() {
+ let expectation = self.expectation(description: "Expired Token Block Not Executed")
+ let expiredTokenError = ClientError(with: ErrorPayload(
+ code: StreamErrorCode.expiredToken,
+ message: .unique,
+ statusCode: .unique
+ ))
+
+ repository.handleConnectionUpdate(state: .disconnected(source: .serverInitiated(error: expiredTokenError)), onExpiredToken: {
+ expectation.fulfill()
+ })
+
+ waitForExpectations(timeout: defaultTimeout)
+ }
+
+ func test_handleConnectionUpdate_whenInvalidToken_shouldNotExecuteExpiredTokenBlock() {
+ let expectation = self.expectation(description: "Expired Token Block Not Executed")
+ expectation.isInverted = true
let invalidTokenError = ClientError(with: ErrorPayload(
- code: .random(in: ClosedRange.tokenInvalidErrorCodes),
+ code: StreamErrorCode.invalidTokenSignature,
message: .unique,
statusCode: .unique
))
- repository.handleConnectionUpdate(state: .disconnected(source: .serverInitiated(error: invalidTokenError)), onInvalidToken: {
+ repository.handleConnectionUpdate(state: .disconnected(source: .serverInitiated(error: invalidTokenError)), onExpiredToken: {
expectation.fulfill()
})
waitForExpectations(timeout: defaultTimeout)
}
- func test_handleConnectionUpdate_whenInvalidToken_whenDisconnecting_shouldNOTExecuteInvalidTokenBlock() {
+ func test_handleConnectionUpdate_whenInvalidToken_whenDisconnecting_shouldNOTExecuteRefreshTokenBlock() {
// We only want to refresh the token when it is actually disconnected, not while it is disconnecting, otherwise we trigger refresh token twice.
let invalidTokenError = ClientError(with: ErrorPayload(
code: .random(in: ClosedRange.tokenInvalidErrorCodes),
@@ -386,16 +402,16 @@ final class ConnectionRepository_Tests: XCTestCase {
statusCode: .unique
))
- repository.handleConnectionUpdate(state: .disconnecting(source: .serverInitiated(error: invalidTokenError)), onInvalidToken: {
+ repository.handleConnectionUpdate(state: .disconnecting(source: .serverInitiated(error: invalidTokenError)), onExpiredToken: {
XCTFail("Should not execute invalid token block")
})
}
- func test_handleConnectionUpdate_whenNoError_shouldNOTExecuteInvalidTokenBlock() {
+ func test_handleConnectionUpdate_whenNoError_shouldNOTExecuteRefreshTokenBlock() {
let states: [WebSocketConnectionState] = [.connecting, .initialized, .connected(connectionId: .newUniqueId), .waitingForConnectionId]
for state in states {
- repository.handleConnectionUpdate(state: state, onInvalidToken: {
+ repository.handleConnectionUpdate(state: state, onExpiredToken: {
XCTFail("Should not execute invalid token block")
})
}
@@ -499,7 +515,10 @@ final class ConnectionRepository_Tests: XCTestCase {
func test_completeConnectionIdWaiters_nil_connectionId() {
// Set initial connectionId
let initialConnectionId = "initial-connection-id"
- repository.handleConnectionUpdate(state: .connected(connectionId: initialConnectionId), onInvalidToken: {})
+ repository.handleConnectionUpdate(
+ state: .connected(connectionId: initialConnectionId),
+ onExpiredToken: {}
+ )
XCTAssertEqual(repository.connectionId, initialConnectionId)
repository.completeConnectionIdWaiters(connectionId: nil)
diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
index 7a6dba36d87..a7267e8ca6e 100644
--- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
+++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift
@@ -102,6 +102,37 @@ final class MessageRepositoryTests: XCTestCase {
}
}
+ func test_sendMessage_APIFailure_whenDuplicatedMessage_shouldNotMarkMessageAsFailed() throws {
+ let id = MessageId.unique
+ try createMessage(id: id, localState: .pendingSend)
+ let expectation = self.expectation(description: "Send Message completes")
+ var result: Result?
+ repository.sendMessage(with: id) {
+ result = $0
+ expectation.fulfill()
+ }
+
+ wait(for: [apiClient.request_expectation], timeout: defaultTimeout)
+
+ let error = ClientError(with: ErrorPayload(code: 4, message: "Message X already exists.", statusCode: 400))
+ (apiClient.request_completion as? (Result) -> Void)?(.failure(error))
+
+ wait(for: [expectation], timeout: defaultTimeout)
+
+ var currentMessageState: LocalMessageState?
+ try database.writeSynchronously { session in
+ currentMessageState = session.message(id: id)?.localMessageState
+ }
+
+ XCTAssertNil(currentMessageState)
+ switch result?.error {
+ case .failedToSendMessage:
+ break
+ default:
+ XCTFail()
+ }
+ }
+
func test_sendMessage_APISuccess() throws {
let id = MessageId.unique
try createMessage(id: id, localState: .pendingSend)
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
index 124bc9c66ac..efb0f569202 100644
--- a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
+++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/ChatChannelVC_Tests.swift
@@ -604,6 +604,50 @@ final class ChatChannelVC_Tests: XCTestCase {
AssertSnapshot(vc, variants: [.defaultLight])
}
+ func test_whenMessageEditedAt_editedMessageIsNotGrouped() {
+ let channel: ChatChannel = .mock(cid: .unique)
+ let user: ChatUser = .mock(id: .unique)
+
+ let closingGroupMessage: ChatMessage = .mock(
+ id: .unique,
+ cid: channel.cid,
+ text: "Closes the group",
+ author: user,
+ isSentByCurrentUser: true
+ )
+ let editedMessage: ChatMessage = .mock(
+ id: .unique,
+ cid: channel.cid,
+ text: "Ignores group because it is edited",
+ author: user,
+ createdAt: closingGroupMessage.createdAt.addingTimeInterval(-maxTimeInterval / 2),
+ isSentByCurrentUser: true,
+ textUpdatedAt: .unique
+ )
+ let openingGroupMessage: ChatMessage = .mock(
+ id: .unique,
+ cid: channel.cid,
+ text: "Opens the group",
+ author: user,
+ createdAt: editedMessage.createdAt.addingTimeInterval(-maxTimeInterval),
+ isSentByCurrentUser: true
+ )
+
+ channelControllerMock.simulateInitial(
+ channel: channel,
+ messages: [
+ closingGroupMessage,
+ editedMessage,
+ openingGroupMessage
+ ],
+ state: .localDataFetched
+ )
+
+ vc.components.isMessageEditedLabelEnabled = true
+
+ AssertSnapshot(vc, variants: [.defaultLight])
+ }
+
func test_didReceiveNewMessagePendingEvent_whenFirstPageNotLoaded_whenMessageSentByCurrentUser_whenMessageNotPartOfThread_thenLoadsFirstPage() {
channelControllerMock.hasLoadedAllNextMessages_mock = false
let message = ChatMessage.mock(
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_whenMessageEditedAt_editedMessageIsNotGrouped.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_whenMessageEditedAt_editedMessageIsNotGrouped.default-light.png
new file mode 100644
index 00000000000..139bf75db6a
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannel/__Snapshots__/ChatChannelVC_Tests/test_whenMessageEditedAt_editedMessageIsNotGrouped.default-light.png differ
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift
index 160e4056b0f..4a3287c7eb9 100644
--- a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift
+++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/ChatMessageContentView_Tests.swift
@@ -639,6 +639,49 @@ final class ChatMessageContentView_Tests: XCTestCase {
AssertSnapshot(view, variants: [.defaultLight])
}
+ func test_appearance_whenMessageIsEdited() throws {
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: "Hello World",
+ author: .unique,
+ createdAt: createdAt,
+ localState: nil,
+ isSentByCurrentUser: true,
+ textUpdatedAt: .unique
+ )
+
+ let view = contentView(
+ message: message,
+ channel: .mock(cid: .unique)
+ )
+ view.components.isMessageEditedLabelEnabled = true
+
+ AssertSnapshot(view, variants: .all)
+ }
+
+ func test_appearance_whenMessageIsEdited_andDeleted_shouldNotShowEditedLabel() throws {
+ let message: ChatMessage = .mock(
+ id: .unique,
+ cid: .unique,
+ text: "Hello World",
+ author: .unique,
+ createdAt: createdAt,
+ deletedAt: .unique,
+ localState: nil,
+ isSentByCurrentUser: true,
+ textUpdatedAt: .unique
+ )
+
+ let view = contentView(
+ message: message,
+ channel: .mock(cid: .unique)
+ )
+ view.components.isMessageEditedLabelEnabled = true
+
+ AssertSnapshot(view, variants: [.defaultLight])
+ }
+
func test_chatReactionsBubbleViewInjectable() {
let testMessage: ChatMessage = .mock(
id: .unique,
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.default-light.png
new file mode 100644
index 00000000000..6f740450649
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.default-light.png differ
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.extraExtraExtraLarge-light.png
new file mode 100644
index 00000000000..dca48f0e489
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.extraExtraExtraLarge-light.png differ
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.rightToLeftLayout-default.png
new file mode 100644
index 00000000000..f92113666b0
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.rightToLeftLayout-default.png differ
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.small-dark.png
new file mode 100644
index 00000000000..4538b979abc
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited.small-dark.png differ
diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited_andDeleted_shouldNotShowEditedLabel.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited_andDeleted_shouldNotShowEditedLabel.default-light.png
new file mode 100644
index 00000000000..4fb7ca8456d
Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/ChatMessage/__Snapshots__/ChatMessageContentView_Tests/test_appearance_whenMessageIsEdited_andDeleted_shouldNotShowEditedLabel.default-light.png differ
diff --git a/docusaurus/docs/iOS/guides/video-100ms.md b/docusaurus/docs/iOS/guides/video-100ms.md
deleted file mode 100644
index f52a1236847..00000000000
--- a/docusaurus/docs/iOS/guides/video-100ms.md
+++ /dev/null
@@ -1,882 +0,0 @@
----
-title: 100ms Video integration guide
----
-
-## Introduction
-
-Video calls have become an integral part of daily life since the pandemic hit. Today, we will take a look at how you can use the 100ms service to integrate video calls into the Stream Chat SDK.
-
-100ms is an infrastructure provider for services like video, audio, and live streaming. They offer native SDKs for mobile platforms and the web that allow for simple integration with very few lines of code. They cover a wide range of use-cases such as video conferencing, telehealth, classrooms, and many more.
-
-There are a few necessary steps to follow to integrate video calling capabilities with the Stream Chat SDK, but we will go over each phase of the process to come up with a functional and reusable solution that allows your end-users to communicate with one another through a seamless video experience.
-
-Here is a sneak peek of the final product youāll build today:
-
-
-
-Follow the steps below to produce this app that allows your users to make video calls:
-
-1. Set up an account for 100ms
-2. Stream Dashboard integration
-3. Set up basic app architecture
-4. Create a layout UI
-5. Send messages with the [Stream Chat SDK](https://getstream.io/chat/)
-6. Hook up UI with 100ms
-
-If you want to avoid starting from the very beginning, our [SwiftUI tutorial](https://getstream.io/tutorials/swiftui-chat/) on the [Stream website](https://getstream.io) is set as the starting point. If you followed this step-by-step tutorial before, you are ready to jump right in.
-
-## 1. Setting Up an Account for 100ms
-
-First, letās go over a quick introduction to [100ms](https://www.100ms.live). It is a service that allows you to do video conferencing, audio, and more. Their aim is to provide you with a wide range of extensible features, all while allowing you to get started quickly with minimum effort.
-
-To get started, you must [set up an account](https://dashboard.100ms.live/register) for the platform - click the **Try For Free** button for a trial to use for this tutorial. You can sign up with either a Google or GitHub account, or you can use any other email address. You will receive an email asking you to confirm your credentials.
-
-Next, youāll get a quick tour of how to create your own video conference. Here is an outline of the steps you must take:
-
-1. Choose a template
- Select **Video Conferencing**, hit **Next**
-2. Add a few more details
- Enter everything that is valid for you
-3. Choose a subdomain
- Create a subdomain that is suitable for your use case and select the closest region (for example in our case, "integrationguide" and āEUā make the most sense, resulting in the domain: **integrationguide.app.100ms.live**)
-4. Your app is ready
- You can join the room if you want to see a sample (not necessary)
-
-From here, click the **Go to Dashboard** button at the bottom. After completing the quick introductory tour, your account and app will be ready to continue. Nice job.
-
-You will come back to the Dashboard later, but we will move on to other steps next.
-
-## 2. Stream Dashboard integration
-
-In order for the integration of 100ms to work there needs to be a server component handling token generation and more. Normally this would require a custom server implementation but Stream offers first-class integration for 100ms relieving you of these duties.
-
-There is only a few setup-steps to go through in the Stream dashboard and this guide details all of them:
-
-1. Head over to the [Dashboard](https://dashboard.getstream.io) and login
-2. Create a new app or select your app by name
-3. In the sidebar on the left, select **Ext. Video Integration**
-4. Make sure the **100ms** tab is selected
-
-This is the screen that you navigated to:
-
-![View of the Stream Dashboard when an app was selected with the external video integration.](../assets/hms-guide-before.png)
-
-First, it is necessary to enable the integration through the toggle in the top right (red arrow in the image below). Make sure that you can see the green **HMS Enabled** badge at the top.
-
-Next, it is necessary to enter the credentials from the 100ms console. This is the place you need to enter the following values (in brackets are the place you can find them in the 100ms dashboard):
-
-- `App Access Key` (100ms Dashboard: `Developer` -> `App Access Key`)
-- `App Secret` (100ms Dashboard: `Developer` -> `App Secret`)
-- `Default Role` (can be `guest`)
-- `Default Room Template` (100ms Dashboard: `Templates` -> `Name`)
-
-![View of the Stream Dashboard when the 100ms video integration was enabled.](../assets/hms-guide-after.png)
-
-With these steps being taken, the Stream Chat SDK will use these credentials internally to initiate calls without you needing to take additional steps.
-
-So, let's focus on the iOS implementation.
-
-## 3. Set Up the Basic App Architecture
-
-The integration requires a bit of setup, which is why you will create most of the necessary files right now. This will give a good overview of the overall architecture; you will add more to these files throughout the course of this integration guide.
-
-Before starting, you need to import the 100ms SDK into the project via CocoaPods. (Note that Swift Package Manager (SPM) is also supported, but the support seems to be subpar so far.) Follow the [100ms CocoaPods integration guide](https://www.100ms.live/docs/ios/v2/features/Integration#cocoapods) to include the required dependency in your `Podfile`.
-
-For the implementation, you will need an object that conforms to the `ViewFactory` of the `StreamChatSwiftUI` SDK. This will be used to tailor the SDK to your needs, so create a new Swift file called `CustomFactory`. Now, you only need to add a `chatClient` object to it (we will do all the other work later). This is what it should look like for now:
-
-```swift
-import StreamChatSwiftUI
-import StreamChat
-
-class CustomFactory: ViewFactory {
- @Injected(\.chatClient) public var chatClient: ChatClient
-}
-```
-
-**Note**: if youāre not sure how the dependency injection mechanism works, check out our [SwiftUI Dependency Injection guide](https://getstream.io/chat/docs/sdk/ios/swiftui/dependency-injection/).
-
-In order to have a clean architecture, you must separate the logic from the view code. One of the most common ways to do this is through the MVVM-Architecture (Model-View-View-Model). You will create the `CallViewModel` next and give it some basic properties that will be filled later with the necessary SDK logic. This will make it easier for you to layout the UI and have that in place.
-
-Create a Swift file called `CallViewModel` and fill it with the following code:
-
-```swift
-import StreamChatSwiftUI
-import HMSSDK
-
-class CallViewModel: ObservableObject {
- // Video tracks of ourself and other people during a video call
- @Published var ownTrack: HMSVideoTrack?
- @Published var otherTracks: Set = []
-
- // Handles for muting of audio / video
- @Published var isAudioMuted = false
- @Published var isVideoMuted = false
-
- // This published variable is responsible for handling if
- // we show the sheet containing the call UI
- @Published var isCallScreenShown = false
-
- // The channelId is needed for editing and sending messages
- var channelId: ChannelId?
-
- // Leave a call and use a potential completionHandler
- func leaveCall(completionHandler: @escaping () -> Void) {
- // fill later
- }
-}
-```
-
-If you need a refresher on MVVM architecture, [there is a nice article here](https://www.hackingwithswift.com/books/ios-swiftui/introducing-mvvm-into-your-swiftui-project).
-
-We used the `HMSVideoTrack` type here, which is taken directly from the 100ms SDK (`HMSSDK`) that we import at the top of the file. This type is basically what its name suggests - a video track of a call participant. You will need the `@Published` properties later when you assemble the UI.
-
-Speaking of UI, create a SwiftUI view called `VideoView` to fill in during the next chapter.
-
-## 4. Create a Basic Layout UI
-
-You saw the UI in the video at the beginning of this guide. Itās not a complicated setup, and luckily, the SDKs provide a lot of assistance. But thereās still work to be done, so letās get to it.
-
-### Create the Video Call View
-
-Start off by opening the view that you created at the end of the last chapter (`VideoView`) of the call.
-
-![Preview of the UI of a currently ongoing call.](../assets/video-call-preview.png)
-
-The UI components of the call are:
-
-1. A vertical list of all call participants
-2. The userās own video (placed at the top right above the other content)
-3. A row of buttons at the bottom to control certain call elements (namely, **toggle audio**, **video**, and **end call**)
-
-You can achieve this effect with a `ZStack` that has the list of all call participants as the first element. Then, you can layout the userās own video and the button rows with a combination of `VStack` and `HStack`.
-
-Before you create the layout, you will create a wrapper for the video representation of the tracks for each participant. The reason for that is that the 100ms SDK provides us with a `UIKit` view. Luckily, you can use that in your `SwiftUI` context very easily.
-
-Create a file called `VideoViewRepresentable.swift` and put the following code inside:
-
-```swift
-struct VideoViewRepresentable: UIViewRepresentable {
-
- var track: HMSVideoTrack
-
- func makeUIView(context: Context) -> some UIView {
- let videoView = HMSVideoView()
- videoView.setVideoTrack(track)
- return videoView
- }
-
- func updateUIView(_ uiView: UIViewType, context: Context) {
- // nothing to do here, but necessary for the protocol
- }
-}
-```
-
-**Note**: if youāre not sure how to bridge from `SwiftUI` to `UIKit`, [Apple has created a nice tutorial about it](https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit) to learn more.
-
-Now, head over to your `VideoView.swift` and create the following three properties:
-
-```swift
-private let buttonSize: CGFloat = 50
-@ObservedObject var viewModel: CallViewModel
-@Environment(\.dismiss) var dismiss
-```
-
-The `buttonSize` is used for the buttons you show at the bottom of the screen. The `viewModel` variable is what you need to access to the variables called `ownTrack` and `otherTracks` in order to show the video UI for the user and the other participants. The `dismiss` is used to close the view after a call is ended (since it will be shown with a `.sheet` modifier).
-
-Add the layout to the `body` of `VideoView` now:
-
-```swift
-ZStack {
- // List of other attendees of the call
- VStack {
- ForEach(Array(viewModel.otherTracks), id: \.self) { track in
- VideoViewRepresentable(track: track)
- .frame(maxWidth: .infinity)
- }
- }
-
- VStack {
- // If we have video enabled, show our video track at the top right
- if let ownTrack = viewModel.ownTrack {
- HStack {
- Spacer()
-
- VideoViewRepresentable(track: ownTrack)
- .frame(width: 100, height: 150)
- .overlay(
- Rectangle().stroke(Color.primary, lineWidth: 2)
- )
- .shadow(radius: 10)
- .padding()
- }
- }
-
- Spacer()
-
- // Show the three icons (mute, toggle video, end call) at the bottom
- HStack(spacing: 40) {
- Button {
- // mute
- } label: {
- Image(systemName: viewModel.isAudioMuted ? "speaker.slash.circle" : "speaker.circle")
- .resizable()
- .foregroundColor(viewModel.isAudioMuted ? .gray : .primary)
- .frame(width: buttonSize, height: buttonSize)
- }
-
- Button {
- // toggle video
- } label: {
- Image(systemName: viewModel.isVideoMuted ? "video.slash" : "video.circle")
- .resizable()
- .foregroundColor(viewModel.isVideoMuted ? .gray : .primary)
- .frame(width: buttonSize, height: buttonSize)
- }
-
- Button {
- // end call
- dismiss()
- } label: {
- Image(systemName: "phone.circle.fill")
- .resizable()
- .foregroundColor(.red)
- .frame(width: buttonSize, height: buttonSize)
- }
- }
- .padding()
- }
-}
-```
-
-This is all you need to do to show the UI of the video call. The advantage of the separated logic is that you can really focus on laying out UI in the `VideoView` itself.
-
-The next step is to add a button to start a call to the `ChannelHeader`. Luckily, the StreamChatSwiftUI SDK offers a factory method called `makeChannelHeaderViewModifier` that you can use to customize it.
-
-### Customizing the ChannelHeader
-
-Before you can use the methods from the SDK you need to build up the UI for the header itself.
-
-![Preview of the channel header UI.](../assets/100ms-channel-header.png)
-
-You start off by creating a new Swift file called `CustomChatChannelHeader.swift`. It will define a toolbar and the content that should go there, which are two things:
-
-1. The name of the channel in the middle
-2. A button to start a call on the right of the toolbar
-
-You will leverage the `ToolbarContent` type for that and create your items as `ToolbarItem`s with a placement parameter specifying the position.
-
-There are a few things you need to make everything work as expected. Create the struct and add the following parameters to it:
-
-```swift
-public struct CustomChatChannelHeader: ToolbarContent {
- // Stream SDK related
- @Injected(\.fonts) var fonts
- @Injected(\.utils) var utils
- @Injected(\.chatClient) var chatClient
-
- // Parameters received upon creation
- @ObservedObject var viewModel: CallViewModel
- public var channel: ChatChannel
- @Binding var isCallShown: Bool
- public var onTapTrailing: () -> ()
-
- public var body: some ToolbarContent {
- // To fill
- }
-}
-```
-
-The need for those will become clear once you add the layout in the `body` inside of `CustomChatChannelHeader`:
-
-```swift
-// Name of the channel
-ToolbarItem(placement: .principal) {
- VStack {
- Text(utils.channelNamer(channel, chatClient.currentUserId) ?? "")
- .font(fonts.bodyBold)
- }
-}
-
-// Button to start a call
-ToolbarItem(placement: .navigationBarTrailing) {
- Button {
- onTapTrailing()
- } label: {
- Image(systemName: "video.fill")
- }
-}
-```
-
-This allows you to have the name of the channel with the `.principal` placement (the middle) and the button to start a call for the `.navigationBarTrailing` placement.
-
-The action that is happening is handed in as a closure with the name `onTapTrailing`. The image for the button is taken from [SF Symbols](https://developer.apple.com/sf-symbols/).
-
-You need to create one more element, which is the modifier for the channel header. It is the place where you will define the functionality of the `onTapTrailing` closure. You will also need to add a second, very important thing.
-
-Here is where youāll add a `.sheet` modifier that will hold the `VideoView` that will pop up when a call is entered.
-
-Create that below the definition of the `CustomChannelHeader`:
-
-```swift
-struct CustomChannelHeaderModifier: ChatChannelHeaderViewModifier {
-
- var channel: ChatChannel
- @ObservedObject var viewModel: CallViewModel
-
- func body(content: Content) -> some View {
- content.toolbar {
- CustomChatChannelHeader(viewModel: viewModel, channel: channel, isCallShown: $viewModel.isCallScreenShown) {
- Task {
- await viewModel.createCall()
- }
- }
- }
- .sheet(isPresented: $viewModel.isCallScreenShown, onDismiss: {
- viewModel.leaveCall {}
- }, content: {
- VideoView(viewModel: viewModel)
- })
- }
-}
-```
-
-The code contains a `body` that is handed the content. Attach the `CustomChannelHeader` as a toolbar with the `.toolbar` modifier.
-
-Then, create a `Task` and call the `createCall` method of the view model. This method will later be filled with functionality. It will make the sheet pop up and you can handle joining the call from the `VideoView` itself. The `VideoView` is set as the content of the sheet.
-
-The last step is to add the `makeChannelHeaderViewModifier` override in the `CustomFactory`. Open up `CustomFactory` and add the following snippet:
-
-```swift
-func makeChannelHeaderViewModifier(for channel: ChatChannel) -> some ChatChannelHeaderViewModifier {
- // when we create the channel header we know that the channel has become active, so we notify the viewModel
- viewModel.channelId = channel.cid
-
- return CustomChannelHeaderModifier(channel: channel, viewModel: viewModel)
-}
-```
-
-The reason why the `channelId` in the `viewModel` is set here is that it is needed to later join the call. When a user taps on a channel, its header will be rendered and it's a notification for you that you can safely set the ID.
-
-### Creating Custom Call Messages
-
-You will show a custom UI for call messages in the message list that will look like this:
-
-![Preview of how the custom message attachments UI will look like.](../assets/100ms-custom-message-attachments.png)
-
-In order to create custom messages for calls, you will leverage [the custom attachments functionality of the StreamChat SDK](../../swiftui/message-components/attachments/). It takes three steps:
-
-1. Create a custom view to show the call messages
-2. Detect when to show the custom call message (with a custom message resolver)
-3. Use the `makeCustomAttachmentViewType` to render the view you created
-
-You will need a few `String` constants to compare. First, you will create an extension to `String` and add them. Create a new Swift file called `String+Constants` and fill it with the following content:
-
-```swift
-import Foundation
-
-extension String {
- // Extra data keys
- static let callKey = "isCall"
- static let roomIdKey = "roomId"
-
- // Message texts
- static let callOngoing = "Call ongoing"
- static let callEnded = "Call ended"
-}
-```
-
-Second, create a new file called `VideoCallAttachmentView`. It will have two parameters handed to it: `viewModel` and the `message` (of type `ChatMessage`).
-
-With the entire UI construction the view looks like this:
-
-```swift
-import SwiftUI
-import StreamChat
-
-struct VideoCallAttachmentView: View {
-
- @ObservedObject var viewModel: CallViewModel
- let message: ChatMessage
-
- var isActiveCall: Bool {
- message.text == .callOngoing
- }
-
- var body: some View {
- HStack(spacing: 20) {
- VStack(alignment: .leading, spacing: 4) {
- Text("CALL")
- .font(.caption)
- .foregroundColor(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- Text(message.text)
- .font(.headline)
- .bold()
- }
-
- if isActiveCall {
- Button {
- // End call, filled later
- } label: {
- Image(systemName: "phone.circle")
- .resizable()
- .foregroundColor(.red)
- .frame(width: 30, height: 30)
- }
-
- Button {
- // Join call, filled later
- } label: {
- Image(systemName: "phone.circle.fill")
- .resizable()
- .foregroundColor(.green)
- .frame(width: 30, height: 30)
- }
- }
- }
- .padding()
- .background(
- isActiveCall ? Color.green.opacity(0.1) : Color.red.opacity(0.1),
- in: RoundedRectangle(cornerRadius: 10, style: .continuous))
- }
-}
-```
-
-The computed property `isActiveCall` is used to determine whether to show the UI for joining/ending the call. Itās simply determined by the text of the message.
-
-The functionality of the `Button`s is not filled with anything yet, but you will add that once itās available in the view model (part of the next chapter).
-
-Next, you will create a message resolver. This tells the StreamChat SDK whether a message needs to be rendered in a custom way. Create a Swift file called `CustomMessageResolver` and fill it with this:
-
-```swift
-import StreamChat
-import StreamChatSwiftUI
-
-class CustomMessageResolver: MessageTypeResolving {
- func hasCustomAttachment(message: ChatMessage) -> Bool {
- if message.extraData.keys.contains(.callKey) {
- return true
- } else {
- return false
- }
- }
-}
-```
-
-The only requirement for it is to have the `hasCustomAttachment` function that will check if the message has the `.callKey` string in its `extraData` field. If so, it will return `true` (it is, in fact, a call message), otherwise `false`.
-
-You need to introduce that `CustomMessageResolver` into the _StreamChat_ SDK. Go to the `AppDelegate` and replace the following line
-
-```swift
-streamChat = StreamChat(chatClient: chatClient)
-```
-
-with this piece of code:
-
-```swift
-let messageTypeResolver = CustomMessageResolver()
-let utils = Utils(messageTypeResolver: messageTypeResolver)
-
-streamChat = StreamChat(chatClient: chatClient, utils: utils)
-```
-
-Lastly, go to the `CustomFactory` you created earlier and paste the `makeCustomAttachmentViewType` function inside:
-
-```swift
-func makeCustomAttachmentViewType(
- for message: ChatMessage,
- isFirst: Bool,
- availableWidth: CGFloat,
- scrolledId: Binding
-) -> some View {
- VideoCallAttachmentView(viewModel: viewModel, message: message)
-}
-```
-
-All it does is render the previously created `VideoCallAttachmentView` in case it detects the call message in the `CustomMessageResolver`.
-
-This finishes up all the UI that was necessary to create. The next step is to add the integration of the StreamChat SDK to send messages with the call information.
-
-## 5. Send and Edit Chat Messages for the Calls
-
-There are two use cases you need to cover for sending and editing messages:
-
-1. **Starting a call** - which will send a message to the channel with the necessary info for anybody to join it.
-2. **Ending a call** - which will edit the original message specifying that nobody can join anymore and the call has ended.
-
-### Starting a Call and Sending the Message
-
-When the user hits the call button at the top right of the channel header a new message should be sent to the channel. Therefore, a new room needs to be created with the _100ms SDK_, which is happening on the backend. The Stream Chat SDK offers native support for `100ms` so it's easy to create one and retrieve the necessary information (namely a `roomId`) with a single API call.
-
-In order to make the API work nicely with our `async/await` based architecture, we first extend the `ChatClient` to build upon the closure-based API from the SDK. Create a new file called `ChatClient+createCall.swift` and paste this code inside of it:
-
-```swift
-import StreamChat
-
-extension ChatClient {
- func createCall(with id: String, in channelId: ChannelId) async throws -> CallWithToken {
- try await withCheckedThrowingContinuation({ continuation in
- channelController(for: channelId).createCall(id: id, type: "video") { result in
- continuation.resume(with: result)
- }
- })
- }
-}
-```
-
-We create a call with a given ID (that is randomly generated) in a channel with the `channelId` (of type `ChannelId`). Inside the SDK function of the `channelController` is called.
-
-:::note
-The only supported type for a call as of now is `"video"` so we hardcoded this in the extension of the `ChatClient`.
-:::
-
-In order to initiate a call in the `CallViewModel` there is a few steps to take. First, it's necessary to make sure a valid `channelId` is present. Then the `createCall` function that was just created on the `chatClient` can be called. The room ID will be a randomly created `UUID` from the client-side, but can also be created from the backend directly.
-
-After we made sure that a valid room ID was received (and it is set on the `viewModel`), the `chatClient` can be used to get a `channelController` that you can use to call `createNewMessage`.
-
-Finally, you can start the call screen by setting `isCallScreenShown` to `true`.
-
-:::note
-You need to make sure that the call to isCallScreenShown
is happening on the main thread, so itās necessary to wrap it into an await MainActor.run {}
call.
-:::
-
-Head over to the `CallViewModel` and create the `createCall` function:
-
-```swift
-func createCall() async {
- do {
- guard let channelId = channelId else {
- return
- }
-
- let callWithToken = try await chatClient.createCall(with: UUID().uuidString, in: channelId)
- guard let roomId = callWithToken.call.hms?.roomId else {
- return
- }
-
- self.roomId = roomId
-
- chatClient
- .channelController(for: channelId)
- .createNewMessage(text: .callOngoing, extraData: createExtraData(with: roomCreationResult.roomId))
-
- await MainActor.run {
- isCallScreenShown = true
- }
- } catch {
- print(error.localizedDescription)
- }
-}
-```
-
-In order for this code to work, youāll need to add two more things to the `CallViewModel`. The first is the `chatClient` which can be easily added with the `@Injected` property wrapper from the StreamChatSwiftUI SDK. So, add that as a property:
-
-```swift
-@Injected(\.chatClient) var chatClient
-```
-
-Next, youāll need to create a convenience function to create the extra data that is added to a message. Add that in the `CallViewModel` class as well:
-
-```swift
-private func createExtraData(with roomId: String) -> [String: RawJSON] {
- return [
- // Indicator that this message is attached to a call
- .callKey: .bool(true),
- // RoomId for the room the call is happening in
- .roomIdKey: .string(roomId)
- ]
-}
-```
-
-With that, the code will compile and messages will be sent to the channel when a call is initiated.
-
-### End Call and Edit the Message
-
-The next step is to edit the message whenever a user is ending the call with the red button of the message. Since a reactive architecture is set up, the _StreamChatSwiftUI_ SDK will automatically update the cells and adapt to the changes.
-
-The code is straightforward and does three things. First, it unwraps the `channelId`, then it creates a `messageController` from it, and finally, it calls the `editMessage` function on top of it.
-
-So, add this function to the `CallViewModel`:
-
-```swift
-func endCall(from messageId: MessageId) {
- guard let cid = channelId else { return }
-
- let messageController = chatClient.messageController(cid: cid, messageId: messageId)
-
- messageController.editMessage(text: .callEnded) { error in
- if let error = error {
- print("Error: \(error.localizedDescription)")
- }
- }
-}
-```
-
-Now, you need to call that function. Head over to `VideoCallAttachmentView` and search for the following text inside of the button that ends the call:
-
-```swift
-// End call, filled later
-```
-
-Replace the comment with the call to the `viewModel`:
-
-```swift
-viewModel.endCall(from: message.id)
-```
-
-Thatās all you need to customize in the _StreamChat_ & _StreamChatSwiftUI_ SDK.
-
-## 6. Integrating the 100ms SDK
-
-So far we have not dived into code that is specific to the _100ms_ SDK too much (aside from UI code). This was intentional as it is demonstrating that the integration of any video service can be done with the same architecture that is wrapped around it.
-
-Now, the integration of the framework is starting so the functionality is tailored towards the _100ms_ SDK. First thing is to initialize the framework in the `CallViewModel`.
-
-First, add the import at the top of the file:
-
-```swift
-import HMSSDK
-```
-
-Second, create a property variable called `hmsSDK` inside of the `CallViewModel` and initialize it like so:
-
-```swift
-var hmsSDK = HMSSDK.build()
-```
-
-You need a couple of convenience functions thatāll make it easier to perform the rest of the integration.
-
-Add the following two functions inside of the `CallViewModel`:
-
-```swift
-func toggleAudioMute() {
- isAudioMuted.toggle()
- hmsSDK.localPeer?.localAudioTrack()?.setMute(isAudioMuted)
-}
-
-func toggleVideoMute() {
- isVideoMuted.toggle()
- hmsSDK.localPeer?.localVideoTrack()?.setMute(isVideoMuted)
-}
-```
-
-What they are doing is self-explanatory.
-
-You can easily hook them up by calling them from the respective button in the `VideoView`. Head over to that file and locate the following line:
-
-```swift
-// mute
-```
-
-Replace it with the call to the function in the `CallViewModel` that we just added:
-
-```swift
-viewModel.toggleAudioMute()
-```
-
-Do the exact same with the `// toggle video` and replace it with `viewModel.toggleVideoMute()`.
-
-### Joining a Call
-
-In order to join a call there are a few steps you need to do:
-
-1. Show the call screen by setting `isCallScreenShown` to `true`
-2. Get an auth token (we can re-use the `createCall` function of the `chatClient`)
-3. Create a `config` variable for the `hmsSDK` (of type `HMSConfig`) with the name of the current user (if available)
-4. Call the `join` function of the `hmsSDK`
-
-The following code snippet does exactly that, so add it to the `CallViewModel`:
-
-```swift
-func joinCall(with roomId: String) async {
- do {
- isCallScreenShown = true
- guard let channelId = channelId else {
- return
- }
-
- let callWithToken = try await chatClient.createCall(with: roomId, in: channelId)
- let config = HMSConfig(userName: chatClient.currentUserController().currentUser?.name ?? "Unknown User", authToken: callWithToken.token)
-
- hmsSDK.join(config: config, delegate: self)
- } catch {
- print(error.localizedDescription)
- isCallScreenShown = false
- }
-}
-```
-
-The `joinCall` function will be called when the `VideoView` appears. It will get the `roomId` from the `viewModel` and then initiate an `async` call to `joinCall`. That code needs to run inside of a `Task`.
-
-Open up the `VideoView` and add the following modifier to the root `ZStack`:
-
-```swift
-.onAppear {
- guard let roomId = viewModel.roomId else {
- print("Couldn't join because no roomId was found!")
- return
- }
-
- Task {
- await viewModel.joinCall(with: roomId)
- }
-}
-```
-
-Lastly, you need to set up the button in the `VideoCallAttachmentView` when a user wants to join the room.
-
-Open up the `VideoCallAttachmentView` and find the button to join the call with the comment:
-
-```swift
-// Join call
-```
-
-Replace it with the following code:
-
-```swift
-guard case let .string(roomId) = message.extraData[.roomIdKey] else {
- print("Call didn't contain roomId (messageId: \(message.id))")
- return
-}
-viewModel.roomId = roomId
-viewModel.isCallScreenShown = true
-```
-
-It extracts the `roomId` from the messages `extraData` array and sets it in the `viewModel`. After that, it updates the `isCallScreenShown` variable. Once the `VideoView` appears the previously created modifier takes over and calls the `joinCall` function.
-
-### Listening for Updates During a Call
-
-During a call, it is necessary to listen for people joining or leaving the call in order to update the UI.
-
-The previous code you added to the `joinCall` function will not build because it sets the `CallViewModel` to be the delegate of the `HMSSDK`. See this call:
-
-```swift
-hmsSDK.join(config: config, delegate: self)
-```
-
-In order to listen for updates, the `CallViewModel` needs to conform to the `HMSUpdateListener` protocol from the `HMSSDK`.
-
-Create a new Swift file and call it `CallViewModel+HMSUpdateListener`. There are a few callbacks you need to listen to.
-
-The first one is to listen when someone joins the current room that the user is currently in, which is called `on(join room: HMSRoom)`. It will iterate over all the peers in the room and check if they have video tracks attached. If yes, it will add it to either the local track (represented by the `ownTrack` variable in the view model) or insert it into the `otherTracks` set.
-
-```swift
-func on(join room: HMSRoom) {
- for peer in room.peers {
- // check if video track is attached to the peer
- if let videoTrack = peer.videoTrack {
- // if it's the user's own video we set it to their own track, else add it to the "other" video view's
- if peer.isLocal {
- ownTrack = videoTrack
- } else {
- otherTracks.insert(videoTrack)
- }
- }
- }
-}
-```
-
-The next thing you want to listen to is when a `peer` (= another participant of the call) is leaving the call because in this case, their video track needs to be removed.
-
-The `HMSSDK` offers the `on(peer: HMSPeer, update: HMSPeerUpdate)` function for that and by checking the `update` variable you can detect if itās of type `.peerLeft`. In that case, the `videoTrack` is extracted and removed from the `otherTracks` set.
-
-```swift
-func on(peer: HMSPeer, update: HMSPeerUpdate) {
- switch update {
- case .peerLeft:
- // remove video if the peer has a video track attached to them
- if let videoTrack = peer.videoTrack {
- otherTracks.remove(videoTrack)
- }
- default:
- break
- }
-}
-```
-
-During a call, it can also happen that tracks are updated. Itās important to listen to the two update types `.trackAdded` and `.trackRemoved` and act accordingly.
-
-The logic is similar to the ones before, hereās the code for it:
-
-```swift
-func on(track: HMSTrack, update: HMSTrackUpdate, for peer: HMSPeer) {
- switch update {
- case .trackAdded:
- if let videoTrack = track as? HMSVideoTrack {
- if peer.isLocal {
- ownTrack = videoTrack
- } else {
- otherTracks.insert(videoTrack)
- }
- }
- case .trackRemoved:
- if let videoTrack = track as? HMSVideoTrack {
- if peer.isLocal {
- ownTrack = nil
- } else {
- otherTracks.remove(videoTrack)
- }
- }
- default:
- break
- }
-}
-```
-
-With that, you are covering all relevant changes and always keep the `VideoView` in sync with whatās happening in the call.
-
-### Leaving a Call
-
-The last functionality to add is to leave calls. At the beginning of this integration guide you already added the function signature in the view model for `leaveCall` and now youāre going to populate it with the following code:
-
-```swift
-func leaveCall(completionHandler: @escaping () -> Void) {
- hmsSDK.leave { success, error in
- guard success == true, error == nil else {
- print("hmsSDK.leave: Error: \(error?.message ?? "unknown")")
- return
- }
-
- completionHandler()
- }
-}
-```
-
-It calls the `hmsSDK` function and listens for the result in its completion handler. Youāre then calling the completion handler thatās being handed to it.
-
-There are two occasions where it needs to be called. The first one is already covered in the `CustomChatChannelHeader`. When the `sheet` that is used for showing the `VideoView` is dismissed it will call `viewModel.leaveCall {}` (so with an empty completion handler) because thereās nothing more to do in that case.
-
-If the user is in the `VideoView` itself and presses on the end call button however thereās nothing happening right now.
-
-You need to fix that by going to the `VideoView` and locating the button with the following comment in it:
-
-```swift
-// leave call
-```
-
-Replace that with the code:
-
-```swift
-viewModel.leaveCall {
- dismiss()
-}
-```
-
-In order for this to work, you need to import the environment variable `dismiss` which is used to dismiss sheets programmatically. You did this earlier and now you make use of it.
-
-Thatās it. Leaving calls works now and all the necessary code is handled.
-
-## Summary
-
-In this guide, you completed the entire integration of a video service into a chat app created with the _StreamChat_ SDK. All this happened with a clean architectural approach that makes it straightforward to also use other video services in case you want to experiment with that.
-
-For the purpose of simplification, we have not offered audio calls in this guide. But the principle is applicable with very few changes as well.
-
-The 100ms SDK works really well in this case and allows you to quickly set up and use a video call service in your apps without complicated processes and manual work that needs to be done.
-
-In case you have any more questions about this video integration or the work with other SDKs, feel free to [reach out to the team](https://getstream.io/contact/). We are happy to help and support you.
-
-Thank you for following along with this article.
diff --git a/docusaurus/docs/iOS/guides/video-agora.md b/docusaurus/docs/iOS/guides/video-agora.md
deleted file mode 100644
index f7c37977fc8..00000000000
--- a/docusaurus/docs/iOS/guides/video-agora.md
+++ /dev/null
@@ -1,955 +0,0 @@
----
-title: Agora Video Integration Guide
----
-
-## Introduction
-
-Video calls have become immensely popular since the onset of the pandemic. Today, we take a look at how you can use the service of [agora](https://www.agora.io/en/) to integrate video calls into the Stream Chat SDK.
-
-Agora is an infrastructure provider for live, interactive voice and video. They offer native SDKs for mobile platforms, cross-platform frameworks, and the web and allow for simple integration with very few lines of code. They cover a wide range of use-cases such as video conferencing, interactive live-streaming, real-time messaging, and many more.
-
-You must complete quite a few steps to create the final product. We will cover all of them to help you create a well-integrated, fully functional, and reusable solution in the end.
-
-First, letās take a look at the end result of this project:
-
-
-
-In order to create this, follow these six steps:
-
-1. Set up an Agora account
-2. Stream Dashboard integration
-3. Set up a basic app architecture
-4. Layout UI
-5. Update the channel information to indicate an active call
-6. Hook up the agora SDK to the UI
-
-In order to not start from the beginning, [the tutorial for SwiftUI](https://getstream.io/tutorials/swiftui-chat/) from [our website](https://getstream.io) is set as the starting point. So, if you followed this one, you are ready to jump right in.
-
-## 1. Setting up an account for Agora
-
-You need to set up an account on the [agora.io](http://agora.io) website. Once youāve created the account, you will need to create a project and look for it in the [console](https://console.agora.io). Once you have that ready, you will need to prepare two things for the creation of the server in the next chapter:
-
-- the app id
-- the app certificate
-
-Once you have these two available you can continue with this guide.
-
-## 2. Stream Dashboard integration
-
-In order for the integration of agora to work there needs to be a server component handling token generation and more. Normally this would require a custom server implementation but Stream offers first-class integration for agora relieving you of these duties.
-
-There is only a few setup-steps to go through in the Stream dashboard and this guide details all of them:
-
-1. Head over to the [Dashboard](https://dashboard.getstream.io) and login
-2. Create a new app or select your app by name
-3. In the sidebar on the left, select **Ext. Video Integration**
-4. Make sure the **Agora** tab is selected
-
-This is the screen that you navigated to:
-
-![View of the Stream Dashboard when an app was selected with the external video integration.](../assets/agora-guide-before.png)
-
-First, it is necessary to enable the integration through the toggle in the top right (red arrow in the image below). Make sure that you can see the green **Agora Enabled** badge at the top.
-
-Next, it is necessary to enter the credentials from the agora console. This is the place you need to enter the aforementioned `app id` and `app certificate` from the agora console.
-
-![View of the Stream Dashboard when the agora video integration was enabled.](../assets/agora-guide-after.png)
-
-With these steps being taken, the Stream Chat SDK will use these credentials internally to initiate calls without you needing to take additional steps.
-
-So, let's focus on the iOS implementation.
-
-## 3. Set up basic app architecture
-
-The integration requires a bit of setup which is why you will create most of the necessary files right now. This will provide a solid overview of the overall architecture and you will fill up the files more and more over the course of following this integration guide.
-
-Before starting, you need to import the Agora SDK into the project. The recommended way is to use Swift Package Manager (SPM) for it. [Follow the guide on their GitHub repository](https://github.com/AgoraIO/AgoraRtcEngine_iOS/) to add the dependency to your project.
-
-For the implementation, you will need an object that conforms to the `ViewFactory` of the `StreamChatSwiftUI` SDK. This will be used to tailor the SDK to your needs so you will create a new Swift file called `CustomFactory`. You now only need to add a `chatClient` object to it and we will do all other work later. This is what it should look like for now:
-
-```swift
-import StreamChatSwiftUI
-import StreamChat
-
-class CustomFactory: ViewFactory {
- @Injected(\.chatClient) public var chatClient: ChatClient
-}
-```
-
-Note: if youāre not sure how the dependency injection mechanism works, [we have a nice resource to read up on it](https://getstream.io/chat/docs/sdk/ios/swiftui/dependency-injection/).
-
-In order to have a clean architecture, separate the logic from the view code. One of the most common approaches for that is the MVVM-Architecture (Model-View-ViewModel). Create the `CallViewModel` next and give it some basic properties that will later be filled up with the necessary SDK logic. This will make it easier for you to layout the UI and have that in place.
-
-Create a Swift file called `CallViewModel` and fill it with the following code:
-
-```swift
-import SwiftUI
-import StreamChat
-import StreamChatSwiftUI
-
-class CallViewModel: NSObject, ObservableObject {
-
- @Injected(\.chatClient) var chatClient
-
- // Indicates whether a call is active at the moment
- @Published fileprivate(set) var callActive: Bool = false
-
- // Indicates whether the call screen is currently shown
- @Published var isCallScreenShown: Bool = false
-
- // Handles for muting of audio / video
- @Published private(set) var isAudioMuted = false
- @Published private(set) var isVideoMuted = false
-
- // Needed to update the call info about the channel
- private var channelController: ChatChannelController?
-
- // Property used to detect if the user is currently in a call
- @Published private(set) var ownUid: UInt?
-
- func setChannelId(_ id: ChannelId) {}
-
- func startCall(updateChannel: Bool = true) {}
-
- func joinCall() {}
-
- func leaveCall() {}
-
- func toggleAudioMute() {}
-
- func toggleVideoMute() {}
-}
-```
-
-If you need a refresher on the entire MVVM architecture [there is a nice article here](https://www.hackingwithswift.com/books/ios-swiftui/introducing-mvvm-into-your-swiftui-project).
-
-There are a few methods already defined which have no logic inside. You will gradually fill the view model up with more logic throughout this guide, but first, letās start by building out the UI for the project. Having the (empty) functions in place already allows us to build up the UI completely and only care for the logic later inside of the `CallViewModel`.
-
-## 4. Layout basic UI
-
-You saw the UI in the video at the beginning of this guide. Itās not a complicated setup, and luckily, the SDKs provide a lot of assistance. But thereās still work to be done, so letās get to it.
-
-Youāll start off with creating the view for calls, that shows some UI elements and the participants. Letās have a look first at what it is made up of:
-
-![Preview of the call screen](../assets/video-call-preview.png)
-
-The UI components are:
-
-1. A vertical list of all call participants
-2. The userās own video is placed at the top right above the other content
-3. A row of buttons at the bottom to control certain call elements (namely toggle audio and video and end call)
-
-### Create a View for Call Participants
-
-For the rendering of participantsā videos, you will use the _agora_ SDK. This is only supported in UIKit yet so this part of the UI will be wrapped inside of a `ViewController` that will then be bridged with SwiftUI to make it integrate seamlessly into the project.
-
-Start off by creating a new Swift file called `RemoteVideoViewController`.
-
-Its structure is fairly straightforward. It will only consist of a `UIStackView`. When a new participant is joining a call it will have an `addVideoView` function that adds a new view to the stack view and a `removeVideoView` that will remote a certain view from the stack view.
-
-In order to keep track of the participants and their respective video views, there will be a variable called `remoteVideoViews` which is a dictionary that assigns the respective video view to the key of the id of the participant.
-
-With that, you can easily remove a view from the stack view (via `.removeFromSuperview()`).
-
-Letās fill the `RemoteVideoViewController.swift` with the following code:
-
-```swift
-import UIKit
-import AgoraRtcKit
-import SwiftUI
-
-class RemoteVideoViewController: UIViewController {
-
- let stackView: UIStackView = {
- let sv = UIStackView()
- sv.translatesAutoresizingMaskIntoConstraints = false
- sv.axis = .vertical
- sv.distribution = .fillEqually
- sv.spacing = 16.0
- return sv
- }()
-
- var remoteVideoViews: [UInt: UIView] = [:]
-
- override func viewDidLoad() {
- super.viewDidLoad()
-
- self.view.addSubview(stackView)
-
- NSLayoutConstraint.activate([
- stackView.topAnchor.constraint(equalTo: view.topAnchor),
- stackView.leftAnchor.constraint(equalTo: view.leftAnchor),
- stackView.rightAnchor.constraint(equalTo: view.rightAnchor),
- stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- }
-
- func addVideoView(_ view: AgoraRtcVideoCanvas, with uid: UInt) {
- let containerView = UIView()
- containerView.backgroundColor = .gray
- containerView.translatesAutoresizingMaskIntoConstraints = false
-
- stackView.addArrangedSubview(containerView)
-
- view.view = containerView
-
- remoteVideoViews[uid] = containerView
- }
-
- func removeVideoView(with uid: UInt) {
- if !remoteVideoViews.keys.contains(uid) {
- print("Uid (\(uid)) is not in keys.")
- return
- }
- let viewToRemove = remoteVideoViews[uid]
- viewToRemove?.removeFromSuperview()
- remoteVideoViews.removeValue(forKey: uid)
- }
-
- override func viewDidDisappear(_ animated: Bool) {
- remoteVideoViews = [:]
- for child in stackView.subviews {
- child.removeFromSuperview()
- }
- super.viewDidDisappear(animated)
- }
-
-}
-```
-
-
-
-
-
-Connecting this code with real calls will happen in a later chapter, but the UI is functional for now. The last required step is to create a bridge for SwiftUI so that you can use the `RemoteVideoViewController` in the SwiftUI world.
-
-Luckily, there is `UIViewControllerRepresentable` that allows to quite simply wrap a `UIViewController` with very few lines of code.
-
-Add the following lines to the top of the `RemoteVideoViewController.swift` file (after the imports):
-
-```swift
-struct RemoteVideoView: UIViewControllerRepresentable {
-
- let viewController: RemoteVideoViewController
-
- func makeUIViewController(context: Context) -> some UIViewController {
- return viewController
- }
-
- func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
- // nothing to do here
- }
-}
-```
-
-The `viewController` variable is used because it will be part of the view model and needs to reflect updates from the _agora_ SDK when participants are joining or leaving a call.
-
-Add the following variable to `CallViewModel`:
-
-```swift
-var remoteVideoViewController = RemoteVideoViewController()
-```
-
-You have now taken care of the video from other participants of the call, so letās tackle the userās own video next.
-
-### Create a view of the userās video
-
-What you can see from the UIKit view is that in order to show the remote video, you need to have a `UIView` as a container. Youāll then create the _agora_ SDKās video view and assign your container as its `view` property (you will see how to do that in the later chapter called ā**Integration of the agora SDK**ā.
-
-The process is merely the same for the userās local video. Again, youāll need to create a `UIView` for the container that serves as a canvas for the video.
-
-Create a new Swift file called `VideoCanvas` and fill it with this code:
-
-```swift
-import SwiftUI
-
-struct VideoCanvas: UIViewRepresentable {
-
- let containerView = UIView()
-
- func makeUIView(context: Context) -> some UIView {
- containerView.backgroundColor = .gray
- return containerView
- }
-
- func updateUIView(_ uiView: UIViewType, context: Context) {
- // Nothing to do here
- }
-
-}
-```
-
-It is important to have a reference to the `containerView` because that is needed to assign it to the _agora_ SDKās video view.
-
-With that, head over to the `CallViewModel` and give it a new property (for example right below the previously added `remoteVideoViewController`):
-
-```swift
-var localCanvas = VideoCanvas()
-```
-
-Hooking that up with real functionality comes later but now you have everything ready to combine the previously created views and create the UI for an entire call.
-
-### Combine everything with the call view
-
-Create a new SwiftUI view called `CallView`. It will consist of the following pieces:
-
-1. The remote video parts (the `RemoteVideoView`)
-2. The userās video (the `localCanvas` you just added to the view model)
-3. A list of buttons to mute/unmute audio and video and end the call
-
-Because we access several variables and will also call functions from it the `CallView` needs to receive a `CallViewModel` upon initialization, so first, add that as a property:
-
-```swift
-@ObservedObject var viewModel: CallViewModel
-```
-
-You will be stacking views on top of each other so a `ZStack` will be at the root. Then the remote videos will be shown. On top, there will be a `VStack` that shows the userās video if it is available (the check for `viewModel.ownUid`) and if the video is not muted (the check for `viewModel.isVideoMuted`). The `VStack` will also contain the button row at the bottom with an `HStack` where the actions happening will be filled later on.
-
-Here is the code for the `CallView`:
-
-```swift
-import SwiftUI
-
-struct CallView: View {
-
- @ObservedObject var viewModel: CallViewModel
-
- var body: some View {
- ZStack {
- RemoteVideoView(viewController: viewModel.remoteVideoViewController)
-
- VStack {
- if let _ = viewModel.ownUid {
- HStack {
- Spacer()
-
- if viewModel.isVideoMuted {
- Rectangle()
- .fill(Color.gray)
- .frame(width: 100, height: 150)
- .shadow(radius: 10)
- } else {
- viewModel.localCanvas
- .frame(width: 100, height: 150)
- }
- }
- .padding()
- }
-
- Spacer()
-
- HStack(spacing: 40) {
- Button {
- // mute
- } label: {
- Image(systemName: viewModel.isAudioMuted ? "speaker.slash.circle" : "speaker.circle")
- .resizable()
- .foregroundColor(viewModel.isAudioMuted ? .gray : .primary)
- .frame(width: 50, height: 50)
- }
-
- Button {
- // toggle video
- } label: {
- Image(systemName: viewModel.isVideoMuted ? "video.slash" : "video.circle")
- .resizable()
- .foregroundColor(viewModel.isVideoMuted ? .gray : .primary)
- .frame(width: 50, height: 50)
- }
-
- Button {
- // end call
- } label: {
- Image(systemName: "phone.circle.fill")
- .resizable()
- .foregroundColor(.red)
- .frame(width: 50, height: 50)
- }
- }
- .padding()
- }
- }
- }
-}
-```
-
-With that, the `CallView` is finished and you have already called the correct functions on the button clicks. They are just not hooked up with the logic that, will happen in the next chapters.
-
-### Create the custom channel header
-
-For initiating calls the channel header will have a call icon at the right as shown in the screenshot below:
-
-![Preview image of the custom channel header.](../assets/agora-channel-header.png)
-
-Creating the custom channel header requires a few steps.
-
-You start off by creating a new Swift file called `CustomChatChannelHeader.swift`. It will define a toolbar and the content that should go there, which are two things:
-
-1. The name of the channel in the middle
-2. A button to start a call on the right of the toolbar
-
-You will leverage the `ToolbarContent` type for that and create your items as `ToolbarItem`s with a placement parameter specifying the position.
-
-There are a few things you need to make everything work as expected. Create the struct and add the following parameters to it:
-
-```swift
-public struct CustomChatChannelHeader: ToolbarContent {
- // Stream SDK related
- @Injected(\.fonts) var fonts
- @Injected(\.utils) var utils
- @Injected(\.chatClient) var chatClient
-
- // Parameters received upon creation
- @ObservedObject var viewModel: CallViewModel
-
- public var onTapTrailing: () -> ()
-
- public var body: some ToolbarContent {
- // To fill
- }
-}
-```
-
-The need for those will become clear in a second because now you will add the layout in the `body` inside of `CustomChatChannelHeader`:
-
-```swift
-// Name of the channel
-ToolbarItem(placement: .principal) {
- VStack {
- Text(utils.channelNamer(channel, chatClient.currentUserId) ?? "")
- .font(fonts.bodyBold)
- }
-}
-
-// Button to start a call
-ToolbarItem(placement: .navigationBarTrailing) {
- Button {
- onTapTrailing()
- } label: {
- Image(systemName: "video.fill")
- }
- .disabled(viewModel.callActive)
-}
-```
-
-This allows you to have the name of the channel with the `.principal` placement (the middle) and the button to start a call for the `.navigationBarTrailing` placement.
-
-The action that is happening is handed in as a closure with the name `onTapTrailing`. The image for the button is taken from [SF Symbols](https://developer.apple.com/sf-symbols/).
-
-You need to create one more element, which is the modifier for the channel header. It is the place where you want to define the functionality of the `onTapTrailing` closure and you also need to add one second, very important thing.
-
-The button to start a call will be disabled (with the `.disabled`) modifier when a call in the channel is active. You only want one active call at all times so disabling prevents users from initiating new ones while one is currently ongoing.
-
-We already mentioned it and here is where youāll add a `.sheet` modifier that will hold the `CallView` that will popup when a call is entered.
-
-Create that below the definition of the `CustomChannelHeader`:
-
-```swift
-struct CustomChannelHeaderModifier: ChatChannelHeaderViewModifier {
-
- var channel: ChatChannel
- @ObservedObject var viewModel: GeneralViewModel
-
- func body(content: Content) -> some View {
- content.toolbar {
- CustomChatChannelHeader(viewModel: viewModel, channel: channel, isCallShown: $viewModel.isCallScreenShown) {
- viewModel.startCall()
- }
- }
- .sheet(isPresented: $viewModel.isCallScreenShown, onDismiss: {
- viewModel.leaveCall()
- }, content: {
- CallView(viewModel: viewModel)
- })
- .onAppear {
- // when we the channel header appears the channel has become active, so we notify the viewModel
- viewModel.setChannelId(channel.cid)
- }
- }
-}
-```
-
-The code contains a `body` that is handed the content. Youāre attaching the `CustomChannelHeader` as a toolbar with the `.toolbar` modifier.
-
-Youāre then calling the `startCall` method of the view model. This method will later be filled with functionality. It will make the sheet pop up and you can handle joining the call from the `CallView` itself. The `CallView` is set as the content of the sheet.
-
-The reason why the `channelId` in the `viewModel` is set in the `.onAppear` modifier is that it is needed to later join the call. This makes use of the fact, that when a user taps on a channel, its header will be rendered and it's a notification for you that you can safely set the id.
-
-The last step is to add the `makeChannelHeaderViewModifier` override in the `CustomFactory`. Open up `CustomFactory` and add the following snippet:
-
-```swift
-func makeChannelHeaderViewModifier(for channel: ChatChannel) -> some ChatChannelHeaderViewModifier {
- return CustomChannelHeaderModifier(channel: channel, viewModel: viewModel)
-}
-```
-
-### Construct the call overlay for the channel
-
-When a call is active inside of a channel, there should be an indicator at the bottom of the screen that indicates that and allows users to join. It looks like this:
-
-![Preview of the UI when a call is ongoing inside of a channel.](../assets/agora-call-ongoing.png)
-
-The _StreamChatSwiftUI_ SDK makes it very easy to build that because you can create a custom view modifier and conditionally apply that. Letās first create the UI and then hook it up with the SDK in the `CustomFactory`.
-
-Create a new Swift file called `CallOverlay`. This will be a `struct` that conforms to `ViewModifier`. What this requires you to do is give it a function with the signature `body(content: Content) -> some View`.
-
-With the `callActive` property in the view model, you can detect if you need to apply the modifier. If not then you can just return the `content` itself.
-
-If it is active you will give the content a little padding and then create an overlay with it. The view code itself is rather unspectacular so weāll give you the entire code of the `CallOverlay` file here:
-
-```swift
-import SwiftUI
-
-struct CallOverlay: ViewModifier {
-
- @ObservedObject var viewModel: CallViewModel
-
- func body(content: Content) -> some View {
- if viewModel.callActive {
- content
- .padding(.top, 30)
- .overlay(
- VStack {
- Spacer()
- HStack {
- Text("Call ongoing")
-
- Spacer()
-
- Button {
- // join
- viewModel.joinCall()
- } label: {
- Text("Join")
- }
- }
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .background(Color.green, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
- .padding(.horizontal)
- .padding(.bottom, -12)
- }
- // This couple of modifiers is necessary due to some magic in the SDK
- .rotationEffect(.radians(Double.pi))
- .scaleEffect(x: -1, y: 1, anchor: .center)
- )
- } else {
- content
- }
- }
-}
-```
-
-Now, head over to the `CustomFactory` and add the following code to it:
-
-```swift
-func makeMessageListModifier() -> some ViewModifier {
- CallOverlay(viewModel: viewModel)
-}
-```
-
-By this simple override of the `makeMessageListModifier()` function, you can inject your `CallOverlay` function and conditionally show it once the variable in the view model is `true`.
-
-With that, you have completed the UI setup of the app and can now continue by updating the channels.
-
-## 5. Initiate call with channel update
-
-The basic approach you are taking for video calls is that every time a call is initiated, the channel will hold all necessary information about the call so that all members of the channel can join. This requires you to do a few things:
-
-1. Watch the channel for updates
-2. Handle the updates in local state to update the UI when a call is ongoing
-3. Update the channel state when a call starts / ends
-
-The approach taken is a very reactive one. So, letās go over the points and build up the code.
-
-### Watching for channel updates
-
-The first thing you will create is a little helper function that takes care of watching a channel. The concept of watching channels in the _StreamChat_ SDK simply means that you are subscribing to updates of it and can define a `delegate` that will be called anytime there are changes.
-
-For the convenience function, you create you will need the `ChannelId` to create a controller. You already added a member variable to the `CallViewModel` earlier that will hold the `channelController` so that you can also stop watching the channel (no longer receive updates for that one) if necessary.
-
-Add this function inside of `CallViewModel`:
-
-```swift
-private func watchChannel(with id: ChannelId) {
- self.channelController = chatClient.channelController(for: id)
- self.channelController?.synchronize()
- self.channelController?.delegate = self
-}
-```
-
-This will not build as the `CallViewModel` does not conform to the `ChatChannelControllerDelegate` yet, but that will be fixed in a second.
-
-First, you need to call the `watchChannel` function. The place to do that is the `setChannelId` function you set up when creating the `CallViewModel` class. Whenever a user enters a channel this will be called and a channel should be watched for updates.
-
-It is important, however, that you check whether a channel is already being watched. You can do that by checking if the `channelController` has already been initialized. If it was, then you will call `stopWatching` on it, and only in the completion handler start watching the new channel.
-
-With that being said here is the code for `setChannelId`:
-
-```swift
-func setChannelId(_ id: ChannelId) {
- if let channelController = channelController {
- channelController.stopWatching { [unowned self] error in
- if let error = error {
- print(error)
- return
- }
-
- self.watchChannel(with: id)
- }
- } else {
- watchChannel(with: id)
- }
-}
-```
-
-
-
-
-
-Remember that `setChannelId` is already called in the `CustomFactory` when the channel header is created, so thereās no more setup that you need to do.
-
-### Conforming to the ChatChannelControllerDelegate
-
-For the handling of channel updates and channel data, you will need a few constants. Create a new Swift file called `String+Constants` and fill it with a `String` extension that holds a few constants you will need:
-
-```swift
-import Foundation
-
-extension String {
- // Extra data keys
- static let callActive = "callActive"
- static let callInitiator = "callInitiator"
-}
-```
-
-The code will still not build as the `CallViewModel` is still not conforming to the `ChatChannelControllerDelegate`. You will do that right now by going to `CallViewModel.swift` and after the definition of the class, `CallViewModel` creates an extension for it.
-
-The extension will implement the `didUpdateChannel` function and inside of it, you will check if the `extraData` of the channel will contain the `callActive` key.
-
-It is important to note that channel updates can come from many sources so in order to prevent unnecessary state updates and redrawing of the UI you will also check if your local state (represented by the `callActive` property of the `CallViewModel`) is different from the newly received state. If it is, only then will you update it.
-
-Here is the code:
-
-```swift
-extension CallViewModel: ChatChannelControllerDelegate {
- func channelController(_ channelController: ChatChannelController, didUpdateChannel channel: EntityChange) {
- let isCallActive = channelController.channel?.extraData.keys.contains(.callActive) ?? false
- if self.callActive != isCallActive {
- self.callActive = isCallActive
- }
- }
-}
-```
-
-With that, the code will build and you are watching updates on the channel at all times. The UI will be in sync with the state of the channel and whenever updates are received it will automatically update. Now, let's see how the channel can be updated once a call is starting or ending.
-
-### Updating the channel state
-
-You are listening to updates of the channel state. However, right now there are no updates happening. The code to update the channel state is not very complex. But first, thereās a concept we have for this tutorial and there are many ways to approach it, so weāll quickly discuss that.
-
-In the extensions you made to `String` there is the `callInitiator` key. This is there because the code will work like this: the user who starts a call has the role of āinitiatorā. Someone joining the call will have the role of āparticipantā. The difference will be that when a participant will leave a call it will remain active. When the initiator leaves, however, then the call will be terminated which requires a channel state update.
-
-
-
-
-
-In order to accommodate that, create a new Swift file and call it `CallRole`. It will be an `enum` that tracks the state of the current user and your logic will adapt to that.
-
-Fill the file with the following code:
-
-```swift
-import Foundation
-
-enum CallRole {
- case initiator, participant, notJoined
-}
-```
-
-With that, there is everything in place to update the channel state. Youāll create a function called `updateChannelCallState` inside of `CallViewModel` for that. This will be private and will only be called when you integrate the _agora_ SDK in the next chapter. It will do the following things:
-
-- Get the channel data from `channelController`
-- Get the `extraData` from the channel
-- Update the `extraData` by either adding the necessary info to it or removing it
-- Call the `updateChannel` function on the `channelController`
-
-With that, add this code inside of the `CallViewModel`:
-
-```swift
-private func updateChannelCallState(to activeCall: Bool, with uid: UInt) {
- guard let channel = channelController?.channel else {
- print("Couldn't get channel from channelController")
- return
- }
- var updatedExtraData = channel.extraData
- if activeCall {
- updatedExtraData[.callActive] = .bool(true)
- updatedExtraData[.callInitiator] = .number(Double(uid))
- } else {
- updatedExtraData.removeValue(forKey: .callActive)
- updatedExtraData.removeValue(forKey: .callInitiator)
- }
-
- channelController?.updateChannel(name: channel.name, imageURL: channel.imageURL, team: channel.team, extraData: updatedExtraData)
-}
-```
-
-Code-wise this is all that is needed. However there is one more setting that needs to be updated and that's for good reason.
-
-### Allowing regular users to update the channel state
-
-Executing the code above now would not do anything. It will return an error that the user is not allowed to perform this task. And that makes sense, as regular channel members are not allowed to update channel data by default.
-
-The Stream Chat SDK offers a fine-grained [roles and permissions system](https://getstream.io/chat/docs/other-rest/user_permissions/) that allows you to fine tune which member is allowed to perform which actions. This is a safety measure to only give allowance to execute the tasks necessary for the respective user.
-
-It is easy to update those however and allow our users to perform the update channel action that the code above does. Head over to the [Stream Dashboard](https://dashboard.getstream.io/) and select your app.
-
-:::note
-If you follow the sample code in the repository the project that is setup already has these changes done, so you can skip to the next part if you are not using a custom project on your own.
-:::
-
-Now, head over to the **Roles & Permissions** tab and click the **Edit Button** (red arrow in the image below) for the **channel_member** role.
-
-![View of the Stream Dashboard in the Roles & Permissions tab with the edit button of the channel_member role marked with a red arrow.](../assets/agora-dashboard-roles.png)
-
-Next, for the **Current Scope** select `messaging` and in the **Available Permissions** search for `Update Channel`. Mark the option as checked and click on the blue arrow pointing towards the left. Make sure it shows up under **Grants** and hit **Save Changes** (red arrow in the image below).
-
-![View of the Stream Dashboard with the Update Channel permission selected to be moved to the Grants are.](../assets/agora-dashboard-set-permission.png)
-
-This is all the preparation you need. The updating of the call state will happen when calls are started and when the initiator of a call ends it. Both cases will be covered in the next chapter.
-
-## 6. Integration of the Agora SDK
-
-The last missing piece of the integration is the Agora SDK. Luckily, everything is prepared so you just need to fill the methods with the SDK-relevant code.
-
-### Starting and joining a call
-
-First, letās have a look at the steps to do when joining a call:
-
-1. Get an `id` that identifies the user (you will use a random one here but it would make sense to combine that with your authentication solution in production)
-2. Get the current `channelId`
-3. Request an auth token from the channel controller
-4. Join the call with the _Agora_ SDK
-
-Due to the calls functions using `async/await` you have to wrap the call to get an access token in a `Task {}`. With the asynchronous nature of that, it is then again necessary to call all UI-relevant code on the main thread. Therefore that will then be wrapped inside of a `MainActor.run {}` closure.
-
-In order to keep the code clean youāll extract the _Agora_ SDK part of the code into its own function but here is the code of the `startCall` function:
-
-```swift
-func startCall(updateChannel: Bool = true) {
- let uid: UInt = UInt.random(in: UInt.min ... 1000)
-
- guard let agoraChannelId = channelController?.cid.rawValue else {
- print("Couldn't get channel id")
- return
- }
-
- Task {
- do {
- let authTokenResult = try await channelController.createCall(id: agoraChannelId, type: "video")
-
- await MainActor.run {
- agoraJoinCall(authTokenResult: authTokenResult, agoraChannelId: agoraChannelId, updateChannel: updateChannel)
- }
- } catch {
- print(error.localizedDescription)
- return
- }
- }
-}
-```
-
-The code works exactly the way it was described. In order to use the `async/await` pattern for the `createCall` function of the `ChatChannelController` we need to extend it to allow us to use it instead of the closure based function:
-
-```swift
-extension ChatChannelController {
- func createCall(id: String, type: String) async throws -> CallWithToken {
- try await withCheckedThrowingContinuation { continuation in
- createCall(id: id, type: type) { result in
- continuation.resume(with: result)
- }
- }
- }
-}
-```
-
-You have not yet created the `agoraJoinCall` function, so thatās up next.
-
-It will again do a few things. First, it will set up the _agora_ SDK and enable video capabilities. Then, you will call the `joinChannel` function with the token you received and the rest of the necessary information.
-
-Once that is done, the local video will be set up with a combination of the `AgoraRtcVideoCanvas` (the UIKit view that the _agora_ SDK offers) and the `localCanvas` property of the `CallViewModel`.
-
-Also, the published properties of `isCallScreenShown` as well as the `ownUid` are updated. Depending on the role of the user (`initiator` or `participant`) the channel will be updated (remember that you prepared this method in the last chapter) and the local `callRole` will be set.
-
-With that explained, here is the code for it:
-
-```swift
- private func agoraJoinCall(authTokenResult: CallWithToken, agoraChannelId: String, updateChannel: Bool) {
- guard let agoraCall = authTokenResult.call.agora else {
- print("getCallToken did not return data as AgoraCall")
- return
- }
- guard let uid = agoraCall.agoraInfo?.uid else {
- print("getCallToken did not return the Agora UID")
- return
- }
- guard let appId = agoraCall.agoraInfo?.appId else {
- print("getCallToken did not return the Agora UID")
- return
- }
-
- agoraKit = AgoraRtcEngineKit.sharedEngine(withAppId: appId, delegate: self)
- agoraKit?.enableVideo()
-
- agoraKit?.joinChannel(byToken: authTokenResult.token, channelId: agoraChannelId, info: nil, uid: uid, joinSuccess: { [unowned self] (channel, uid, elapsed) in
-
- // setup local video
- let videoCanvas = AgoraRtcVideoCanvas()
- videoCanvas.uid = uid
- videoCanvas.renderMode = .hidden
- videoCanvas.view = localCanvas.containerView
- agoraKit?.setupLocalVideo(videoCanvas)
-
- // update published properties
- isCallScreenShown = true
- ownUid = uid
-
- // set call role and update channel if necessary
- if updateChannel {
- callRole = .initiator
- updateChannelCallState(to: true, with: uid)
- } else {
- callRole = .participant
- }
-
- })
-}
-```
-
-The good thing with this structure is that you can use the `startCall` method also for the implementation of `joinCall`. The only difference between the two use-case is whether the user creates a new call (via the icon in the channel header) or is joining via the overlay.
-
-In the case of the user only joining the call, there is no need to update the channel, so the `startCall` function can be called with the `updateChannel` parameter set to `false`.
-
-Here is the `joinCall` function:
-
-```swift
-func joinCall() {
- startCall(updateChannel: false)
-}
-```
-
-In case you want to save more information about the call, such as number of participants, start times, etc. you can also do this. For the sake of simplicity, it is not part of this guide but you have a lot of freedom in implementing more features here.
-
-### Leaving a call
-
-When a user is leaving a call there is some clean-up to do. The _agora_ SDK requires a few function call to disable the call locally.
-
-There is also a few published properties that require updating, namely `isCallScreenShown`, `ownUid`, and `callRole`. When the initiator of the call is leaving it youāll also terminate the entire call by updating the channel info. Again, you can re-use the previously created function `updateChannelCallState`.
-
-Here is the code for the `leaveCall` function:
-
-```swift
-func leaveCall() {
- agoraKit?.leaveChannel(nil)
- AgoraRtcEngineKit.destroy()
- isCallScreenShown = false
-
- guard let id = ownUid else { return }
-
- if callRole == .initiator {
- updateChannelCallState(to: false, with: id)
- }
-
- // cleanup
- ownUid = nil
- callRole = .notJoined
-}
-```
-
-### Muting audio and video
-
-There are two more empty function that you didnāt implement yet and thatās `toggleAudioMute` and `toggleVideoMute`. Both have a `@Published` property attached to them that you can simply toggle when they are called.
-
-Also, the agora SDK offers functions to optionally enable the audio and video. So, without more explanation hereās the code for both thatās really short and self-explanatory:
-
-```swift
-func toggleAudioMute() {
- isAudioMuted.toggle()
- agoraKit?.enableLocalAudio(!isAudioMuted)
-}
-
-func toggleVideoMute() {
- isVideoMuted.toggle()
- agoraKit?.enableLocalVideo(!isVideoMuted)
-}
-```
-
-### Listen for updates during a call
-
-The last thing to do really is to listen to updates during a call. This is necessary because after joining this will allow you to add the necessary views for each participant (via the `addVideoView` of `remoteVideoViewController`). Once another user leaves a call you will be notified and can update the video view (or the `remoteVideoViewController` from the `CallViewModel` to be more specific) as well.
-
-The _agora_ SDK gives you the option to implement the `AgoraRtcEngineDelegate` which offers a set of callbacks. You will use the `CallViewModel` and create an extension of it that will implement the delegate. Create a new Swift file called `CallViewModel+AgoraRtcEngineDelegate`.
-
-
-
-
-
-Add the following code for the file (the callbacks will be added in a second):
-
-```swift
-import AgoraRtcKit
-
-extension CallViewModel: AgoraRtcEngineDelegate {
-
-}
-```
-
-Youāll start with the `didJoinedOfUid` function that is called whenever someone enters the call. It will also be called for each participant that is part of a call initially. Youāll create a new `AgoraRtcVideoCanvas` each time that is connected to the `uid` of the call participant. The `addVideoView` function of the `remoteVideoController` was created for this exact purpose, so youāll make use of that. The `agoraKit` also requires you to call the `setupRemoteVideo` function with the newly created canvas.
-
-Hereās the entire code for the function that you need to add to the freshly created `CallViewModel` extension:
-
-```swift
-func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
- let videoCanvas = AgoraRtcVideoCanvas()
- videoCanvas.uid = uid
- videoCanvas.renderMode = .hidden
-
- self.remoteVideoViewController.addVideoView(videoCanvas, with: uid)
-
- agoraKit?.setupRemoteVideo(videoCanvas)
-}
-```
-
-The last function to implement is the `didOfflineOfUid` function. As the name suggests this will be called whenever a user leaves the channel and is offline.
-
-You will be handed the `uid` of the user which is all you need as you can use the `removeVideoView` from `remoteVideoController` so its implementation is a one-liner:
-
-```swift
-func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) {
- self.remoteVideoViewController.removeVideoView(with: uid)
-}
-```
-
-Thereās more functionality you can implement, but this is all you need to have fully functional video calls in your application. Congratulations.
-
-## Summary
-
-With that, you are finished with the integration of video calls in your chat application with the _agora_ SDK. You created a well-architected app that uses modern, reactive solutions like SwiftUI, MVVM and `async/await`.
-
-This is just a suggested solution and you can of course use a pure `UIKit` solution just as well. Also, there is more functionality to explore, such as audio calls, livestreams, and much more. For the scope of this article, we only implemented one for video calls.
-
-Also, it is worth noting that the architecture allows for quick exchange of _agora_ as a solution provider and use of other ones because the code is separated out into modular pieces. This guide is aimed to show you the flexibility of the StreamChat SDK that makes the integration easy and straightforward.
-
-In case you have any more questions about this video integration or the work with other SDKs, feel free to [reach out to the team](https://getstream.io/contact/) and weāre happy to help and support you.
-
-Thank you for following along with this article.
diff --git a/fastlane/.rubocop.yml b/fastlane/.rubocop.yml
index bf1c07c88a0..6937df5b069 100755
--- a/fastlane/.rubocop.yml
+++ b/fastlane/.rubocop.yml
@@ -22,6 +22,8 @@ Performance/RegexpMatch:
Enabled: false
Performance/StringReplacement:
Enabled: false
+Performance/CollectionLiteralInLoop:
+ Enabled: false
Style/NumericPredicate:
Enabled: false
Metrics/BlockLength:
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 296b1bca252..85bb6ee9b54 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -13,7 +13,9 @@ github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-swift'
stress_tests_cycles = 50
derived_data_path = 'derived_data'
source_packages_path = 'spm_cache'
+performance_path = "performance/#{github_repo.split('/').last}.json"
buildcache_xcargs = 'CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++'
+testlab_bucket = 'gs://test-lab-af3rt9m4yh360-mqm1zzm767nhc'
is_localhost = !is_ci
@force_check = false
@@ -247,7 +249,10 @@ lane :match_me do |options|
'io.getstream.iOS.MessengerClone',
'io.getstream.iOS.YouTubeClone',
'io.getstream.iOS.DemoAppUIKit',
- 'io.getstream.iOS.ChatDemoApp.DemoShare'
+ 'io.getstream.iOS.ChatDemoApp.DemoShare',
+ 'io.getstream.iOS.StreamChatMockServer',
+ 'io.getstream.iOS.StreamChatUITestsApp',
+ 'io.getstream.iOS.StreamChatUITestsAppUITests.xctrunner'
]
custom_match(
api_key: appstore_api_key,
@@ -345,6 +350,150 @@ lane :build_test_app_and_frameworks do
)
end
+lane :xcmetrics do |options|
+ next unless is_check_required(sources: sources_matrix[:xcmetrics], force_check: @force_check)
+
+ ['test_output/', 'performance/', "../#{derived_data_path}/Build/Products"].each { |dir| FileUtils.remove_dir(dir, force: true) }
+
+ match_me
+
+ scan(
+ project: xcode_project,
+ scheme: 'StreamChatUITestsApp',
+ testplan: 'Performance',
+ result_bundle: true,
+ derived_data_path: derived_data_path,
+ cloned_source_packages_path: source_packages_path,
+ clean: is_localhost,
+ xcargs: buildcache_xcargs,
+ sdk: 'iphoneos',
+ skip_detect_devices: true,
+ build_for_testing: true
+ )
+
+ firebase_error = ''
+ xcodebuild_output = ''
+ Dir.chdir("../#{derived_data_path}/Build/Products") do
+ begin
+ sh("zip -r MyTests.zip .")
+ sh("gcloud firebase test ios run --test MyTests.zip --timeout 7m --results-dir test_output --device 'model=iphone14pro,version=16.6,orientation=portrait'")
+ rescue StandardError => e
+ UI.error("Test failed on Firebase:\n#{e}")
+ firebase_error = e
+ end
+
+ sh("gsutil cp -r #{testlab_bucket}/test_output/iphone14pro-16.6-en-portrait/xcodebuild_output.log xcodebuild_output.log")
+ xcodebuild_output = File.read('xcodebuild_output.log')
+ end
+
+ sh("git clone git@github.com:GetStream/stream-swift-performance-benchmarks.git #{File.dirname(performance_path)}")
+ branch_performance = xcmetrics_log_parser(log: xcodebuild_output)
+ performance_benchmarks = JSON.parse(File.read(performance_path))
+ expected_performance = performance_benchmarks['benchmark']
+
+ markdown_table = "## StreamChat XCMetrics\n| `target` | `metric` | `benchmark` | `branch` | `performance` | `status` |\n| - | - | - | - | - | - |\n"
+ ['testMessageListScrollTime', 'testChannelListScrollTime'].each do |test_name|
+ index = 0
+ ['hitches_total_duration', 'duration', 'hitch_time_ratio', 'frame_rate', 'number_of_hitches'].each do |metric|
+ benchmark_value = expected_performance[test_name][metric]['value']
+ branch_value = branch_performance[test_name][metric]['value']
+ value_extension = branch_performance[test_name][metric]['ext']
+
+ max_stddev = 0.1 # Default Xcode Max STDDEV is 10%
+ status_emoji =
+ if branch_value > benchmark_value && branch_value < benchmark_value + (benchmark_value * max_stddev)
+ 'š”' # Warning if a branch is 10% less performant than the benchmark
+ elsif branch_value > benchmark_value
+ 'š“' # Failure if a branch is more than 10% less performant than the benchmark
+ else
+ 'š¢' # Success if a branch is more performant or equals to the benchmark
+ end
+
+ benchmark_value_avoids_zero_division = benchmark_value == 0 ? 1 : benchmark_value
+ diff = ((benchmark_value - branch_value) * 100.0 / benchmark_value_avoids_zero_division).round(2)
+ diff_emoji = diff > 0 ? 'š¼' : 'š½'
+
+ title = metric.to_s.gsub('_', ' ').capitalize
+ target = index.zero? ? test_name.match(/(?<=test)(.*?)(?=ScrollTime)/).to_s : ''
+ index += 1
+
+ markdown_table << "| #{target} | #{title} | #{benchmark_value} #{value_extension} | #{branch_value} #{value_extension} | #{diff}% #{diff_emoji} | #{status_emoji} |\n"
+ FastlaneCore::PrintTable.print_values(
+ title: "ā³ #{title} ā³",
+ config: {
+ benchmark: "#{benchmark_value} #{value_extension}",
+ branch: "#{branch_value} #{value_extension}",
+ diff: "#{diff}% #{diff_emoji}",
+ status: status_emoji
+ }
+ )
+ end
+ end
+
+ UI.user_error!("See Firebase error above āļø") unless firebase_error.to_s.empty?
+
+ if is_ci
+ pr_comment_required = ENV.key?('GITHUB_PR_NUM')
+ performance_benchmarks[current_branch] = branch_performance
+ UI.message("Performance benchmarks: #{performance_benchmarks}")
+ File.write(performance_path, JSON.pretty_generate(performance_benchmarks))
+
+ Dir.chdir(File.dirname(performance_path)) do
+ if sh('git status -s', log: false).to_s.empty?
+ pr_comment_required = false
+ UI.important('No changes in performance benchmarks. Skipping commit and comment.')
+ else
+ sh('git add -A')
+ sh("git commit -m 'Update #{github_repo.split('/').last}.json: #{current_branch}'")
+ sh('git push')
+ end
+ end
+
+ sh("gh pr comment #{ENV.fetch('GITHUB_PR_NUM')} -b '#{markdown_table}'") if pr_comment_required
+ end
+
+ UI.user_error!('Performance benchmark failed.') if markdown_table.include?('š“')
+end
+
+private_lane :xcmetrics_log_parser do |options|
+ log = options[:log]
+ method = 'Scroll_DraggingAndDeceleration'
+ metrics = {}
+
+ ['testMessageListScrollTime', 'testChannelListScrollTime'].each do |test_name|
+ hitches_total_duration = log.match(/#{test_name}\]' measured \[Hitches Total Duration \(#{method}\), ms\] average: (\d+\.\d+)/)
+ duration = log.match(/#{test_name}\]' measured \[Duration \(#{method}\), s\] average: (\d+\.\d+)/)
+ hitch_time_ratio = log.match(/#{test_name}\]' measured \[Hitch Time Ratio \(#{method}\), ms per s\] average: (\d+\.\d+)/)
+ frame_rate = log.match(/#{test_name}\]' measured \[Frame Rate \(#{method}\), fps\] average: (\d+\.\d+)/)
+ number_of_hitches = log.match(/#{test_name}\]' measured \[Number of Hitches \(#{method}\), hitches\] average: (\d+\.\d+)/)
+
+ metrics[test_name] = {
+ 'hitches_total_duration' => {
+ 'value' => hitches_total_duration ? hitches_total_duration[1].to_f.round(2) : '?',
+ 'ext' => 'ms'
+ },
+ 'duration' => {
+ 'value' => duration ? duration[1].to_f.round(2) : '?',
+ 'ext' => 's'
+ },
+ 'hitch_time_ratio' => {
+ 'value' => hitch_time_ratio ? hitch_time_ratio[1].to_f.round(2) : '?',
+ 'ext' => 'ms per s'
+ },
+ 'frame_rate' => {
+ 'value' => frame_rate ? frame_rate[1].to_f.round(2) : '?',
+ 'ext' => 'fps'
+ },
+ 'number_of_hitches' => {
+ 'value' => number_of_hitches ? number_of_hitches[1].to_f.round(2) : '?',
+ 'ext' => ''
+ }
+ }
+ end
+
+ metrics
+end
+
desc 'Runs e2e ui tests using mock server in Debug config'
lane :test_e2e_mock do |options|
next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check)
@@ -598,7 +747,8 @@ lane :sources_matrix do
ui: ['Sources', 'Tests/StreamChatUITests', 'Tests/Shared', xcode_project],
sample_apps: ['Sources', 'Examples', 'DemoApp', xcode_project],
integration: ['Sources', 'Integration', xcode_project],
- ruby: ['fastlane', 'Gemfile', 'Gemfile.lock']
+ ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'],
+ xcmetrics: ['Sources']
}
end
@@ -615,7 +765,7 @@ end
private_lane :create_pr do |options|
options[:base_branch] ||= 'develop'
sh("git checkout -b #{options[:head_branch]}")
- sh('git restore Brewfile.lock.json')
+ sh('git restore Brewfile.lock.json || true')
sh('git add -A')
sh("git commit -m '#{options[:title]}'")
push_to_git_remote(tags: false)
@@ -631,7 +781,20 @@ private_lane :create_pr do |options|
end
private_lane :current_branch do
- ENV['BRANCH_NAME'].to_s.empty? ? git_branch : ENV.fetch('BRANCH_NAME')
+ github_pr_branch_name = ENV['BRANCH_NAME'].to_s
+ github_ref_branch_name = ENV['GITHUB_REF'].to_s.sub('refs/heads/', '')
+ fastlane_branch_name = git_branch
+
+ branch_name = if !github_pr_branch_name.empty?
+ github_pr_branch_name
+ elsif !fastlane_branch_name.empty?
+ fastlane_branch_name
+ elsif !github_ref_branch_name.empty?
+ github_ref_branch_name
+ end
+
+ UI.important("Current branch: #{branch_name} šļø")
+ branch_name
end
private_lane :git_status do |options|
diff --git a/fastlane/Sonarfile b/fastlane/Sonarfile
index f54b8003231..1e2fd6facf0 100755
--- a/fastlane/Sonarfile
+++ b/fastlane/Sonarfile
@@ -37,7 +37,7 @@ private_lane :sonar_options do |options|
if ENV['GITHUB_EVENT_NAME'] == 'pull_request'
default_options.merge(pull_request_branch: ENV.fetch('GITHUB_HEAD_REF', nil),
pull_request_base: ENV.fetch('GITHUB_BASE_REF', nil),
- pull_request_key: ENV.fetch('PR_NUMBER', nil))
+ pull_request_key: ENV.fetch('GITHUB_PR_NUM', nil))
else
default_options.merge(branch_name: current_branch, project_version: options[:version_number])
end