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

Open
wants to merge 14 commits into
base: develop
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions DemoAppSwiftUI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {

let utils = Utils(
messageListConfig: MessageListConfig(
messageDisplayOptions: .init(showOriginalTranslatedButton: true),
dateIndicatorPlacement: .messageList,
userBlockingEnabled: true,
bouncedMessagesAlertActionsEnabled: true,
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
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 Expand Up @@ -156,6 +157,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
messageDisplayInfo = nil
}
)
.environment(\.messageViewModel, viewModel.makeMessageViewModel(for: messageDisplayInfo!.message))
.transition(.identity)
.edgesIgnoringSafeArea(.all)
: nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

@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 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
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 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
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
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
@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 @@ -229,12 +225,13 @@ 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))
if messageViewModel.translatedText != nil {
factory.makeMessageTranslationFooterView(
channelViewModel: channelViewModel,
messageViewModel: messageViewModel
)
}

if showsAllInfo && !message.isDeleted {
if message.isSentByCurrentUser && channel.config.readEventsEnabled {
HStack(spacing: 4) {
Expand All @@ -243,15 +240,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 +260,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")
.environment(\.messageViewModel, 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 @@ -146,6 +146,7 @@ public struct MessageDisplayOptions {
public let spacerWidth: (CGFloat) -> CGFloat
public let reactionsTopPadding: (ChatMessage) -> CGFloat
public let dateSeparator: (ChatMessage, ChatMessage) -> Date?
public let showOriginalTranslatedButton: Bool

public init(
showAvatars: Bool = true,
Expand All @@ -165,7 +166,8 @@ public struct MessageDisplayOptions {
.defaultLinkDisplay,
spacerWidth: @escaping (CGFloat) -> CGFloat = MessageDisplayOptions.defaultSpacerWidth,
reactionsTopPadding: @escaping (ChatMessage) -> CGFloat = MessageDisplayOptions.defaultReactionsTopPadding,
dateSeparator: @escaping (ChatMessage, ChatMessage) -> Date? = MessageDisplayOptions.defaultDateSeparator
dateSeparator: @escaping (ChatMessage, ChatMessage) -> Date? = MessageDisplayOptions.defaultDateSeparator,
showOriginalTranslatedButton: Bool = false
) {
self.showAvatars = showAvatars
self.showAuthorName = showAuthorName
Expand All @@ -184,6 +186,7 @@ public struct MessageDisplayOptions {
self.newMessagesSeparatorSize = newMessagesSeparatorSize
self.dateSeparator = dateSeparator
self.reactionsPlacement = reactionsPlacement
self.showOriginalTranslatedButton = showOriginalTranslatedButton
}

public func showAvatars(for channel: ChatChannel) -> Bool {
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 Expand Up @@ -603,6 +608,14 @@ private struct ChannelTranslationLanguageKey: EnvironmentKey {
static let defaultValue: TranslationLanguage? = nil
}

private struct MessageViewModelKey: EnvironmentKey {
static let defaultValue: MessageViewModel? = nil
}

private struct ChatChannelViewModelKey: EnvironmentKey {
static let defaultValue: ChatChannelViewModel? = nil
}
Comment on lines +611 to +617
Copy link
Contributor

Choose a reason for hiding this comment

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

These seems to be unused now

Copy link
Member Author

Choose a reason for hiding this comment

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

It is used on LinkDetectionView, and it would be used for lower level views if needed


extension EnvironmentValues {
var channelTranslationLanguage: TranslationLanguage? {
get {
Expand All @@ -612,4 +625,22 @@ extension EnvironmentValues {
self[ChannelTranslationLanguageKey.self] = newValue
}
}

var messageViewModel: MessageViewModel? {
get {
self[MessageViewModelKey.self]
}
set {
self[MessageViewModelKey.self] = newValue
}
}

var channelViewModel: ChatChannelViewModel? {
get {
self[ChatChannelViewModelKey.self]
}
set {
self[ChatChannelViewModelKey.self] = newValue
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import StreamChat
import SwiftUI

public struct MessageTranslationFooterView: View {
@ObservedObject var channelViewModel: ChatChannelViewModel
@ObservedObject var messageViewModel: MessageViewModel

@Injected(\.fonts) private var fonts
@Injected(\.colors) private var colors
@Injected(\.utils) private var utils

public init(
channelViewModel: ChatChannelViewModel,
messageViewModel: MessageViewModel
) {
self.channelViewModel = channelViewModel
self.messageViewModel = messageViewModel
}

public var body: some View {
if utils.messageListConfig.messageDisplayOptions.showOriginalTranslatedButton {
HStack(spacing: 4) {
if !messageViewModel.originalTextShown {
translatedToView
separatorView
}
showOriginalButton
}
} else {
translatedToView
}
}

private var translatedToView: some View {
Text(messageViewModel.translatedLanguageText ?? "")
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
}

private var separatorView: some View {
Text("•")
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
}

private var showOriginalButton: some View {
Button(
action: {
if messageViewModel.originalTextShown {
channelViewModel.showTranslatedText(for: messageViewModel.message)
} else {
channelViewModel.showOriginalText(for: messageViewModel.message)
}
},
label: {
Text(messageViewModel.originalTranslationButtonText)
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
}
)
}
}
Loading
Loading