State Layer

Available from StreamChat version 4.56.0.

The StreamChat framework represents the Stream API with controllers which use completion handlers and delegates for communicating about state changes. In addition to controllers, we provide a new and modern way of interacting with the Stream API. It follows an architecture where we have objects interacting with the Stream API through async functions. These objects are accompanied with state objects that hold the current state. This architecture follows similar patterns as our Video SDK for iOS.

While controllers use delegates, the state layer provides a way of observing data changes through @Published properties. Observable properties are part of state objects which conform to the ObservableObject protocol.

One of the important improvements of the new state layer is predictability of the current data state. While methods in controllers have completion handlers, these do not ensure that the data is up to date when the completion handler is called. The new state layer, on the other hand, is designed in way that if an async method call returns, the required API request has finished, and the state object holding the current state has been updated to include the change.

Every async method is safe to be called from any thread. Observable state objects are isolated to the main actor. This gives a guarantee that observable state changes and reads happen on the main actor. While state objects are isolated to the main actor, we can still access the state from non-main actor contexts which just requires to use the await keyword.

Let’s consider an example below where we ask to load messages. When the loadMessages(with:) returns, we are guaranteed that the observable state object has all the loaded messages available.

try await chat.loadMessages(with: MessagesPagination(pageSize: pageSize))
let allMessages = chat.state.messages

Types Representing the State

The state layer has objects representing different use-cases which enables accessing and manipulating data the Stream API provides. These types follow an architecture where high level types have methods for interacting with the Stream API and the state counterpart keeps the currently loaded data. The state object is observable and is backed by the local persistent store.

  • ChannelList
    • ChannelListState
  • Chat
    • ChatState
    • MessageState
  • ConnectedUser
    • ConnectedUserState
  • MemberList
    • MemberListState
  • MessageSearch
    • MessageSearchState
  • ReactionList
    • ReactionListState
  • UserList
    • UserListState
  • UserSearch
    • UserSearchState

These objects are created through factory methods in ChatClient. Factory methods create instances of ChannelList, Chat, ConnectedUser, MemberList, MessageSearch, ReactionList, UserList, and UserSearch. The observable state is accessed through state properties what return the respective state object. ChannelList, Chat, MemberList, ReactionList, and UserList have a get() method for loading the default set of data from the Stream API.

ConnectedUser

ConnectedUser and its ConnectedUserState represent the currently logged in user. We log in by calling connectUser method on the ChatClient instance. Please refer to our tokens and authentication documentation for more information on expiring and non-expiring tokens. ConnectedUser type enables setting up push notifications and set states, like muting a user in every channel or even blocking an user. Another common use-case is marking all the channels as read.

var connectedUser = try await chatClient.connectUser(
    userInfo: UserInfo(id: "<# Your User ID Here #>"),
    token: "<# Your User Token Here#>"
)
// Or while being logged in
connectedUser = try chatClient.makeConnectedUser()
// Register for push notifications
try await connectedUser.addDevice(
    .apn(
        token: apnTokenData, 
        providerName: "<# Your Stream's Push Configuration Name>"
    )
)
// Mute a user
try await connectedUser.muteUser("user-id")
// Block a user
try await connectedUser.blockUser("another-user-id")
// Mark all the channels as read
try await connectedUser.markAllChannelsRead()
// Read user data
let unreadCounts = connectedUser.state.user.unreadCount
let numberOfUnreadChannels = unreadCounts.channels
let numberOfUnreadMessages = unreadCounts.messages
// React to user data changes
connectedUser.state.$user
  .sink { changedUser in
      let changedUnreadCount = changedUser.unreadCount
  }
  .store(in: &cancellables)

ChannelList

ChannelList and its ChannelListState enable querying a list of channels from the Stream API and provide an interface for loading channels in a paginated manner.

let query = ChannelListQuery(
    filter: .and([
        .equal(.type, to: .messaging), 
        .containMembers(userIds: ["thierry"])
    ]),
    sort: [Sorting(key: .lastMessageAt, isAscending: false)],
    pageSize: 10
)
let channelList = chatClient.makeChannelList(with: query)
// Local state of the channel list (state from previous sessions)
var channels = channelList.state.channels
// Load the first page of channels from the Stream API
try await channelList.get()
// Load more channels
try await channelList.loadMoreChannels()
// Access all the loaded channels
channels = channelList.state.channels

Since ChannelListState is observable, we can observe and react to channel list changes.

let cancellable = channelList.state
    .$channels
    .sink { channels in
        // Do something with channels
    }

Chat

Chat and its ChatState represent the state of a channel. In addition, Chat has a method for retrieving an observable MessageState type which represents a single message and its observable state like reactions and replies.

Here is an example of creating an instance of Chat, using the offline state, refreshing it with server state and then paginating available messages.

let channelId = ChannelId(type: .messaging, id: "general")
let chat = chatClient.makeChat(for: channelId)
// Local state of the channel (state from previous sessions)
var messages = chat.state.messages
var members = chat.state.members
// Load the latest state from the Stream API and subscribe to data changes
try await chat.get(watch: true)
// Access all the loaded messages and members
messages = chat.state.messages
members = chat.state.members
// Load more messages
try await chat.loadOlderMessages()
// Access all the loaded messages
messages = chat.state.messages

Call get(watch: true) once per app lifetime The get method loads the latest state from the Stream API and if we set watch to true, server-side events will keep your local state up to state.

Chat has a sendMessage method for sending messages. Sending a message could be as simple as just sending a text or more complex like including attachments, quoting another message, marking it as pinned and including extra data.

