In the previous posts of this series, we explored how to create a WWDC watch party app using Stream's Video SDK. We started by building a basic watch party app that allowed users to watch WWDC sessions together in real-time. Then, we upgraded the app with custom features like reactions, push notifications, and a personalized user interface.

Now, chat is an important component of any video calling app, to help users with discussions. It becomes more important for an idea of a watch party app for developers to share ideas, and interact with each other while watching the sessions, developing a a sense of community!

Stream's Chat SDK integrates well with their Video SDK, making adding chat capabilities to our watch party app easy. Like the Video SDK, it offers pre-built UI components, with real-time messaging, message reactions, typing indicators, and much more.

In this post, we will integrate Stream Chat SDK into the existing WWDC watch party app. We will cover step-by-step implementation, from setting up the SDK to synchronising chat with video call participants.

This post is sponsored by Stream, but all views expressed are my own.

GitHub - rudrankriyam/WWDC-Watch-Party at rudrank.com
Sample project for Stream Video SDK. Contribute to rudrankriyam/WWDC-Watch-Party development by creating an account on GitHub.

So, let's get started and chat a lot about the new Apple Intelligence and visionOS sessions using Stream's Chat SDK in our WWDC watch party app!

Setting up the Stream Chat SDK

First, you need to create a Stream Chat application and obtain the necessary API credentials.

Head over to the Stream Chat dashboard and create a new app. Give it a name that reflects the watch party app, like "WWDC Watch Party."

Once created, you will find the API key and secret for your application. Keep these handy, as you will need them to initialise the ChatClient in the project.

Now, open your Xcode project and navigate to the project settings. Click on the plus button in the "Frameworks, Libraries, and Embedded Content" section and select "Add Package Dependency." Enter the URL of the Stream Chat SwiftUI SDK repository: https://github.com/GetStream/stream-chat-swiftui.git

Choose the latest version and click "Next" to add the package to your project.

Integrating Stream Chat with Stream Video

With the Stream Chat SDK installed, it is time to initialise the ChatClient. To manage both the Video and Chat clients, we create a class called StreamWrapper. This wrapper will handle the initialisation and connection of both clients, making it easier for you.

import StreamChat
import StreamChatSwiftUI
import StreamVideo
import StreamVideoSwiftUI

class StreamChatVideo {
  let chatClient: ChatClient
  let streamChatUI: StreamChat
  let streamVideo: StreamVideo
  let streamVideoUI: StreamVideoUI

  init(
    apiKey: String,
    user: User,
    token: UserToken
  ) {
    // Initialize StreamVideo
    streamVideo = StreamVideo(
      apiKey: apiKey,
      user: user,
      token: token
    )
    streamVideoUI = StreamVideoUI(streamVideo: streamVideo)

    // Initialize StreamChat
    chatClient = ChatClient(config: .init(apiKeyString: apiKey))
    streamChatUI = StreamChat(chatClient: chatClient)

    // Connect the chat user
    let userInfo = UserInfo(
      id: user.id,
      name: user.name,
      imageURL: user.imageURL,
      extraData: [:]
    )

    chatClient.connectUser(userInfo: userInfo, token: Token(stringLiteral: token.rawValue)) { error in
      if let error = error {
        print("Error connecting chat user: \(error)")
      } else {
        print("Chat user connected successfully")
      }
    }
  }

  func logout() async {
    await streamVideo.disconnect()
    await  chatClient.logout()
  }
}

To use this StreamChatVideo class in the app, we would initialize it in the SessionDetailView like this:

struct SessionDetailView: View {
  let session: Session
  let streamChatVideo: StreamChatVideo

  @ObservedObject var state: CallState
  @State private var call: Call
  @State private var callCreated: Bool = false
  @State private var player: AVPlayer?
  @State private var syncTimer: Timer?

  init(session: Session) {
    self.session = session

    self.streamChatVideo = StreamChatVideo(
      apiKey: "your_api_key",
      user: .guest("guest_name"),
      token: .init(stringLiteral: "your_token")
    )

    let call = streamChatVideo.streamVideo.call(callType: "default", callId: "session_\(session.id)")

    self.call = call
    self.state = call.state
  }

    // ... rest of the view implementation
}

The StreamChatVideo takes care of initializing the ChatClientStreamChatStreamVideo, and StreamVideoUI instances using the provided information. This means we do not have to worry about managing the connections separately for each client. The StreamChatVideo takes care of it all!

Integrating Chat into the Session Detail View

Now that we have our StreamChatVideo set up, let us modify our SessionDetailView to include a chat interface alongside the video player and participants list. We will create a new view called ChatView that will handle the chat functionality:

import SwiftUI
import StreamChat
import StreamChatSwiftUI

struct ChatView: View {
    @StateObject var viewModel: ChatViewModel
    
    var body: some View {
        ChatChannelView(viewFactory: ChatViewFactory.shared)
    }
}

class ChatViewModel: ObservableObject {
    let chatClient: ChatClient
    
