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

Merged
merged 39 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ad10b1d
Introduction of ChatMessageViewModel
nuno-vieira Apr 22, 2025
ef9efc7
Add ChatChannelViewModel and ChatMessageViewModel as environment objects
nuno-vieira Apr 23, 2025
fc44f5e
Extract the Message View Model to a separate file
nuno-vieira Apr 23, 2025
5b133a5
Fix show original text not working properly
nuno-vieira Apr 23, 2025
4d56529
Extract some properties from the view to the MessageViewModel
nuno-vieira Apr 23, 2025
ab0e511
Fix MessageContainerView_Tests
nuno-vieira Apr 23, 2025
b3b00ef
Fix MessageListView_Tests
nuno-vieira Apr 23, 2025
43df718
Fix MessageListViewAvatars_Tests
nuno-vieira Apr 23, 2025
5e947ec
Use EnvironmentObject for the main container and EnvironmentValue for…
nuno-vieira May 5, 2025
bd695bf
Fix existing unit tests
nuno-vieira May 5, 2025
81f89fe
Add Stream's show original translation button + make it configurable
nuno-vieira May 5, 2025
66fdfea
Add test coverage to show original text feature
nuno-vieira May 5, 2025
f890c13
Fix reaction overlay tests
nuno-vieira May 5, 2025
ab8f758
Fix message container tests
nuno-vieira May 5, 2025
44391c2
Refactor code to avoid environment objects in MessageContainerView
nuno-vieira May 22, 2025
654de72
Provide view model by default
nuno-vieira May 22, 2025
2adc09b
Add warning about thread-safeness in the singletone store
nuno-vieira May 22, 2025
afd7e0e
Example of performing translations from message actions
nuno-vieira May 22, 2025
4584aa7
Revert "Example of performing translations from message actions"
nuno-vieira May 22, 2025
af2745e
Merge branch 'develop' into add/show-original-translated-message
nuno-vieira May 22, 2025
d5a51f4
Fix reactions overlay view not shown correct translated or original text
nuno-vieira May 22, 2025
c54da27
Open `ChatChannelViewModel.messageActionExecuted` so that it can be o…
nuno-vieira May 22, 2025
a4a2763
Rename originalTextTranslationsStore to originalTranslationsStore
nuno-vieira May 22, 2025
2aa26c9
Make the LinkDetectionTextView change wheneve the translations store …
nuno-vieira May 22, 2025
be6aa99
Clear the original translations when opening the channel
nuno-vieira May 22, 2025
c114ec5
Add TODO v5 comments
nuno-vieira May 22, 2025
eca63b1
PR Feedback
nuno-vieira May 23, 2025
a722442
Refactor using utils
nuno-vieira May 23, 2025
33520f9
Reapply "Example of performing translations from message actions"
nuno-vieira May 23, 2025
f37f114
Revert "Reapply "Example of performing translations from message acti…
nuno-vieira May 23, 2025
90fd7d9
Update CHANGELOG.md
nuno-vieira May 23, 2025
10a46e2
Fix editing message not working
nuno-vieira May 23, 2025
0042189
Revert "Fix MessageListView_Tests"
nuno-vieira May 23, 2025
8e0b1f8
Revert "Fix MessageListViewAvatars_Tests"
nuno-vieira May 23, 2025
eb69af2
Revert "Fix MessageContainerView_Tests"
nuno-vieira May 23, 2025
959962e
Revert "Fix existing unit tests"
nuno-vieira May 23, 2025
4d7aa6a
Revert "Fix message container tests"
nuno-vieira May 23, 2025
330450b
Fix message container tests
nuno-vieira May 23, 2025
862593c
Allow providing custom view models to reactions view
nuno-vieira May 23, 2025
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### ✅ Added
- Add extra data to user display info [#819](https://github.com/GetStream/stream-chat-swiftui/pull/819)
- Make message spacing in message list configurable [#830](https://github.com/GetStream/stream-chat-swiftui/pull/830)
- Add `MessageViewModel` to `MessageContainerView` to make it easier to customise presentation logic [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815)
- Add `MessageListConfig.messaeDisplayOptions.showOriginalTranslatedButton` to enable showing original text in translated message [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815)
- Add `Utils.originalTranslationsStore` to keep track of messages that should show the original text [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815)
### 🐞 Fixed
- Fix swipe to reply enabled when quoting a message is disabled [#824](https://github.com/GetStream/stream-chat-swiftui/pull/824)
- Fix mark unread action not removed when read events are disabled [#823](https://github.com/GetStream/stream-chat-swiftui/pull/823)
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

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

public var isMessageThread: Bool {
messageController != nil
}
Expand Down Expand Up @@ -158,7 +158,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 +221,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
checkHeaderType()
checkUnreadCount()
}

@objc
private func selectedMessageThread(notification: Notification) {
if let message = notification.userInfo?[MessageRepliesConstants.selectedMessage] as? ChatMessage {
Expand Down Expand Up @@ -490,14 +490,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
messageActionExecuted(.init(message: message, identifier: "edit"))
}

public func messageActionExecuted(_ messageActionInfo: MessageActionInfo) {
open func messageActionExecuted(_ messageActionInfo: MessageActionInfo) {
utils.messageActionsResolver.resolveMessageAction(
info: messageActionInfo,
viewModel: self
)
}

@objc public func onViewAppear() {
utils.originalTranslationsStore.clear()
setActive()
messages = channelDataSource.messages
firstUnreadMessageId = channelDataSource.firstUnreadMessageId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import StreamChat
import SwiftUI

public struct MessageContainerView<Factory: ViewFactory>: View {
@StateObject var messageViewModel: MessageViewModel
@Environment(\.channelTranslationLanguage) var translationLanguage

@Injected(\.fonts) private var fonts
Expand Down Expand Up @@ -41,10 +42,6 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
utils.messageListConfig.messagePaddings.groupBottom
}

var isSwipeToReplyPossible: Bool {
message.isInteractionEnabled && channel.canQuoteMessage
}

public init(
factory: Factory,
channel: ChatChannel,
Expand All @@ -55,7 +52,8 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
isLast: Bool,
scrolledId: Binding<String?>,
quotedMessage: Binding<ChatMessage?>,
onLongPress: @escaping (MessageDisplayInfo) -> Void
onLongPress: @escaping (MessageDisplayInfo) -> Void,
viewModel: MessageViewModel? = nil
) {
self.factory = factory
self.channel = channel
Expand All @@ -65,30 +63,36 @@ public struct MessageContainerView<Factory: ViewFactory>: View {
self.isInThread = isInThread
self.isLast = isLast
self.onLongPress = onLongPress
_messageViewModel = .init(
wrappedValue: viewModel ?? MessageViewModel(
message: message,
channel: channel
)
)
_scrolledId = scrolledId
_quotedMessage = quotedMessage
}

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 @@ -115,9 +119,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 @@ -143,7 +145,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 @@ -235,12 +237,12 @@ 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(
messageViewModel: messageViewModel
)
}

if showsAllInfo && !message.isDeleted {
if message.isSentByCurrentUser && channel.config.readEventsEnabled {
HStack(spacing: 4) {
Expand All @@ -249,15 +251,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 @@ -271,43 +271,43 @@ 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 : groupMessageInterItemSpacing)
.padding(.bottom, showsAllInfo || messageViewModel.isPinned ? paddingValue : groupMessageInterItemSpacing)
.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")
// This is needed for the LinkDetectionTextView to work properly.
// TODO: This should be refactored on v5 so the TextView does not depend directly on the view model.
.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 @@ -148,6 +148,7 @@ public struct MessageDisplayOptions {
public let otherUserMessageTransition: AnyTransition
public let shouldAnimateReactions: Bool
public let reactionsPlacement: ReactionsPlacement
public let showOriginalTranslatedButton: Bool
public let messageLinkDisplayResolver: (ChatMessage) -> [NSAttributedString.Key: Any]
public let spacerWidth: (CGFloat) -> CGFloat
public let reactionsTopPadding: (ChatMessage) -> CGFloat
Expand All @@ -167,6 +168,7 @@ public struct MessageDisplayOptions {
otherUserMessageTransition: AnyTransition = .identity,
shouldAnimateReactions: Bool = true,
reactionsPlacement: ReactionsPlacement = .top,
showOriginalTranslatedButton: Bool = false,
messageLinkDisplayResolver: @escaping (ChatMessage) -> [NSAttributedString.Key: Any] = MessageDisplayOptions
.defaultLinkDisplay,
spacerWidth: @escaping (CGFloat) -> CGFloat = MessageDisplayOptions.defaultSpacerWidth,
Expand All @@ -190,6 +192,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,7 +6,6 @@ import StreamChat
import SwiftUI

public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {

@Injected(\.utils) private var utils
@Injected(\.chatClient) private var chatClient
@Injected(\.colors) private var colors
Expand Down Expand Up @@ -603,6 +602,10 @@ private struct ChannelTranslationLanguageKey: EnvironmentKey {
static let defaultValue: TranslationLanguage? = nil
}

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

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

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

import StreamChat
import SwiftUI

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

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

public init(
messageViewModel: MessageViewModel
) {
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 {
messageViewModel.hideOriginalText()
} else {
messageViewModel.showOriginalText()
}
},
label: {
Text(messageViewModel.originalTranslationButtonText)
.font(fonts.footnote)
.foregroundColor(Color(colors.subtitleText))
}
)
}
}
Loading
Loading