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.
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 ChatClient
, StreamChat
, StreamVideo
, 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 ChatCallControls
view:
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 theChatChannelController
, 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 providedchannelId
. - The
markAsRead()
function marks all messages in the channel as read and resets theunreadCount
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 ofChatHelper
, passing in theChatClient
configured with your API key. - Inside the
if chatHelper.chatShown
block, we conditionally display theChatChannelView
from the Stream Chat SDK if thechannelController
is available. - The
ChatChannelView
is configured with theChatViewFactory.shared
and thechannelController
from thechatHelper
. - When the
ChatChannelView
appears, themarkAsRead()
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, theconnect(to:)
function is called to connect to the desired chat channel using the specifiedCHANNEL_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:
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:
- Stream Video SDK tutorials: https://getstream.io/video/sdk/ios/
- Stream Video SDK documentation: https://getstream.io/video/docs/ios/
- Complete sample code for the watch party app: https://github.com/rudrankriyam/WWDC-Watch-Party/tree/main
I look forward to joining one of the amazing watch parties in the app you will build! Happy coding!