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 ->

Conversations with Spaces

Welcome to the sixth Twitter Clone tutorial. If you followed the fifth tutorial, we hope you now know how easily you can integrate the Stream Chat.

Spaces header image

When adding live video to your applications on Stream, we recommend checking out our newly released Video API!

Using Stream Video, developers can build live video calling and conferencing, voice calling, audio rooms, and livestreaming from a single unified API, complete with our fully customizable UI Kits across all major frontend platforms.

To learn more, check out our Video homepage or dive directly into the code using one of our SDKs.

Welcome to the sixth TwitterClone tutorial. If you followed the fifth tutorial, we hope you now know how easily you can integrate the Stream Chat iOS/SwiftUI SDK into your app to build immersive chat messaging experiences. In this tutorial, you will learn about conversations with spaces. In particular, you will discover how to implement audio rooms so that users can hang out with one another, meet new people, and talk about anything one could imagine.

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

Introduction to Drop-in Audio Chat

Have you ever joined Twitter Spaces or Club House's instant audio conversations? If yes, then you have experienced drop-in audio, or in other words, an audio room. Drop-in audio-supported applications allow users to connect instantly to live voice conversations. It supports two-way and group audio conversations allowing users to express themselves in many ways. If you are thinking of having a drop-in audio feature in your app, one possible way is to use the 100ms SDK.

What is 100ms?

100ms provides SDKs to build live video and audio experiences.

Getting 100ms Account

To integrate 100ms with your app, you need to set up a 100ms account. Head to their website and follow the steps to create a free account. The screenshot below shows an example 100ms dashboard account.

Install the 100ms SDK

We add 100ms’ dependencies as a Swift Packages to our Tuist dependencies.swift file.

swift
            var swiftPackageManagerDependencies = SwiftPackageManagerDependencies(
        [
     …
        .remote(url: "https://github.com/100mslive/100ms-ios-sdk.git",requirement: .upToNextMinor(from: "0.6.2")),
    ],
)
        

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

swift
            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")
                                 ])
        

We now fetch the dependency by running

bash
            tuist fetch
        

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

bash
            tuist generate
        

From our codebase, you can find and explore how we implemented RevenueCat in the following Swift files in the folders Profile -> Sources from the Project Navigator.

Integrate the 100ms SDK

For our Spaces emulation, we did something interesting to create an audio space as a channel on our chat product. We achieved the desired Spaces behavior by adding features specific to the audio Spaces to these channels. We created the audio feature using the 100ms product. When a Space starts, a call begins with 100ms.

To explore the full implementation of the 100ms SDK in our code base, open the Xcode Project Navigator and find the folders Spaces -> Sources -> VieModel. Find the complete integration code in the Swift file SpacesViewModel.swift.

To get access to the 100ms SDK, you should initialize it. We initialized the SDK by importing import HMSSDK and adding the stored property var hmsSDK = HMSSDK.build() in SpacesViewModel.swift. Next, we implemented the necessary properties and functions for handling and publishing updates about the Space and its participants, such as joining and leaving the audio room.

SpacesViewModel.swift

swift
            //
//  SpacesViewModel.swift
//  Spaces
//
//  Created by Stefan Blos on 14.02.23.
//  Copyright © 2023 Stream.io Inc. All rights reserved.

import HMSSDK
import Chat
import StreamChat
import StreamChatSwiftUI

public class SpacesViewModel: ObservableObject {

    @Injected(\.chatClient) var chatClient

    @Published var ownTrack: HMSAudioTrack?
    @Published var otherTracks: Set<HMSAudioTrack> = []

    @Published var isAudioMuted = false

    @Published var isInSpace = false

    var hmsSDK = HMSSDK.build()

    @Published var spaces: [Space] = []
    @Published var selectedSpace: Space?

    var eventsController: EventsController?

