Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

Messaging and DMs

Our previous tutorial showed you Searching and Finding Users with the Twitter Clone app. Since you are here, we believe you enjoyed all the previous tutorials. In this tutorial, you will learn about Messaging and DMs by integrating the Stream Chat SwiftUI SDK with this project.

Our previous tutorial showed you Searching and Finding Users with the TwitterClone app. Since you are here, we believe you enjoyed all the previous tutorials. In this tutorial, you will learn about Messaging and DMs by integrating the Stream Chat SwiftUI SDK with this project.

Find the project demo and download the source code from GitHub.

Why Stream Chat for Messages?

The iOS/SwiftUI Chat SDK on Stream’s Global Edge Network allows developers to build faster, more reliable, real-time, and fully functional chat messaging experiences similar to WhatsApp, Facebook Messenger, and Telegram. You can create SwiftUI and UIKit-based conversational experiences in no time using high-level UI components.

Stream' SwiftUI Chat SDK helps developers to build chat messaging apps with as minimal code as possible. The SDK provides several customization options as well as offline support, which makes it possible for users to browse channels and send messages when they are offline. You can easily create a chat experience with a fully custom-made UI. Get started with the UIKit-based or SwiftUI SDK by reading the SwiftUI Chat App Tutorial. To build a sample chat app, you can fetch the SwiftUI SDK from GitHub.

The Integration Process

An essential part of our implementation was allowing users to interact with each other directly through direct messages. To implement the massaging feature, we relied on Stream Chat. We were able to create the initial chat experience in under two hours. After the initial implementation, we only needed to refine it and ensure everything worked as intended.

Install The SDK in TwitterClone

The guide Build a SwiftUI Chat Messaging App demonstrate how to integrate the SwiftUI SDK with a blank SwiftUI app. You can integrate the Stream Chat SwiftUI SDK with the TwitterClone app using dependency managers such as CocoaPods, Carthage, or Swift Package Manager. Let's keep it simple by following the steps below to fetch the SDK from GitHub using SPM. 

Add Stream Chat’s dependency as a Swift Package to our Tuist dependencies.swift file.

swift
            var swiftPackageManagerDependencies = SwiftPackageManagerDependencies(
         [.remote(url: "https://github.com/GetStream/stream-chat-swiftui.git", requirement: .upToNextMajor(from: "4.0.0")),
     .remote(url: "https://github.com/GetStream/stream-chat-swift.git", requirement: .upToNextMajor(from: "4.0.0")),
    ],
    productTypes: [
        "StreamChatSwiftUI" : .framework,
        "StreamChat": .framework,]
)
        

We then add the dependency on the Tuist project, in project.swift, to a target.

swift
            let messagesTarget =
    Project.makeFrameworkTargets(name: messagesName,
                                 platform: .iOS,
                                 dependencies:
                                    [
                                        .external(name: "StreamChatSwiftUI"),
                                        .external(name: "StreamChat"),
                                        .target(name: authName),
                                        .target(name: feedsName),
                                        .target(name: chatName),
                                        .target(name: uiName)
                                    ])
let chatTarget =
    Project.makeFrameworkTargets(name: chatName,
                                 platform: .iOS,
                                 dependencies:
                                    [
                                        .external(name: "StreamChatSwiftUI"),
                                        .external(name: "StreamChat"),
                                        .target(name: authName),
                                        .target(name: feedsName),
                                        .target(name: uiName)
                                    ])let spacesTarget =
    Project.makeFrameworkTargets(name: spacesName,
                                 platform: .iOS,
                                 dependencies: [
                                    .external(name: "StreamChatSwiftUI"),
                                    .external(name: "StreamChat"),
                                    .target(name: uiName),
                                    .target(name: authName),
                                    .target(name: chatName),
                                    .external(name: "HMSSDK")
                                 ])
        

Notice how the dependencies are added to multiple targets. This is possible and causes no issues due to the StreamChat dependencies being declared dynamic frameworks

We now fetch the dependency by running

bash
            tuist fetch
        

And make sure to generate the Xcode project again by running:

bash
            tuist generate
        

Implementation

There are a few steps you should follow and set up the SDK to work with our TwitterClone. From the Xcode Project Navigator, open ChatModel.swift under the folders Chat -> Sources.

swift
            // ChatModel.swift

