Skip to content

Introduce MessageViewModel + Show original translated message #815

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

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
},
onJumpToMessage: viewModel.jumpToMessage(messageId:)
)
.environmentObject(viewModel)
.overlay(
viewModel.currentDateString != nil ?
factory.makeDateIndicatorView(dateString: viewModel.currentDateString!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@
}

@Published public private(set) var channel: ChatChannel?


/// The message ids of the translated messages that should show the original text.
@Published public var originalTextMessageIds: Set<MessageId> = []

public var isMessageThread: Bool {
messageController != nil
}
Expand Down Expand Up @@ -158,7 +161,7 @@
channelDataSource.delegate = self
messages = channelDataSource.messages
channel = channelController.channel

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
if let scrollToMessage, let parentMessageId = scrollToMessage.parentMessageId, messageController == nil {
let message = channelController.dataStore.message(id: parentMessageId)
Expand Down Expand Up @@ -221,7 +224,30 @@
checkHeaderType()
checkUnreadCount()
}


/// Show the original text for the given translated message.
public func showOriginalText(for message: ChatMessage) {
originalTextMessageIds.insert(message.id)
}

/// Show the translated text for the given translated message.
public func showTranslatedText(for message: ChatMessage) {
originalTextMessageIds.remove(message.id)
}

/// Creates a view model for the given message.
///
/// You can override this method to provide a custom view model.
open func makeMessageViewModel(
for message: ChatMessage
) -> MessageViewModel {
MessageViewModel(
message: message,
channel: channel,
originalTextMessageIds: originalTextMessageIds
)
}

@objc
private func selectedMessageThread(notification: Notification) {
if let message = notification.userInfo?[MessageRepliesConstants.selectedMessage] as? ChatMessage {
Expand Down Expand Up @@ -799,7 +825,7 @@
}
}

extension ChatMessage: Identifiable {

Check warning on line 828 in Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

extension declares a conformance of imported type 'ChatMessage' to imported protocol 'Identifiable'; this will not behave correctly if the owners of 'StreamChat' introduce this conformance in the future

public var scrollMessageId: String {
messageId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import StreamChat
import SwiftUI

public struct MessageContainerView<Factory: ViewFactory>: View {
@EnvironmentObject var channelViewModel: ChatChannelViewModel
@EnvironmentObject var messageViewModel: MessageViewModel
Comment on lines +10 to +11
Copy link
Member Author

@nuno-vieira nuno-vieira Apr 24, 2025

Choose a reason for hiding this comment

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

I have a stashed commit that uses @EnvinronmentValues instead, like we do with the @Environment(\.channelTranslationLanguage). Ofc, there are pros and cons here:

@EnvironmentObject:
🟢 No Optionals
🔴 Unsafe, can cause crashes easily if a customer is using views directly and do not provide the env object

@Environment:
🟢 Safer
🔴 More boilerplate code, to fall back to previous logic when the view model is optional

Either way, I think going for the @Environment is better IMO. It is safer, we can use it in child views safely.

@Environment(\.channelTranslationLanguage) var translationLanguage

@Injected(\.fonts) private var fonts
Expand Down Expand Up @@ -35,10 +37,6 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
private let replyThreshold: CGFloat = 60
private let paddingValue: CGFloat = 8

var isSwipeToReplyPossible: Bool {
message.isInteractionEnabled && channel.config.repliesEnabled
}

public init(
factory: Factory,
channel: ChatChannel,
Expand All @@ -65,24 +63,24 @@ public struct MessageContainerView<Factory: ViewFactory>: View {

public var body: some View {
HStack(alignment: .bottom) {
if message.type == .system || (message.type == .error && message.isBounced == false) {
if messageViewModel.systemMessageShown {
factory.makeSystemMessageView(message: message)
} else {
if message.isRightAligned {
if messageViewModel.isRightAligned {
MessageSpacer(spacerWidth: spacerWidth)
} else {
if messageListConfig.messageDisplayOptions.showAvatars(for: channel) {
if let userDisplayInfo = messageViewModel.userDisplayInfo {
factory.makeMessageAvatarView(
for: message.authorDisplayInfo
for: userDisplayInfo
)
.opacity(showsAllInfo ? 1 : 0)
.offset(y: bottomReactionsShown ? offsetYAvatar : 0)
.animation(nil)
}
}

VStack(alignment: message.isRightAligned ? .trailing : .leading) {
if isMessagePinned {
VStack(alignment: messageViewModel.isRightAligned ? .trailing : .leading) {
if messageViewModel.isPinned {
MessagePinDetailsView(
message: message,
reactionsShown: topReactionsShown
Expand All @@ -109,9 +107,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
}
)
: nil

((message.localState == .sendingFailed || message.isBounced) && !message.text.isEmpty) ?
SendFailureIndicator() : nil
messageViewModel.failureIndicatorShown ? SendFailureIndicator() : nil
}
)
.background(
Expand All @@ -137,7 +133,7 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
coordinateSpace: .local
)
.updating($offset) { (value, gestureState, _) in
guard isSwipeToReplyPossible else {
guard messageViewModel.isSwipeToQuoteReplyPossible else {
return
}
// Using updating since onEnded is not called if the gesture is canceled.
Expand Down Expand Up @@ -231,10 +227,30 @@ public struct MessageContainerView<Factory: ViewFactory>: View {

if message.textContent(for: translationLanguage) != nil,
let localizedName = translationLanguage?.localizedName {
Text(L10n.Message.translatedTo(localizedName))
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
HStack(spacing: 4) {
Text(L10n.Message.translatedTo(localizedName))
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
Text("•")
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
Button(
action: {
if messageViewModel.originalTextShown {
channelViewModel.showTranslatedText(for: message)
} else {
channelViewModel.showOriginalText(for: message)
}
},
label: {
Text(messageViewModel.originalTextShown ? "Show Translation" : "Show Original")
Copy link
Member Author

Choose a reason for hiding this comment

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

Still requires adding L10n translation. And the UI is not fully done yet.

.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
}
)
}
}

if showsAllInfo && !message.isDeleted {
if message.isSentByCurrentUser && channel.config.readEventsEnabled {
HStack(spacing: 4) {
Expand All @@ -243,15 +259,13 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
message: message
)

if messageListConfig.messageDisplayOptions.showMessageDate {
if messageViewModel.messageDateShown {
factory.makeMessageDateView(for: message)
}
}
} else if !message.isRightAligned
&& channel.memberCount > 2
&& messageListConfig.messageDisplayOptions.showAuthorName {
} else if messageViewModel.authorAndDateShown {
factory.makeMessageAuthorAndDateView(for: message)
} else if messageListConfig.messageDisplayOptions.showMessageDate {
} else if messageViewModel.messageDateShown {
factory.makeMessageDateView(for: message)
}
}
Expand All @@ -265,43 +279,41 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
: nil
)

if !message.isRightAligned {
if !messageViewModel.isRightAligned {
MessageSpacer(spacerWidth: spacerWidth)
}
}
}
.padding(
.top,
topReactionsShown && !isMessagePinned ? messageListConfig.messageDisplayOptions.reactionsTopPadding(message) : 0
topReactionsShown && !messageViewModel.isPinned ? messageListConfig.messageDisplayOptions
.reactionsTopPadding(message) : 0
)
.padding(.horizontal, messageListConfig.messagePaddings.horizontal)
.padding(.bottom, showsAllInfo || isMessagePinned ? paddingValue : 2)
.padding(.bottom, showsAllInfo || messageViewModel.isPinned ? paddingValue : 2)
.padding(.top, isLast ? paddingValue : 0)
.background(isMessagePinned ? Color(colors.pinnedBackground) : nil)
.padding(.bottom, isMessagePinned ? paddingValue / 2 : 0)
.background(messageViewModel.isPinned ? Color(colors.pinnedBackground) : nil)
.padding(.bottom, messageViewModel.isPinned ? paddingValue / 2 : 0)
.transition(
message.isSentByCurrentUser ?
messageListConfig.messageDisplayOptions.currentUserMessageTransition :
messageListConfig.messageDisplayOptions.otherUserMessageTransition
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("MessageContainerView")
.environmentObject(messageViewModel)
}

private var maximumHorizontalSwipeDisplacement: CGFloat {
replyThreshold + 30
}

private var isMessagePinned: Bool {
message.pinDetails != nil
}

private var contentWidth: CGFloat {
let padding: CGFloat = messageListConfig.messagePaddings.horizontal
let minimumWidth: CGFloat = 240
let available = max(minimumWidth, (width ?? 0) - spacerWidth) - 2 * padding
let avatarSize: CGFloat = CGSize.messageAvatarSize.width + padding
let totalWidth = message.isRightAligned ? available : available - avatarSize
let totalWidth = messageViewModel.isRightAligned ? available : available - avatarSize
return totalWidth
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import StreamChat
import SwiftUI

public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
@EnvironmentObject private var channelViewModel: ChatChannelViewModel

@Injected(\.utils) private var utils
@Injected(\.chatClient) private var chatClient
Expand Down Expand Up @@ -149,6 +150,10 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
isLast: !showsLastInGroupInfo && message == messages.last
)
.environment(\.channelTranslationLanguage, channel.membership?.language)
.environmentObject(channelViewModel)
.environmentObject(channelViewModel.makeMessageViewModel(
for: message
))
.onAppear {
if index == nil {
index = messageListDateUtils.index(for: message, in: messages)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import StreamChat
import SwiftUI

public struct MessageView<Factory: ViewFactory>: View {
@EnvironmentObject private var viewModel: MessageViewModel

@Injected(\.utils) private var utils

private var messageTypeResolver: MessageTypeResolving {
Expand Down Expand Up @@ -252,7 +254,8 @@ struct StreamTextView: View {
public struct LinkDetectionTextView: View {
@Environment(\.layoutDirection) var layoutDirection
@Environment(\.channelTranslationLanguage) var translationLanguage

@EnvironmentObject private var viewModel: MessageViewModel
Comment on lines 254 to +257
Copy link
Member Author

Choose a reason for hiding this comment

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

This one is a good example that @EnvironmentObject is not a good idea here, since LinkDetectionTextView is likely to be reused as a standalone by other customers.


@Injected(\.colors) var colors
@Injected(\.fonts) var fonts
@Injected(\.utils) var utils
Expand All @@ -263,8 +266,6 @@ public struct LinkDetectionTextView: View {
LocalizedStringKey(message.adjustedText)
}

@State var displayedText: AttributedString?

@State var linkDetector = TextLinkDetector()

@State var tintColor = InjectedValues[\.colors].tintColor
Expand All @@ -275,30 +276,16 @@ public struct LinkDetectionTextView: View {

public var body: some View {
Group {
if let displayedText {
Text(displayedText)
} else {
Text(message.adjustedText)
}
Comment on lines -278 to -282
Copy link
Member Author

Choose a reason for hiding this comment

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

In case of LinkDetectionTextView, I'm considering passing a new property, maybe originalText: String? instead of the whole view model, so that it requires less changes. Still need to check if it would work.

Text(displayText)
}
.foregroundColor(textColor(for: message))
.font(fonts.body)
.tint(tintColor)
.onAppear {
displayedText = attributedString(for: message)
}
.onChange(of: message, perform: { updated in
displayedText = attributedString(for: updated)
})
Comment on lines -287 to -292
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 will probably need some help here CC @martinmitrevski @laevandus. This was overriding the view model content always, and was causing some trouble to me. Why is this needed?

}

private func attributedString(for message: ChatMessage) -> AttributedString {
var text = message.adjustedText

// Translation
if let translatedText = message.textContent(for: translationLanguage) {
text = translatedText
}
var displayText: AttributedString {
let text = viewModel.textContent
Comment on lines +286 to +287
Copy link
Member Author

Choose a reason for hiding this comment

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

Having just this was enough to make it work, and I tried markdown etc and did not break anything for me.


// Markdown
let attributes = AttributeContainer()
.foregroundColor(textColor(for: message))
Expand Down
Loading
Loading