    init(chatClient: ChatClient) {
        self.chatClient = chatClient
    }
    
    func createChannelIfNeeded(for sessionId: String) {
        let channelId = ChannelId(type: .messaging, id: "session_\(sessionId)")
        let channelController = chatClient.channelController(for: channelId)
        
        channelController.createChannel { result in
            switch result {
            case .success(let channel):
                print("Channel created or already exists: \(channel.cid)")
            case .failure(let error):
                print("Error creating channel: \(error)")
            }
        }
    }
}

In this code snippet, the makeCallControlsView function returns an instance of ChatCallControls, which is a custom SwiftUI view that we will create to display the call controls with the chat button.

Now, let's create the ChatCallControls view. This view will contain the existing call controls, such as the mute button and end call button, along with the new chat button. Here's an example of how you can structure the ChatCallControlsview:

struct ChatCallControls: View {
    @ObservedObject var viewModel: CallViewModel
    @State private var chatShown = false
    
    var body: some View {
        VStack {
            // Existing call controls
            HStack {
                // Mute button
                Button(action: {
                    viewModel.toggleMute()
                }) {
                    Image(systemName: viewModel.isMuted ? "mic.slash" : "mic")
                        .font(.system(size: 24))
                        .foregroundColor(.white)
                }
                
                Spacer()
                
                // End call button
                Button(action: {
                    viewModel.endCall()
                }) {
                    Image(systemName: "phone.down")
                        .font(.system(size: 24))
                        .foregroundColor(.white)
                }
                
                Spacer()
                
                // Chat button
                Button(action: {
                    chatShown.toggle()
                }) {
                    Image(systemName: "message")
                        .font(.system(size: 24))
                        .foregroundColor(.white)
                }
            }
            .padding()
            .background(Color.black.opacity(0.6))
            .cornerRadius(30)
            
            if chatShown {
                // Chat view goes here
                // We will add this in the next section
            }
        }
    }
}

In the ChatCallControls view, you have the existing call controls (mute button and end call button) arranged in an HStack. The new chat button is added to the right of the existing controls. When the chat button is tapped, it toggles the chatShown state variable, which will control the visibility of the chat view.

There is a placeholder for the chat view inside the if chatShown block. This is where we will add the actual chat view in the next section.

Displaying the Chat Window

Now that we have the chat button in place, let us display the chat window when the button is tapped. To accomplish this, we will use a handy class called ChatHelper to manage the chat-related logic and toggle the visibility of the chat window.

First, create a new class called ChatHelper that will handle the chat-related operations. This class will be responsible for initializing the chat client, connecting to a channel, and managing the chat state. Here's an example of how the ChatHelper class might look like:

class ChatHelper: ObservableObject {
    @Published var channelController: ChatChannelController?
    @Published var chatShown = false
    @Published var unreadCount = 0
    
    private let chatClient: ChatClient
    
    init(chatClient: ChatClient) {
        self.chatClient = chatClient
    }
    
    func connect(to channelId: String) {
        let channelController = chatClient.channelController(for: channelId)
        channelController.synchronize { [weak self] error in
            guard let self = self else { return }
            if let error = error {
                print("Failed to connect to channel: \(error)")
                return
            }
            self.channelController = channelController
        }
    }
    
    func markAsRead() {
        channelController?.markRead()
        unreadCount = 0
    }
}

In the ChatHelper class:

  • The channelController property holds the reference to the ChatChannelController, which is responsible for managing a specific chat channel.
  • The chatShown property determines whether the chat window is currently visible.
  • The unreadCount property keeps track of the number of unread messages in the chat.
  • The connect(to:) function is used to connect to a specific chat channel using the provided channelId.
  • The markAsRead() function marks all messages in the channel as read and resets the unreadCount to zero.

Now, let's go back to the ChatCallControls view and use the ChatHelper to toggle the chat window when the chat button is tapped. Update the ChatCallControls view as follows:

struct ChatCallControls: View {
    @ObservedObject var viewModel: CallViewModel
    @StateObject private var chatHelper = ChatHelper(chatClient: ChatClient(config: .init(apiKey: "YOUR_API_KEY")))
    
    var body: some View {
        VStack {
            // Existing call controls
            // ...
            
            if chatHelper.chatShown {
                if let channelController = chatHelper.channelController {
                    ChatChannelView(
                        viewFactory: ChatViewFactory.shared,
                        channelController: channelController
                    )
                    .frame(height: 300)
                    .onAppear {
                        chatHelper.markAsRead()
                    }
                } else {
                    Text("Loading chat...")
                }
            }
        }
        .onAppear {
            chatHelper.connect(to: "CHANNEL_ID")
        }
    }
}

In the updated ChatCallControls view:

  • The chatHelper property is initialized with an instance of ChatHelper, passing in the ChatClient configured with your API key.
  • Inside the if chatHelper.chatShown block, we conditionally display the ChatChannelView from the Stream Chat SDK if the channelController is available.
  • The ChatChannelView is configured with the ChatViewFactory.shared and the channelController from the chatHelper.
  • When the ChatChannelView appears, the markAsRead() function is called to mark all messages as read.
  • If the channelController is not yet available, a "Loading chat..." text is displayed.
  • In the onAppear closure, the connect(to:) function is called to connect to the desired chat channel using the specified CHANNEL_ID.

