Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for transforming Messages and Channels #3564

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion DemoApp/Shared/StreamChatWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ final class StreamChatWrapper {
config.shouldShowShadowedMessages = true
config.applicationGroupIdentifier = applicationGroupIdentifier
config.urlSessionConfiguration.httpAdditionalHeaders = ["Custom": "Example"]
// config.modelsTransformer = CustomStreamModelsTransformer()
configureUI()
}
}
Expand Down Expand Up @@ -177,7 +178,7 @@ extension StreamChatWrapper {
}
}

// MARK: Push Notifications
// MARK: - Push Notifications

extension StreamChatWrapper {
func registerForPushNotifications(with deviceToken: Data) {
Expand All @@ -192,3 +193,34 @@ extension StreamChatWrapper {
try? ChatPushNotificationInfo(content: response.notification.request.content)
}
}

// MARK: - Stream Models Transformer

// An object to test the Stream Models transformer.
// By default it is not used. To use it, set it to the `modelsTransformer` property of the `ChatClientConfig`.

class CustomStreamModelsTransformer: StreamModelsTransformer {
func transform(channel: ChatChannel) -> ChatChannel {
channel.replacing(
name: "Hey!",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe just uppercase all the letter of the channel name? Would feel weird otherwise to see only "Hey!" everywhere.
Same for messages, some small transformation of the original text would be nice.

imageURL: channel.imageURL,
extraData: channel.extraData
)
}

func transform(message: ChatMessage) -> ChatMessage {
message.replacing(
text: "Yo",
extraData: message.extraData,
attachments: message.allAttachments
)
}

func transform(newMessageInfo: NewMessageTransformableInfo) -> NewMessageTransformableInfo {
newMessageInfo.replacing(
text: "Changed",
attachments: newMessageInfo.attachments,
extraData: newMessageInfo.extraData
)
}
}
5 changes: 3 additions & 2 deletions Sources/StreamChat/Config/ChatClientConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ public struct ChatClientConfig {
/// If set to `true`, `ChatClient` resets the local cache on the start.
///
/// You should set `shouldFlushLocalStorageOnStart = true` every time the changes in your code makes the local cache invalid.
///
///
public var shouldFlushLocalStorageOnStart: Bool = false

/// An object that provides a way to transform Stream Chat models.
public var modelsTransformer: StreamModelsTransformer?

/// Advanced settings for the local caching and model serialization.
public var localCaching = LocalCaching()

Expand Down
79 changes: 79 additions & 0 deletions Sources/StreamChat/Config/StreamModelsTransformer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// An object that provides a way to transform Stream Chat models.
///
/// Only some data can be changed. The method `replacing()` is used to create a new object from existing data.
/// All transform functions have default implementation, so you can override only the ones you need.
///
/// - Note: When using `replacing()` method, you need to provide all the properties of the existing object.
/// Or you can pass `nil` and that will erase the existing value.
///
nuno-vieira marked this conversation as resolved.
Show resolved Hide resolved
/// - Important: Transform methods can be called often and therefore, it must be performant.
///
/// Example:
/// ```
/// class CustomStreamModelsTransformer: StreamModelsTransformer {
/// func transform(channel: ChatChannel) -> ChatChannel {
/// channel.replacing(
/// name: "Hey!",
/// imageURL: channel.imageURL,
/// extraData: channel.extraData
/// )
/// }
/// ```
public protocol StreamModelsTransformer {
/// Transforms the given `ChatChannel` object.
func transform(channel: ChatChannel) -> ChatChannel
/// Transforms the given `ChatMessage` object.
func transform(message: ChatMessage) -> ChatMessage
/// Transforms the given `NewMessageTransformableInfo` object when creating a new message.
func transform(newMessageInfo: NewMessageTransformableInfo) -> NewMessageTransformableInfo
}

/// Default implementations.
extension StreamModelsTransformer {
func transform(channel: ChatChannel) -> ChatChannel {
channel
}

func transform(message: ChatMessage) -> ChatMessage {
message
}

func transform(newMessageInfo: NewMessageTransformableInfo) -> NewMessageTransformableInfo {
newMessageInfo
}
}

/// The information that can be transformed when creating a new message.
public struct NewMessageTransformableInfo {
public var text: String
public var attachments: [AnyAttachmentPayload]
public var extraData: [String: RawJSON]

init(
text: String,
attachments: [AnyAttachmentPayload],
extraData: [String: RawJSON]
) {
self.text = text
self.attachments = attachments
self.extraData = extraData
}

public func replacing(
text: String,
attachments: [AnyAttachmentPayload],
extraData: [String: RawJSON]
) -> NewMessageTransformableInfo {
.init(
text: text,
attachments: attachments,
extraData: extraData
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -763,17 +763,26 @@ public class ChatChannelController: DataController, DelegateCallable, DataStoreP
extraData: [String: RawJSON] = [:],
completion: ((Result<MessageId, Error>) -> Void)? = nil
) {
var transformableInfo = NewMessageTransformableInfo(
text: text,
attachments: attachments,
extraData: extraData
)
if let transformer = client.config.modelsTransformer {
transformableInfo = transformer.transform(newMessageInfo: transformableInfo)
}

createNewMessage(
messageId: messageId,
text: text,
text: transformableInfo.text,
pinning: pinning,
isSilent: isSilent,
attachments: attachments,
attachments: transformableInfo.attachments,
mentionedUserIds: mentionedUserIds,
quotedMessageId: quotedMessageId,
skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
extraData: extraData,
extraData: transformableInfo.extraData,
poll: nil,
completion: completion
)
Expand Down Expand Up @@ -1638,7 +1647,11 @@ private extension ChatChannelController {
let observer = BackgroundEntityDatabaseObserver(
database: self.client.databaseContainer,
fetchRequest: ChannelDTO.fetchRequest(for: cid),
itemCreator: { try $0.asModel() as ChatChannel }
itemCreator: {
try $0.asModel(
transformer: self.client.config.modelsTransformer
) as ChatChannel
Comment on lines +1651 to +1653
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When thinking ahead to cleaning up deletedMessagesVisibility and others then that would require to have an access to ChatConfig within asModel(). Therefore, should we just pass in the config here and read the transformer within the asModel implementation?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm not sure how that will look like, but we can improve it once we work on that 👍 But I don't think we need to pass the whole config to the asModel though 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, can be changed later if needed. When I briefly checked it then ChannelDTO.asModel requires multiple values from the config.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, in that case I will probably change this yet 👍

}
).onChange { [weak self] change in
self?.delegateCallback { [weak self] in
guard let self = self else {
Expand Down Expand Up @@ -1682,7 +1695,9 @@ private extension ChatChannelController {
deletedMessagesVisibility: deletedMessageVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages
),
itemCreator: { try $0.asModel() as ChatMessage },
itemCreator: {
try $0.asModel(transformer: self.client.config.modelsTransformer) as ChatMessage
},
itemReuseKeyPaths: (\ChatMessage.id, \MessageDTO.id)
)
observer.onDidChange = { [weak self] changes in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ public class ChatChannelListController: DataController, DelegateCallable, DataSt
let observer = self.environment.createChannelListDatabaseObserver(
client.databaseContainer,
request,
{ try $0.asModel() },
{ try $0.asModel(
transformer: self.client.config.modelsTransformer
) },
query.sort.runtimeSorting
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,22 +322,31 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP
) {
let parentMessageId = self.messageId

var transformableInfo = NewMessageTransformableInfo(
text: text,
attachments: attachments,
extraData: extraData
)
if let transformer = client.config.modelsTransformer {
transformableInfo = transformer.transform(newMessageInfo: transformableInfo)
}

messageUpdater.createNewReply(
in: cid,
messageId: messageId,
text: text,
text: transformableInfo.text,
pinning: pinning,
command: nil,
arguments: nil,
parentMessageId: parentMessageId,
attachments: attachments,
attachments: transformableInfo.attachments,
mentionedUserIds: mentionedUserIds,
showReplyInChannel: showReplyInChannel,
isSilent: isSilent,
quotedMessageId: quotedMessageId,
skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
extraData: extraData
extraData: transformableInfo.extraData
) { result in
if let newMessage = try? result.get() {
self.client.eventNotificationCenter.process(NewMessagePendingEvent(message: newMessage))
Expand Down Expand Up @@ -878,7 +887,7 @@ private extension ChatMessageController {
let observer = environment.messageObserverBuilder(
client.databaseContainer,
MessageDTO.message(withID: messageId),
{ try $0.asModel() },
{ try $0.asModel(transformer: self.client.config.modelsTransformer) },
NSFetchedResultsController<MessageDTO>.self
)

Expand All @@ -901,7 +910,7 @@ private extension ChatMessageController {
deletedMessagesVisibility: deletedMessageVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages
),
{ try $0.asModel() as ChatMessage },
{ try $0.asModel(transformer: self.client.config.modelsTransformer) as ChatMessage },
NSFetchedResultsController<MessageDTO>.self
)
observer.onDidChange = { [weak self] changes in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public class ChatMessageSearchController: DataController, DelegateCallable, Data
fetchRequest: MessageDTO.messagesFetchRequest(
for: lastQuery ?? query
),
itemCreator: { try $0.asModel() as ChatMessage },
itemCreator: { try $0.asModel(transformer: self.client.config.modelsTransformer) as ChatMessage },
itemReuseKeyPaths: (\ChatMessage.id, \MessageDTO.id)
)
observer.onDidChange = { [weak self] changes in
Expand Down
37 changes: 27 additions & 10 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,20 @@ extension ChannelDTO {

extension ChannelDTO {
/// Snapshots the current state of `ChannelDTO` and returns an immutable model object from it.
func asModel() throws -> ChatChannel { try .create(fromDTO: self, depth: 0) }
func asModel(
transformer: StreamModelsTransformer? = nil
) throws -> ChatChannel {
try .create(fromDTO: self, depth: 0, transformer: transformer)
}

/// Snapshots the current state of `ChannelDTO` and returns an immutable model object from it if the dependency depth
/// limit has not been reached
func relationshipAsModel(depth: Int) throws -> ChatChannel? {
func relationshipAsModel(
depth: Int,
transformer: StreamModelsTransformer?
) throws -> ChatChannel? {
do {
return try .create(fromDTO: self, depth: depth + 1)
return try .create(fromDTO: self, depth: depth + 1, transformer: transformer)
} catch {
if error is RecursionLimitError { return nil }
throw error
Expand All @@ -456,7 +463,11 @@ extension ChannelDTO {

extension ChatChannel {
/// Create a ChannelModel struct from its DTO
fileprivate static func create(fromDTO dto: ChannelDTO, depth: Int) throws -> ChatChannel {
fileprivate static func create(
fromDTO dto: ChannelDTO,
depth: Int,
transformer: StreamModelsTransformer?
) throws -> ChatChannel {
guard StreamRuntimeCheck._canFetchRelationship(currentDepth: depth) else {
throw RecursionLimitError()
}
Expand Down Expand Up @@ -501,7 +512,7 @@ extension ChatChannel {
let latestMessages: [ChatMessage] = {
var messages = sortedMessageDTOs
.prefix(dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25)
.compactMap { try? $0.relationshipAsModel(depth: depth) }
.compactMap { try? $0.relationshipAsModel(depth: depth, transformer: transformer) }
if let oldest = dto.oldestMessageAt?.bridgeDate {
messages = messages.filter { $0.createdAt >= oldest }
}
Expand All @@ -519,7 +530,7 @@ extension ChatChannel {
guard messageDTO.localMessageState == nil else { return false }
return messageDTO.type != MessageType.ephemeral.rawValue
})?
.relationshipAsModel(depth: depth)
.relationshipAsModel(depth: depth, transformer: transformer)
}()

let watchers = dto.watchers
Expand Down Expand Up @@ -555,11 +566,11 @@ extension ChatChannel {
)
}()
let membership = try dto.membership.map { try $0.asModel() }
let pinnedMessages = dto.pinnedMessages.compactMap { try? $0.relationshipAsModel(depth: depth) }
let previewMessage = try? dto.previewMessage?.relationshipAsModel(depth: depth)
let pinnedMessages = dto.pinnedMessages.compactMap { try? $0.relationshipAsModel(depth: depth, transformer: transformer) }
let previewMessage = try? dto.previewMessage?.relationshipAsModel(depth: depth, transformer: transformer)
let typingUsers = Set(dto.currentlyTypingUsers.compactMap { try? $0.asModel() })
return try ChatChannel(

let channel = try ChatChannel(
cid: cid,
name: dto.name,
imageURL: dto.imageURL,
Expand Down Expand Up @@ -592,6 +603,12 @@ extension ChatChannel {
muteDetails: muteDetails,
previewMessage: previewMessage
)

if let transformer = transformer {
return transformer.transform(channel: channel)
}

return channel
}
}

Expand Down
Loading
Loading