    init() {
        let query = ChannelListQuery(
            filter: .equal(.type, to: .livestream)
        )

        let controller = chatClient.channelListController(query: query)

        controller.synchronize { error in
            if let error = error {
                print("Error querying channels: \(error.localizedDescription)")
            }

            self.spaces = Array(controller.channels)
                .filter({ channel in
                    channel.type == .livestream
                })
                .filter({ channel in
                    channel.extraData.keys.contains("spaceChannel")
                })
                .map { Space.from($0) }
        }
    }

    var isHost: Bool {
        guard let userId = chatClient.currentUserId, let hostId = selectedSpace?.hostId else {
            return false
        }

        return userId == hostId
    }

    func spaceTapped(space: Space) {
        watchChannel(id: space.id)
        selectedSpace = space
    }

    @MainActor
    func joinSpace(id: String) async {
        do {
            let channelId = try ChannelId(cid: "livestream:\(id)")

            // add user to the channel members
            let controller = chatClient.channelController(for: channelId)
            if let currentUserId = chatClient.currentUserId {
                controller.addMembers(userIds: [currentUserId])
            }

            if let callId = selectedSpace?.callId {
                await joinCall(with: callId, in: channelId)

                isInSpace = true
            } else {
                // handle error
            }
        } catch {
            print(error.localizedDescription)
            isInSpace = false
        }
    }

    func leaveSpace(id: String) {
        // TODO: stop observing channel updates

        if let channelId = try? ChannelId(cid: "livestream:\(id)") {
            let controller = chatClient.channelController(for: channelId)

            if let currentUserId = chatClient.currentUserId {
                controller.removeMembers(userIds: [currentUserId])
            }
        }

//        leaveCall(with: id)
        isInSpace = false
    }

    @MainActor
    func startSpace(id: String) async {
        do {
            let channelId = try ChannelId(cid: "livestream:\(id)")

            let callId = await startCall(with: id, in: channelId)

            updateChannel(with: channelId, to: .running, callId: callId)
            isInSpace = true
        } catch {
            print(error.localizedDescription)
            isInSpace = false
        }
    }

    func endSpace(with id: String) {
        if let channelId = try? ChannelId(cid: "livestream:\(id)") {
            // TODO temporary
            updateChannel(with: channelId, to: .planned)
        }
        // TODO: stop observing channel updates
        // TODO: should we lock the room?
        // TODO: deactivate to focus on channel updates for now
//        endCall()
    }

    func toggleAudioMute() {
        isAudioMuted.toggle()
        hmsSDK.localPeer?.localAudioTrack()?.setMute(isAudioMuted)
    }

    // TODO: make this return a Result<> with different error types for the errors and display that to users
    func createChannelForSpace(title: String, description: String, happeningNow: Bool, date: Date) {
        // create new channel
        guard let userId = chatClient.currentUserId else {
            print("ERROR: chat client doesn't have a userId")
            return
        }

        guard let channelController = try? chatClient.channelController(
            createChannelWithId: ChannelId(type: .livestream, id: UUID().uuidString),
            name: title,
            members: [userId],
            isCurrentUserMember: true,
            messageOrdering: .bottomToTop,
            // Potantially invite other users who could be part of it
            invites: [],
            extraData: [
                "spaceChannel": .bool(true),
                "description": .string(description),
                "spaceState": .string(happeningNow ? SpaceState.running.rawValue : SpaceState.planned.rawValue),
                "startTime": .string(date.ISO8601Format()),
                "speakerIdList": .array([.string(String(userId))])
            ]
        ) else {
            print("Channel creation failed")
            return
        }

        // TODO: listen to errors and act accordingly
        channelController.synchronize { error in
            if let error {
                print("Synchronize error: \(error.localizedDescription)")
            }
        }
    }
}
        

Start, Join, Leave, and End the Audio Conversation

In addition to the above, we implemented the required methods to start, join, leave, and end the audio room in the Swift file SpacesViewModel+Call.swift.

swift
            //