let fileURL = URL(filePath: "<file url>")
let attachment = try AnyAttachmentPayload(
    localFileURL: fileURL,
    attachmentType: .file
)
try await chat.sendMessage(
    with: "Hello",
    attachments: [attachment],
    quote: "other-message-id",
    pinning: .noExpiration,
    extraData: [
      "my-custom-key": .string("and string value")
    ]
)

If we would like to create a message thread within a channel, the Chat type provides a reply method with all the before-mentioned arguments.

try await chat.reply(
    to: "parent-message-id",
    text: "Hi!",
    showReplyInChannel: true
)

Adding and deleting reactions is also done through Chat.

try await chat.sendReaction(
    to: "message-id",
    with: "like",
    score: 1,
    enforceUnique: true,
    extraData: [
        "has-xyz-enabled": .bool(true)
    ]
)
try await chat.deleteReaction(
    from: "message-id",
    with: "like"
)

If we would like to observe or read a single message’s state, then we can use the MessageState observable object. This type also gives access to all the loaded replies and reactions for the given message.

let messageState = try await chat.messageState(for: "message-id")
try await chat.loadMoreReplies(for: messageId)
// Access all the loaded replies
let replies = messageState.replies
// Load reactions
let reactionsBatch25 = try await chat.loadReactions(
    for: "message-id",
    pagination: Pagination(pageSize: 25)
)
// Paginate reactions by loading 10 more
let reactionsBatch10 = try await chat.loadMoreReactions(
    for: "message-id",
    limit: 10
)
// Access all the currently loaded reactions
let allLoadedReactions35 = messageState.reactions

Use Chat for loading all the reactions per message and ReactionList if you need to load reactions using a filter.

MemberList

MemberList and MemberListState represent channel members for the specified query. The query supports a wide variety of filters and sorting options. While MemberList provides advanced querying capabilities, it is not required if we would like to load all the channel members. For that purpose, Chat has built-in paginated loading methods for all the channel members.

let query = ChannelMemberListQuery(
        cid: ChannelId(type: .messaging, id: "general"),
        sort: [Sorting(key: .createdAt, isAscending: true)]
    )
let memberList = chatClient.makeMemberList(with: query)
// Local state of the member list (state from previous sessions)
var members = memberList.state.members
// Load the first page of members from the Stream API
try await memberList.get()
// Load more members
try await memberList.loadMoreMembers()
// Access all the loaded members
members = memberList.state.members

MessageSearch

MessageSearch and MessageSearchState represent search results for messages. Messages can be searched using a combination of filters or just by a simple search term.

let messageSearch = chatClient.makeMessageSearch()
// Searching for messages containing "Stream"
try await messageSearch.search(text: "Stream")
// Or searching for messages in the specified channel containing "Stream"
try await messageSearch.search(query:
    MessageSearchQuery(
        channelFilter: .containMembers(userIds: ["john"]),
        messageFilter: .autocomplete(.text, text: "Stream")
    )
)
// Load more results
try await messageSearch.loadMoreMessages()
// All the loaded results
let matches = messageSearch.state.messages

ReactionList

ReactionList and ReactionListState represent a list of messages reactions matching to a query. ReactionList is useful for cases where we want to query a list of message reactions using a filter.

let query = ReactionListQuery(
    messageId: messageId,
    filter: .equal(.reactionType, to: "like")
)
let reactionList = chatClient.makeReactionList(with: query)
// Load reactions for the query
try await reactionList.get()
// Load more reactions
try await reactionList.loadMoreReactions()
// All the loaded reactions
let users = reactionList.state.reactions

UserList

UserList and UserListState represent a list of users matching to a query. UserList is useful for cases where we want to query a list of users filtered and sorted in a specific way.

let query = UserListQuery(
    filter: .in(.id, values: ["john", "jack", "jessie"]),
    sort: [Sorting(key: .lastActivityAt, isAscending: false)]
)
let userList = chatClient.makeUserList(with: query)
// Load users for the query
try await userList.get()
// Load more users
try await userList.loadMoreUsers()
// All the loaded users
let users = userList.state.users

UserSearch

UserSearch and UserSearchState represent user search results. Although UserList and UserSearch are similar and use the same UserListQuery, the main difference is that UserSearch is optimized for interactive searching where the query changes often.

let userSearch = chatClient.makeUserSearch()
try await userSearch.search(term: "thi")
// Load more results
try await userSearch.loadMoreUsers()
// All the loaded results for "thi"
var users = userSearch.state.users
// Change the search term
try await userSearch.search(term: "thie")
// All the loaded results for "thie"
users = userSearch.state.users

Listening to Web-Socket Events

Web-socket events are delivered whenever server side state changes. Stream chat’s low-level client listens to these events and updates the local state accordingly. If there is a need, we can subscribe to these events using ChatClient for any events including channel events, and Chat for channel specific events. The latter offers convenience over the former.

// Every single event
chatClient.subscribe { event in
    // …
}
.store(in: &cancellables)

// A specific event
chatClient.subscribe(toEvent: ConnectionStatusUpdated.self) { event in
    // …
}
.store(in: &cancellables)

// Channel specific event
let channelId = ChannelId(type: .messaging, id: "general")
let chat = chatClient.makeChat(for: channelId)
chat.subscribe { event in
    // All the events only for this particular channel id
}
.store(in: &cancellables)

// Channel specific events filtered down to a single type
chat.subscribe(toEvent: ChannelHiddenEvent.self) { event in
    // …
}
.store(in: &cancellables)
© Getstream.io, Inc. All Rights Reserved.