import Foundation
import StreamChat
import StreamChatSwiftUI
import Auth
import Feeds
import NetworkKit

@MainActor
public class ChatModel: ObservableObject {
    // This is the `StreamChat` reference we need to add
    internal var streamChat: StreamChat

    public init() {
        streamChat = StreamChat(chatClient: self.chatClient)
    }

    // This is the `chatClient`, with config we need to add
    internal var chatClient: ChatClient = {
        //For the tutorial we use a hard coded api key and application group identifier
        var config = ChatClientConfig(apiKey: .init(TwitterCloneNetworkKit.apiKey))
        config.applicationGroupIdentifier = "group.io.getstream.twitterclone.TwitterClone"

        // The resulting config is passed into a new `ChatClient` instance.
        let client = ChatClient(config: config)
        return client
    }()

    public func logout() {
        chatClient.logout(completion: {})
    }

    // The `connectUser` function we need to add.
    public func connectUser(authUser: AuthUser, feedUser: FeedUser) throws {

        // This is a hardcoded token valid on Stream's tutorial environment.
        let token = try Token(rawValue: authUser.chatToken)
        let feedUserProfilePictureUrl = feedUser.profilePicture.flatMap { URL(string: $0) }

        // Call `connectUser` on our SDK to get started.
        chatClient.connectUser(
            userInfo: .init(id: authUser.userId,
                            name: feedUser.fullname,
                            imageURL: feedUserProfilePictureUrl),
                token: token
        ) { error in
            // TODO improve error handling
            if let error {
                // Some very basic error handling only logging the error.
                log.error("connecting the user failed \(error)")
                return
            }
        }
    }
}
        

Here is how it works. In ChatModel.swift, we define the class ChatModel and create a streamChat instance. To access the SDK, we should add a chatClient and initialize it with an API key. A hard-coded API key is used in this tutorial, but you can obtain your API key by creating an account on Stream Dashboard.

We use the connectUser function to obtain the credentials from the user. Next, we send the user information to our backend infrastructure for authentication by calling the connectUser function on the SDK. 

Go Further

To explore direct messaging further in our codebase, open the folders DirectMessages -> Sources and look at all the Swift files to learn more about the implementation.

DirectMessagesView.swift

swift
            import SwiftUI
import StreamChatSwiftUI
import Chat

public struct DirectMessagesView: View {

    public init() {}

    public var body: some View {
        ChatChannelListView(viewFactory: DemoAppFactory.shared)
    }
}

struct DirectMessagesView_Previews: PreviewProvider {
    static var previews: some View {
        DirectMessagesView()
    }
}
        

NewChatView.swift

swift
            //
// Copyright © 2023 Stream.io Inc. All rights reserved.
//
import StreamChat
import StreamChatSwiftUI
import SwiftUI

struct NewChatView: View, KeyboardReadable {

    @Injected(\.fonts) var fonts
    @Injected(\.colors) var colors

    @StateObject var viewModel = NewChatViewModel()

    @Binding var isNewChatShown: Bool

    @State private var keyboardShown = false

    let columns = [GridItem(.adaptive(minimum: 120), spacing: 2)]

    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Text("TO:")
                    .font(fonts.footnote)
                    .foregroundColor(Color(colors.textLowEmphasis))