//  SpacesViewModel+Call.swift
//  TwitterClone
//
//  Created by Stefan Blos on 17.02.23.
//  Copyright © 2023 Stream.io Inc. All rights reserved.

import Foundation
import StreamChat
import HMSSDK

extension SpacesViewModel {

    func startCall(with id: String, in channelId: ChannelId) async -> String? {
        guard let call = try? await chatClient.createCall(with: id, in: channelId) else {
            // TODO: proper error handling
            print("Couldn't start call with id '\(id)' in channel '\(channelId.id)'.")
            return nil
        }
        let token = call.token

        // The fact that we join audio-only is handled in the 100ms dashboard
        let config = HMSConfig(userName: chatClient.currentUserController().currentUser?.name ?? "Unknown", authToken: token)
        hmsSDK.join(config: config, delegate: self)
        return call.call.hms?.roomId
    }

    func joinCall(with id: String, in channelId: ChannelId) async {
        guard let call = try? await chatClient.createCall(with: id, in: channelId) else {
            // TODO: proper error handling
            print("Couldn't join call with id '\(id)' in channel '\(channelId.id)'.")
            return
        }
        let token = call.token

        // TODO: how to join audio only
        let config = HMSConfig(userName: chatClient.currentUserController().currentUser?.name ?? "Unknown", authToken: token)

        hmsSDK.join(config: config, delegate: self)
    }

    func leaveCall(with id: String) {
        hmsSDK.leave { [weak self] _, error in
            if let error {
                print(error.localizedDescription)
                self?.isInSpace = false
            }

            self?.ownTrack = nil
            self?.otherTracks = []
            self?.isInSpace = false
        }
    }

    func endCall() {
        // Do we need to lock the room?
        hmsSDK.endRoom(lock: false, reason: "Host ended the room") { [weak self] _, error in
            if let error {
                print("Error ending the space: \(error.localizedDescription)")
            }
            self?.isInSpace = false
        }
    }

}
        

Observe and Update the UI During an Active Conversation

During active audio conversations, the 100ms SDK provides convenient ways to update the UI while conversations are happening. The updates include joining and leaving the audio room mentioned previously. To observe these ongoing updates and respond accordingly, we implemented HMSUpdateListener protocol in the 100ms SDK HMSSDK. Open SpacesViewModel+HMSUpdateListener.swift to explore the complete implementation.

swift
            //
//  SpacesViewModel+HMSUpdateListener.swift
//  Spaces
//
//  Created by Stefan Blos on 14.02.23.
//  Copyright © 2023 Stream.io Inc. All rights reserved.
//

import HMSSDK

extension SpacesViewModel: HMSUpdateListener {
    public func on(join room: HMSRoom) {
        print("[HMSUpdate] on join room: \(room.roomID ?? "unknown")")
        // Do something here
    }

    public func on(room: HMSRoom, update: HMSRoomUpdate) {
        print("[HMSUpdate] on room: \(room.roomID ?? "unknown"), update: \(update.description)")
    }

    public func on(peer: HMSPeer, update: HMSPeerUpdate) {
        // Do something here
        print("[HMSUpdate] on peer: \(peer.name), update: \(update.description)")
    }

    public func on(track: HMSTrack, update: HMSTrackUpdate, for peer: HMSPeer) {
        print("[HMSUpdate] on track: \(track.trackId), update: \(update.description), peer: \(peer.name)")
        switch update {
        case .trackAdded:
            if let audioTrack = track as? HMSAudioTrack {
                if peer.isLocal {
                    ownTrack = audioTrack
                } else {
                    otherTracks.insert(audioTrack)
                }
            }
        case .trackRemoved:
            if let audioTrack = track as? HMSAudioTrack {
                if peer.isLocal {
                    ownTrack = nil
                } else {
                    otherTracks.remove(audioTrack)
                }
            }
        default:
            break
        }
    }

    public func on(error: Error) {
        // Do something here
        print("[HMSUpdate] on error: \(error.localizedDescription)")
    }