Synchronizing Chat with Video

To create a truly immersive watch party experience, it's crucial to synchronize the chat with the video call participants. By keeping the chat in sync with the active participants, you ensure that everyone can engage in real-time discussions and stay connected throughout the event.

Let's update the ChatHelper class to handle the synchronization of chat participants with the video call participants. Modify the ChatHelper class as follows:

class ChatHelper: ObservableObject {
    // ...
    
    func update(memberIds: Set<String>) {
        guard let channelController = channelController else { return }
        
        // Get the current members of the chat channel
        let currentMemberIds = Set(channelController.members.map { $0.id })
        
        // Add new members who are in the video call but not in the chat
        let newMemberIds = memberIds.subtracting(currentMemberIds)
        newMemberIds.forEach { memberId in
            channelController.addMembers(userIds: [memberId]) { error in
                if let error = error {
                    print("Failed to add member to chat: \(error)")
                }
            }
        }
        
        // Remove members who are in the chat but not in the video call
        let removedMemberIds = currentMemberIds.subtracting(memberIds)
        removedMemberIds.forEach { memberId in
            channelController.removeMembers(userIds: [memberId]) { error in
                if let error = error {
                    print("Failed to remove member from chat: \(error)")
                }
            }
        }
    }
}

In the update(memberIds:) function, we compare the current members of the chat channel with the active participants in the video call. The function adds new members who are in the video call but not in the chat, and removes members who are in the chat but not in the video call. This ensures that the chat participants are always in sync with the video call participants.

Then we update the ChatCallControls view to listen for changes in the video call participants and update the chat accordingly. Modify the ChatCallControls view as follows:

struct ChatCallControls: View {
    @ObservedObject var viewModel: CallViewModel
    @StateObject private var chatHelper = ChatHelper(chatClient: ChatClient(config: .init(apiKey: "YOUR_API_KEY")))
    
    var body: some View {
        VStack {
            // ...
        }
        .onAppear {
            chatHelper.connect(to: "CHANNEL_ID")
        }
        .onReceive(viewModel.$callParticipants) { participants in
            let memberIds = Set(participants.map { $0.id })
            chatHelper.update(memberIds: memberIds)
        }
    }
}

In the updated ChatCallControls view, we use the onReceive modifier to listen for changes in the callParticipants property of the CallViewModel. Whenever the call participants change, we extract their member IDs and pass them to the update(memberIds:) function of the ChatHelper. This triggers the synchronization process, ensuring that the chat participants are updated accordingly.

When a participant joins or leaves the video call, the chat is automatically updated to include or remove the corresponding member.

Customizing the Chat Experience

To personalize the watch party app's chat experience, we can customize the chat UI to match the WWDC 2024 theme. The Stream Chat SDK provides a wide range of customization options, allowing us to tailor the chat interface.

We can style the chat UI using the Stream Chat SDK to modify colors, fonts, and layouts. Here is an example of how you can customize the appearance of the chat UI:

extension ChatViewFactory {
    func makeMessageTextView(for message: ChatMessage, isFirst: Bool, availableWidth: CGFloat) -> some View {
        MessageTextView(message: message, isFirst: isFirst, availableWidth: availableWidth)
            .foregroundColor(.white)
            .font(.custom("YourCustomFont", size: 16))
            .padding(8)
            .background(Color.blue)
            .cornerRadius(16)
    }
    
    func makeMessageContainerView(for message: ChatMessage, isFirst: Bool, availableWidth: CGFloat) -> some View {
        MessageContainerView(message: message, isFirst: isFirst, availableWidth: availableWidth)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
    }
    
    // Customize other UI components as needed
}

In the above example, we extend the ChatViewFactory to customize the appearance of the message text view and message container view. By modifying the makeMessageTextView and makeMessageContainerView functions, we can apply custom colors, fonts, and layouts to the chat messages. We can also customize other UI components in a similar manner to achieve a consistent design throughout the chat interface.

Conclusion

Congratulations on making it to the end of this post and the series! Combining video calling and chat created an interactive user experience for your watch party app. Developers can watch the sessions together and also participate in real-time discussions, building a sense of community!

Find the final project here:

GitHub - rudrankriyam/WWDC-Watch-Party at rudrank.com
Sample project for Stream Video SDK. Contribute to rudrankriyam/WWDC-Watch-Party development by creating an account on GitHub.

I encourage you to take the concepts and code examples covered in this article and expand upon them to build your unique watch party experience using Stream's Video SDK. Here are some additional resources:

I look forward to joining one of the amazing watch parties in the app you will build! Happy coding!

String Catalog

String Catalog - App Localization on Autopilot

Push to GitHub, and we'll automatically localize your app for 40+ languages, saving you hours of manual work.