                VStack {
                    if !viewModel.selectedUsers.isEmpty {
                        LazyVGrid(columns: columns, alignment: .leading) {
                            ForEach(viewModel.selectedUsers) { user in
                                SelectedUserView(user: user)
                                    .onTapGesture(
                                        perform: {
                                            withAnimation {
                                                viewModel.userTapped(user)
                                            }
                                        }
                                    )
                            }
                        }
                    }

                    SearchUsersView(viewModel: viewModel)
                }
            }
            .padding()

            if viewModel.state != .channel {
                CreateGroupButton(isNewChatShown: $isNewChatShown)
                UsersHeaderView()
            }

            if viewModel.state == .loading {
                VerticallyCenteredView {
                    ProgressView()
                }
            } else if viewModel.state == .loaded {
                List(viewModel.chatUsers) { user in
                    Button {
                        withAnimation {
                            viewModel.userTapped(user)
                        }
                    } label: {
                        ChatUserView(
                            user: user,
                            onlineText: viewModel.onlineInfo(for: user),
                            isSelected: viewModel.isSelected(user: user)
                        )
                        .onAppear {
                            viewModel.onChatUserAppear(user)
                        }
                    }
                }
                .listStyle(.plain)
            } else if viewModel.state == .noUsers {
                VerticallyCenteredView {
                    Text("No user matches these keywords")
                        .font(.title2)
                        .foregroundColor(Color(colors.textLowEmphasis))
                }
            } else if viewModel.state == .error {
                VerticallyCenteredView {
                    Text("Error loading the users")
                        .font(.title2)
                        .foregroundColor(Color(colors.textLowEmphasis))
                }
            } else if viewModel.state == .channel, let controller = viewModel.channelController {
                Divider()
                ChatChannelView(
                    viewFactory: DemoAppFactory.shared,
                    channelController: controller
                )
            } else {
                Spacer()
            }
        }
        .navigationTitle("New Chat")
        .onReceive(keyboardWillChangePublisher) { visible in
            keyboardShown = visible
        }
        .modifier(HideKeyboardOnTapGesture(shouldAdd: keyboardShown))
    }
}

struct SelectedUserView: View {

    @Injected(\.colors) var colors

    var user: ChatUser

    var body: some View {
        HStack {
            MessageAvatarView(
                avatarURL: user.imageURL,
                size: CGSize(width: 20, height: 20)
            )

            Text(user.name ?? user.id)
                .lineLimit(1)
                .padding(.vertical, 2)
                .padding(.trailing)
        }
        .background(Color(colors.background1))
        .cornerRadius(16)
    }
}

struct SearchUsersView: View {

    @StateObject var viewModel: NewChatViewModel

    var body: some View {
        HStack {
            TextField("Type a name", text: $viewModel.searchText)
            Button {
                if viewModel.state == .channel {
                    withAnimation {
                        viewModel.state = .loaded
                    }
                }
            } label: {
                Image(systemName: "person.badge.plus")
            }
        }
    }
}

struct VerticallyCenteredView<Content: View>: View {

    var content: () -> Content

    var body: some View {
        VStack {
            Spacer()
            content()
            Spacer()
        }
    }
}

struct CreateGroupButton: View {

    @Injected(\.colors) var colors
    @Injected(\.fonts) var fonts

    @Binding var isNewChatShown: Bool

    var body: some View {
        NavigationLink {
            CreateGroupView(isNewChatShown: $isNewChatShown)
        } label: {
            HStack {
                Image(systemName: "person.3")
                    .renderingMode(.template)
                    .foregroundColor(colors.tintColor)

                Text("Create a group")
                    .font(fonts.bodyBold)
                    .foregroundColor(Color(colors.text))

                Spacer()
            }
            .padding()
        }
        .isDetailLink(false)
    }
}

struct ChatUserView: View {

    @Injected(\.colors) var colors
    @Injected(\.fonts) var fonts

    var user: ChatUser
    var onlineText: String
    var isSelected: Bool

    var body: some View {
        HStack {
            LazyView(
                MessageAvatarView(avatarURL: user.imageURL)
            )

            VStack(alignment: .leading, spacing: 4) {
                Text(user.name ?? user.id)
                    .lineLimit(1)
                    .font(fonts.bodyBold)
                Text(onlineText)
                    .font(fonts.footnote)
                    .foregroundColor(Color(colors.textLowEmphasis))
            }
            Spacer()

            if isSelected {
                Image(systemName: "checkmark")
                    .renderingMode(.template)
                    .foregroundColor(colors.tintColor)
            }
        }
    }
}

struct UsersHeaderView: View {

    @Injected(\.colors) var colors
    @Injected(\.fonts) var fonts

    var title = "On the platform"

    var body: some View {
        HStack {
            Text(title)
                .padding(.horizontal)
                .padding(.vertical, 2)
                .font(fonts.body)
                .foregroundColor(Color(colors.textLowEmphasis))

            Spacer()
        }
        .background(Color(colors.background1))
    }
}
        

Conclusion

Congratulations on completing the firth part of the TwitterClone tutorial series. Check out our documentation to learn more about our SwiftUI and UIKit-based chat messaging components. The next tutorial is about integrating the Twitter Spaces clone into the project. Head to the next tutorial to learn Conversations with Spaces.