    public func on(message: HMSMessage) {
        print("[HMSUpdate] on message: \(message.message)")
    }

    public func on(updated speakers: [HMSSpeaker]) {
        // Do something here
        print("[HMSUpdate] on updated speakers: \(speakers.description)")
    }

    public func onReconnecting() {
        // Do something here
        print("[HMSUpdate] on reconnecting")
    }

    public func onReconnected() {
        // Do something here
        print("[HMSUpdate] on reconnected")
    }
}
        

Creating the Spaces UI in our App

For the home view of our audio space, we wanted the layout and the visual appearance to mimic the actual Twitter Sppaces app. Find SpacesTimelineView.swift from the Spaces folder in the Project Navigator to explore the composition of the Spaces home view.

swift
            //
//  SpacesTimelineView.swift
//  Spaces
//
//  Created by Amos Gyamfi on 7.2.2023.
//  Copyright © 2023 Stream.io Inc. All rights reserved.
//

import SwiftUI
import TwitterCloneUI

public struct SpacesTimelineView: View {

    @StateObject var spacesViewModel = SpacesViewModel()

    @Environment(\.colorScheme) var colorScheme

    @State private var searchSpaces = "Search spaces"
    @State private var isShowingSpacesWelcome = false

    public init() {}

    public var body: some View {
        NavigationStack {
            ZStack(alignment: .bottomTrailing) {
                ScrollView {
                    VStack(alignment: .leading, spacing: 20) {
                        Text("Real spaces")
                            .font(.title3)
                            .bold()

                        ScrollView(.horizontal, showsIndicators: false) {
                            HStack(spacing: 20) {
                                ForEach(spacesViewModel.spaces) { space in
                                    VStack {
                                        SpaceCard(viewModel: spacesViewModel, space: space)
                                    }
                                }
                            }
                        }
                    }
                    .padding(.horizontal)

                    VStack(alignment: .leading) {
                        Text("Happening Now")
                            .font(.title3)
                            .bold()
                        Text("Spaces going on right now")
                            .font(.caption)
                            .foregroundColor(.secondary)

                        SpacesLiveView(spacesViewModel: spacesViewModel)
                    }
                    .padding(.horizontal)

                    VStack(alignment: .leading) {
                        Text("Get these in your calendar")
                            .font(.title3)
                            .bold()
                        Text("People you follow will be tuning in")
                            .font(.caption)
                            .foregroundColor(.secondary)

                        Button {
                            isShowingSpacesWelcome.toggle()
                        } label: {
                            SpacesScheduledView()
                        }
                        .buttonStyle(.plain)
                        .sheet(isPresented: $isShowingSpacesWelcome) {
                            SpacesWelcomeView()
                                .presentationDetents([.fraction(0.6)])
                        }
                    }
                    .padding(.horizontal)
                    .padding(.top)

                    VStack(alignment: .leading) {
                        Text("Trending")
                            .font(.title3)
                            .bold()
                        SpacesLiveView(spacesViewModel: spacesViewModel)
                    }
                    .padding()

                    SpacesScheduledView()
                        .padding()
                }
                .searchable(text: $searchSpaces)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        ProfileImage(imageUrl: "https://picsum.photos/id/550/200/200", action: {})
                            .scaleEffect(0.7)
                    }

                    ToolbarItem(placement: .principal) {
                        Text("Spaces")
                            .font(.title3)
                            .bold()
                    }
                }

                HStack {
                    Spacer()
                    SpacesAddNewButton(spacesViewModel: spacesViewModel)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 16))
                }
            }
        }
    }
}
        

Conclusion

Congratulations!. You have completed our tutorial about integrating the 100ms SDK with the TwitterClone app to provide an audio room experience.

Check our project demo and download the source code from GitHub. Are you looking for ways to integrate chat messaging, activity feeds, real-time audio, and video with your app? Contact our team for help.

Join us for part seven of Build Your Own Twitter where we integrate Twitter Blue & In-App Subscriptions using Revenue Cat!