Skip to content

Show translated text for participant messages #776

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 7 commits into from
Mar 13, 2025
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Feature rich markdown rendering with AttributedString [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
- Add `Fonts.title2` for supporting markdown headers [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
- Add `resignsFirstResponderOnScrollDown` to `MessageListConfig` [#769](https://github.com/GetStream/stream-chat-swiftui/pull/769)
- Show auto-translated message translations ([learn more](https://getstream.io/chat/docs/ios-swift/translation/#enabling-automatic-translation)) [#776](https://github.com/GetStream/stream-chat-swiftui/pull/776)
### 🔄 Changed
- Uploading a HEIC photo from the library is now converted to JPEG for better compatibility [#767](https://github.com/GetStream/stream-chat-swiftui/pull/767)
- Customizing the message avatar view is reflected in all views that use it [#772](https://github.com/GetStream/stream-chat-swiftui/pull/772)
- Made the sendMessage method in MessageComposerViewModel open [#779](https://github.com/GetStream/stream-chat-swiftui/pull/779)
- Move `ChangeBarsVisibilityModifier` into `ViewFactory` for better customization [#774](https://github.com/GetStream/stream-chat-swiftui/pull/774)
### 🎭 New Localizations
- `message.translatedTo` [#776](https://github.com/GetStream/stream-chat-swiftui/pull/776)

# [4.73.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.73.0)
_February 28, 2025_
Expand Down
14 changes: 14 additions & 0 deletions DemoAppSwiftUI/AppConfiguration/AppConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation
import StreamChat
import StreamChatSwiftUI

final class AppConfiguration {
static let `default` = AppConfiguration()

/// The translation language to set on connect.
var translationLanguage: TranslationLanguage?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import StreamChat
import SwiftUI

struct AppConfigurationTranslationView: View {
@Environment(\.dismiss) var dismiss

var selection: Binding<TranslationLanguage?> = Binding {
AppConfiguration.default.translationLanguage
} set: { newValue in
AppConfiguration.default.translationLanguage = newValue
}

var body: some View {
List {
ForEach(TranslationLanguage.all, id: \.languageCode) { language in
Button(action: {
selection.wrappedValue = language
dismiss()
}) {
HStack {
Text(language.languageCode)
Spacer()
if selection.wrappedValue == language {
Image(systemName: "checkmark")
}
}
}
.foregroundStyle(.primary)
}
.navigationTitle("Translation Language")
}
}
}

extension TranslationLanguage {
static let all = allCases.sorted(by: { $0.languageCode < $1.languageCode })
}

#Preview {
NavigationView {
AppConfigurationTranslationView()
}
}
26 changes: 26 additions & 0 deletions DemoAppSwiftUI/AppConfiguration/AppConfigurationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Combine
import SwiftUI

struct AppConfigurationView: View {
var body: some View {
NavigationView {
List {
Section("Connect User Configuration") {
NavigationLink("Translation") {
AppConfigurationTranslationView()
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("App Configuration")
}
}
}

#Preview {
AppConfigurationView()
}
3 changes: 2 additions & 1 deletion DemoAppSwiftUI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
userInfo: .init(
id: credentials.id,
name: credentials.name,
imageURL: credentials.avatarURL
imageURL: credentials.avatarURL,
language: AppConfiguration.default.translationLanguage
),
token: token
)
Expand Down
8 changes: 8 additions & 0 deletions DemoAppSwiftUI/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ struct LoginView: View {
Text("Welcome to Stream Chat")
.font(.title)
.padding(.all, 8)

Button("Configuration") {
viewModel.showsConfiguration = true
}
.buttonStyle(.borderedProminent)

Text("Select a user to try the iOS SDK:")
.font(.body)
Expand All @@ -42,6 +47,9 @@ struct LoginView: View {
.overlay(
viewModel.loading ? ProgressView() : nil
)
.sheet(isPresented: $viewModel.showsConfiguration) {
AppConfigurationView()
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion DemoAppSwiftUI/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class LoginViewModel: ObservableObject {

@Published var demoUsers = UserCredentials.builtInUsers
@Published var loading = false
@Published var showsConfiguration = false

@Injected(\.chatClient) var chatClient

Expand All @@ -28,7 +29,12 @@ class LoginViewModel: ObservableObject {
LogConfig.level = .warning

chatClient.connectUser(
userInfo: .init(id: credentials.id, name: credentials.name, imageURL: credentials.avatarURL),
userInfo: .init(
id: credentials.id,
name: credentials.name,
imageURL: credentials.avatarURL,
language: AppConfiguration.default.translationLanguage
),
token: token
) { [weak self] error in
if let error = error {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@ struct PinnedMessageView<Factory: ViewFactory>: View {
return "📊 \(L10n.Channel.Item.poll)"
}
let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter
return messageFormatter.formatAttachmentContent(for: message) ?? message.adjustedText
return messageFormatter.formatAttachmentContent(for: message, in: channel) ?? message.adjustedText
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ public struct ComposerInputView<Factory: ViewFactory>: View, KeyboardReadable {
isInComposer: true,
scrolledId: .constant(nil)
)
.environment(\.channelTranslationLanguage, viewModel.channelController.channel?.membership?.language)
Copy link
Contributor

Choose a reason for hiding this comment

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

Customers might have their own message composer view implementation. Just to make sure, if we access it @Environment(\.channelTranslationLanguage) var translationLanguage, but this line is not called, it won't crash, because it's optional, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, then it uses the the default value for the key which is nil

}

if !addedAssets.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import StreamChat
import SwiftUI

public struct MessageContainerView<Factory: ViewFactory>: View {

@Environment(\.channelTranslationLanguage) var translationLanguage

@Injected(\.fonts) private var fonts
@Injected(\.colors) private var colors
@Injected(\.images) private var images
Expand Down Expand Up @@ -225,6 +226,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 showsAllInfo && !message.isDeleted {
if message.isSentByCurrentUser && channel.config.readEventsEnabled {
HStack(spacing: 4) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
onLongPress: handleLongPress(messageDisplayInfo:),
isLast: !showsLastInGroupInfo && message == messages.last
)
.environment(\.channelTranslationLanguage, channel.membership?.language)
.onAppear {
if index == nil {
index = messageListDateUtils.index(for: message, in: messages)
Expand Down Expand Up @@ -597,3 +598,18 @@ private class MessageRenderingUtil {
return skipRendering
}
}

private struct ChannelTranslationLanguageKey: EnvironmentKey {
static let defaultValue: TranslationLanguage? = nil
}

extension EnvironmentValues {
var channelTranslationLanguage: TranslationLanguage? {
get {
self[ChannelTranslationLanguageKey.self]
}
set {
self[ChannelTranslationLanguageKey.self] = newValue
}
}
}
Comment on lines +602 to +615
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Channel data is not available in the message's text view. Interfaces are public so no easy way to forward this information with function arguments. Next best way is to use environment key for this.

Note: MessageContainerView is the last one to have access to channel.

Copy link
Contributor

Choose a reason for hiding this comment

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

V5 is a good option to improve this.

Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ struct StreamTextView: View {
@available(iOS 15, *)
public struct LinkDetectionTextView: View {
@Environment(\.layoutDirection) var layoutDirection
@Environment(\.channelTranslationLanguage) var translationLanguage

@Injected(\.colors) var colors
@Injected(\.fonts) var fonts
Expand Down Expand Up @@ -292,8 +293,12 @@ public struct LinkDetectionTextView: View {
}

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

// Translation
if let translatedText = message.textContent(for: translationLanguage) {
text = translatedText
}
// Markdown
let attributes = AttributeContainer()
.foregroundColor(textColor(for: message))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ struct QuotedMessageViewContainer<Factory: ViewFactory>: View {

/// View for the quoted message.
public struct QuotedMessageView<Factory: ViewFactory>: View {
@Environment(\.channelTranslationLanguage) var translationLanguage

@Injected(\.images) private var images
@Injected(\.fonts) private var fonts
Expand Down Expand Up @@ -178,7 +179,9 @@ public struct QuotedMessageView<Factory: ViewFactory>: View {
}

private var textForMessage: String {
let textContent = quotedMessage.textContent ?? ""
let translatedTextContent = quotedMessage.textContent(for: translationLanguage)
let textContent = translatedTextContent ?? quotedMessage.textContent ?? ""

if !textContent.isEmpty {
return textContent
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,13 @@ extension ChatChannel {
public var previewMessageText: String? {
guard let previewMessage else { return nil }
let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter
return messageFormatter.format(previewMessage)
return messageFormatter.format(previewMessage, in: self)
}

public var lastMessageText: String? {
guard let latestMessage = latestMessages.first else { return nil }
let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter
return messageFormatter.format(latestMessage)
return messageFormatter.format(latestMessage, in: self)
}

public var shouldShowTypingIndicator: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ struct SearchResultItem<Factory: ViewFactory, ChannelDestination: View>: View {
guard let previewMessage = searchResult.message else {
return L10n.Channel.Item.emptyMessages
}
return utils.messagePreviewFormatter.format(previewMessage)
return utils.messagePreviewFormatter.format(previewMessage, in: searchResult.channel)
case .messages:
return searchResult.message?.text ?? ""
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public struct ChatThreadListItemViewModel {
parentMessageText = threadTitle
} else {
let formatter = InjectedValues[\.utils].messagePreviewFormatter
parentMessageText = formatter.formatContent(for: thread.parentMessage)
parentMessageText = formatter.formatContent(for: thread.parentMessage, in: thread.channel)
}
return L10n.Thread.Item.repliedTo(parentMessageText.trimmed)
}
Expand All @@ -67,7 +67,7 @@ public struct ChatThreadListItemViewModel {
}

let formatter = InjectedValues[\.utils].messagePreviewFormatter
return formatter.format(latestReply)
return formatter.format(latestReply, in: thread.channel)
}

/// The formatted latest reply timestamp.
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChatSwiftUI/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ internal enum L10n {
internal static var deletedMessagePlaceholder: String { L10n.tr("Localizable", "message.deleted-message-placeholder") }
/// Only visible to you
internal static var onlyVisibleToYou: String { L10n.tr("Localizable", "message.only-visible-to-you") }
/// Translated to %@
internal static func translatedTo(_ p1: Any) -> String {
return L10n.tr("Localizable", "message.translatedTo", String(describing: p1))
}
internal enum Actions {
/// Copy Message
internal static var copy: String { L10n.tr("Localizable", "message.actions.copy") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@
"message.polls.unknown-vote-author" = "Anonymous";
"message.polls.votes" = "%d votes";

"message.translatedTo" = "Translated to %@";

"alert.actions.cancel" = "Cancel";
"alert.actions.delete" = "Delete";
"alert.actions.end" = "End";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public extension ChatMessage {

return isDeleted ? L10n.Message.deletedMessagePlaceholder : adjustedText
}

func textContent(for translationLanguage: TranslationLanguage?) -> String? {
guard let translationLanguage else { return nil }
guard !isSentByCurrentUser, !isDeleted else { return nil }
return translatedText(for: translationLanguage)
}
Comment on lines +50 to +54
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Convenience since similar logic is used in multiple places


/// A boolean value that checks if the message is visible for current user only.
var isOnlyVisibleForCurrentUser: Bool {
Expand Down Expand Up @@ -95,3 +101,9 @@ public extension ChatMessage {
return isSentByCurrentUser
}
}

extension TranslationLanguage {
var localizedName: String? {
Locale.current.localizedString(forLanguageCode: languageCode)
}
}
14 changes: 7 additions & 7 deletions Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,33 @@ struct MessagePreviewFormatter {
init() {}

/// Formats the message including the author's name.
func format(_ previewMessage: ChatMessage) -> String {
func format(_ previewMessage: ChatMessage, in channel: ChatChannel) -> String {
if let poll = previewMessage.poll {
return formatPoll(poll)
}
return "\(previewMessage.author.name ?? previewMessage.author.id): \(formatContent(for: previewMessage))"
return "\(previewMessage.author.name ?? previewMessage.author.id): \(formatContent(for: previewMessage, in: channel))"
}

/// Formats only the content of the message without the author's name.
func formatContent(for previewMessage: ChatMessage) -> String {
if let attachmentPreviewText = formatAttachmentContent(for: previewMessage) {
func formatContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String {
if let attachmentPreviewText = formatAttachmentContent(for: previewMessage, in: channel) {
return attachmentPreviewText
}
if let textContent = previewMessage.textContent, !textContent.isEmpty {
if let textContent = previewMessage.textContent(for: channel.membership?.language), !textContent.isEmpty {
return textContent
}
return previewMessage.adjustedText
}

/// Formats only the attachment content of the message in case it contains attachments.
func formatAttachmentContent(for previewMessage: ChatMessage) -> String? {
func formatAttachmentContent(for previewMessage: ChatMessage, in channel: ChatChannel) -> String? {
if let poll = previewMessage.poll {
return "📊 \(poll.name)"
}
guard let attachment = previewMessage.allAttachments.first, !previewMessage.isDeleted else {
return nil
}
let text = previewMessage.textContent ?? previewMessage.text
let text = previewMessage.textContent(for: channel.membership?.language) ?? previewMessage.text
switch attachment.type {
case .audio:
let defaultAudioText = L10n.Channel.Item.audio
Expand Down
Loading
Loading