Show room encryption state in the composer (#3841)

* Fixes #3835 - Show room encryption state in the composer

* Update the preview test snapshots

* Update the UI test snapshots.
This commit is contained in:
Stefan Ceriu 2025-02-27 22:16:26 +02:00 committed by GitHub
parent 8648f55b5d
commit 890687512f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
92 changed files with 258 additions and 180 deletions

View File

@ -353,6 +353,7 @@
"rich_text_editor_bullet_list" = "Toggle bullet list"; "rich_text_editor_bullet_list" = "Toggle bullet list";
"rich_text_editor_close_formatting_options" = "Close formatting options"; "rich_text_editor_close_formatting_options" = "Close formatting options";
"rich_text_editor_code_block" = "Toggle code block"; "rich_text_editor_code_block" = "Toggle code block";
"rich_text_editor_composer_encrypted_placeholder" = "Encrypted message…";
"rich_text_editor_composer_placeholder" = "Message…"; "rich_text_editor_composer_placeholder" = "Message…";
"rich_text_editor_create_link" = "Create a link"; "rich_text_editor_create_link" = "Create a link";
"rich_text_editor_edit_link" = "Edit link"; "rich_text_editor_edit_link" = "Edit link";
@ -479,9 +480,15 @@
"screen_security_and_privacy_room_publishing_section_header" = "Room publishing"; "screen_security_and_privacy_room_publishing_section_header" = "Room publishing";
"screen_security_and_privacy_room_visibility_section_footer" = "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.\nThe address is also required to make the room visible in %1$@ public room directory."; "screen_security_and_privacy_room_visibility_section_footer" = "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.\nThe address is also required to make the room visible in %1$@ public room directory.";
"screen_security_and_privacy_title" = "Security & privacy"; "screen_security_and_privacy_title" = "Security & privacy";
"screen_start_chat_join_room_by_address_invalid_address" = "Not a valid address";
"screen_start_chat_join_room_by_address_placeholder" = "Enter...";
"screen_start_chat_join_room_by_address_room_found" = "Matching room found";
"screen_start_chat_join_room_by_address_room_not_found" = "Room not found";
"screen_start_chat_join_room_by_address_supporting_text" = "e.g. #room-name:matrix.org";
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed."; "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed.";
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
"screen.start_chat.join_room_by_address_action" = "Join room by address";
"screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_hint" = "Homeserver address";
"screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_notice" = "Enter a search term or a domain address.";
"screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server.";

View File

@ -353,6 +353,7 @@
"rich_text_editor_bullet_list" = "Toggle bullet list"; "rich_text_editor_bullet_list" = "Toggle bullet list";
"rich_text_editor_close_formatting_options" = "Close formatting options"; "rich_text_editor_close_formatting_options" = "Close formatting options";
"rich_text_editor_code_block" = "Toggle code block"; "rich_text_editor_code_block" = "Toggle code block";
"rich_text_editor_composer_encrypted_placeholder" = "Encrypted message…";
"rich_text_editor_composer_placeholder" = "Message…"; "rich_text_editor_composer_placeholder" = "Message…";
"rich_text_editor_create_link" = "Create a link"; "rich_text_editor_create_link" = "Create a link";
"rich_text_editor_edit_link" = "Edit link"; "rich_text_editor_edit_link" = "Edit link";
@ -479,9 +480,15 @@
"screen_security_and_privacy_room_publishing_section_header" = "Room publishing"; "screen_security_and_privacy_room_publishing_section_header" = "Room publishing";
"screen_security_and_privacy_room_visibility_section_footer" = "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.\nThe address is also required to make the room visible in %1$@ public room directory."; "screen_security_and_privacy_room_visibility_section_footer" = "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.\nThe address is also required to make the room visible in %1$@ public room directory.";
"screen_security_and_privacy_title" = "Security & privacy"; "screen_security_and_privacy_title" = "Security & privacy";
"screen_start_chat_join_room_by_address_invalid_address" = "Not a valid address";
"screen_start_chat_join_room_by_address_placeholder" = "Enter...";
"screen_start_chat_join_room_by_address_room_found" = "Matching room found";
"screen_start_chat_join_room_by_address_room_not_found" = "Room not found";
"screen_start_chat_join_room_by_address_supporting_text" = "e.g. #room-name:matrix.org";
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed."; "screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed.";
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices."; "screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices."; "screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
"screen.start_chat.join_room_by_address_action" = "Join room by address";
"screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_hint" = "Homeserver address";
"screen_account_provider_form_notice" = "Enter a search term or a domain address."; "screen_account_provider_form_notice" = "Enter a search term or a domain address.";
"screen_account_provider_form_subtitle" = "Search for a company, community, or private server."; "screen_account_provider_form_subtitle" = "Search for a company, community, or private server.";

View File

@ -846,6 +846,8 @@ internal enum L10n {
internal static var richTextEditorCodeBlock: String { return L10n.tr("Localizable", "rich_text_editor_code_block") } internal static var richTextEditorCodeBlock: String { return L10n.tr("Localizable", "rich_text_editor_code_block") }
/// Add a caption /// Add a caption
internal static var richTextEditorComposerCaptionPlaceholder: String { return L10n.tr("Localizable", "rich_text_editor_composer_caption_placeholder") } internal static var richTextEditorComposerCaptionPlaceholder: String { return L10n.tr("Localizable", "rich_text_editor_composer_caption_placeholder") }
/// Encrypted message
internal static var richTextEditorComposerEncryptedPlaceholder: String { return L10n.tr("Localizable", "rich_text_editor_composer_encrypted_placeholder") }
/// Message /// Message
internal static var richTextEditorComposerPlaceholder: String { return L10n.tr("Localizable", "rich_text_editor_composer_placeholder") } internal static var richTextEditorComposerPlaceholder: String { return L10n.tr("Localizable", "rich_text_editor_composer_placeholder") }
/// Create a link /// Create a link
@ -2458,6 +2460,16 @@ internal enum L10n {
internal static var screenSignoutSaveRecoveryKeyTitle: String { return L10n.tr("Localizable", "screen_signout_save_recovery_key_title") } internal static var screenSignoutSaveRecoveryKeyTitle: String { return L10n.tr("Localizable", "screen_signout_save_recovery_key_title") }
/// An error occurred when trying to start a chat /// An error occurred when trying to start a chat
internal static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") } internal static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") }
/// Not a valid address
internal static var screenStartChatJoinRoomByAddressInvalidAddress: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_invalid_address") }
/// Enter...
internal static var screenStartChatJoinRoomByAddressPlaceholder: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_placeholder") }
/// Matching room found
internal static var screenStartChatJoinRoomByAddressRoomFound: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_room_found") }
/// Room not found
internal static var screenStartChatJoinRoomByAddressRoomNotFound: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_room_not_found") }
/// e.g. #room-name:matrix.org
internal static var screenStartChatJoinRoomByAddressSupportingText: String { return L10n.tr("Localizable", "screen_start_chat_join_room_by_address_supporting_text") }
/// Message not sent because %1$@s verified identity has changed. /// Message not sent because %1$@s verified identity has changed.
internal static func screenTimelineItemMenuSendFailureChangedIdentity(_ p1: Any) -> String { internal static func screenTimelineItemMenuSendFailureChangedIdentity(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_timeline_item_menu_send_failure_changed_identity", String(describing: p1)) return L10n.tr("Localizable", "screen_timeline_item_menu_send_failure_changed_identity", String(describing: p1))
@ -2862,6 +2874,13 @@ internal enum L10n {
/// You /// You
internal static var you: String { return L10n.tr("Localizable", "common.you") } internal static var you: String { return L10n.tr("Localizable", "common.you") }
} }
internal enum Screen {
internal enum StartChat {
/// Join room by address
internal static var joinRoomByAddressAction: String { return L10n.tr("Localizable", "screen.start_chat.join_room_by_address_action") }
}
}
} }
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces

View File

@ -15,6 +15,7 @@ struct MediaUploadPreviewScreenViewState: BindableState {
let url: URL let url: URL
let title: String? let title: String?
let shouldShowCaptionWarning: Bool let shouldShowCaptionWarning: Bool
let isRoomEncrypted: Bool
var shouldDisableInteraction = false var shouldDisableInteraction = false
var bindings = MediaUploadPreviewScreenBindings() var bindings = MediaUploadPreviewScreenBindings()

View File

@ -40,7 +40,10 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
// Start processing the media whilst the user is reviewing it/adding a caption. // Start processing the media whilst the user is reviewing it/adding a caption.
processingTask = Task { await mediaUploadingPreprocessor.processMedia(at: url) } processingTask = Task { await mediaUploadingPreprocessor.processMedia(at: url) }
super.init(initialViewState: MediaUploadPreviewScreenViewState(url: url, title: title, shouldShowCaptionWarning: shouldShowCaptionWarning)) super.init(initialViewState: MediaUploadPreviewScreenViewState(url: url,
title: title,
shouldShowCaptionWarning: shouldShowCaptionWarning,
isRoomEncrypted: roomProxy.isEncrypted))
} }
override func process(viewAction: MediaUploadPreviewScreenViewAction) { override func process(viewAction: MediaUploadPreviewScreenViewAction) {

View File

@ -69,7 +69,7 @@ struct MediaUploadPreviewScreen: View {
captionWarningButton captionWarningButton
} }
} }
.messageComposerStyle() .messageComposerStyle(isEncrypted: context.viewState.isRoomEncrypted)
SendButton { SendButton {
context.send(viewAction: .send) context.send(viewAction: .send)
@ -228,7 +228,7 @@ struct MediaUploadPreviewScreen_Previews: PreviewProvider, TestablePreview {
static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png") static let testURL = Bundle.main.url(forResource: "AppIcon60x60@2x", withExtension: "png")
static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock.default, static let viewModel = MediaUploadPreviewScreenViewModel(userIndicatorController: UserIndicatorControllerMock.default,
roomProxy: JoinedRoomProxyMock(), roomProxy: JoinedRoomProxyMock(.init()),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: ServiceLocator.shared.settings),
title: "App Icon.png", title: "App Icon.png",
url: snapshotURL, url: snapshotURL,

View File

@ -73,6 +73,8 @@ struct ComposerToolbarViewState: BindableState {
var audioPlayerState: AudioPlayerState var audioPlayerState: AudioPlayerState
var audioRecorderState: AudioRecorderState var audioRecorderState: AudioRecorderState
let isRoomEncrypted: Bool
var bindings: ComposerToolbarViewStateBindings var bindings: ComposerToolbarViewStateBindings
var isUploading: Bool { var isUploading: Bool {

View File

@ -66,6 +66,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
title: L10n.commonVoiceMessage, title: L10n.commonVoiceMessage,
duration: 0), duration: 0),
audioRecorderState: .init(), audioRecorderState: .init(),
isRoomEncrypted: roomProxy.isEncrypted,
bindings: .init()), bindings: .init()),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)

View File

@ -157,9 +157,11 @@ struct ComposerToolbar: View {
selectedRange: $context.selectedRange, selectedRange: $context.selectedRange,
composerView: composerView, composerView: composerView,
mode: context.viewState.composerMode, mode: context.viewState.composerMode,
placeholder: placeholder,
composerFormattingEnabled: context.composerFormattingEnabled, composerFormattingEnabled: context.composerFormattingEnabled,
showResizeGrabber: context.composerFormattingEnabled, showResizeGrabber: context.composerFormattingEnabled,
isExpanded: $context.composerExpanded) { isExpanded: $context.composerExpanded,
isEncrypted: context.viewState.isRoomEncrypted) {
sendMessage() sendMessage()
} editAction: { } editAction: {
context.send(viewAction: .editLastMessage) context.send(viewAction: .editLastMessage)
@ -222,8 +224,16 @@ struct ComposerToolbar: View {
private var placeholder: String { private var placeholder: String {
switch context.viewState.composerMode { switch context.viewState.composerMode {
case .reply(_, _, let isThread): case .reply(_, _, let isThread):
return isThread ? L10n.actionReplyInThread : L10n.richTextEditorComposerPlaceholder return isThread ? L10n.actionReplyInThread : composerPlaceholder
default: default:
return composerPlaceholder
}
}
private var composerPlaceholder: String {
if context.viewState.isRoomEncrypted {
return L10n.richTextEditorComposerEncryptedPlaceholder
} else {
return L10n.richTextEditorComposerPlaceholder return L10n.richTextEditorComposerPlaceholder
} }
} }

View File

@ -16,11 +16,16 @@ struct MessageComposer: View {
@Binding var plainComposerText: NSAttributedString @Binding var plainComposerText: NSAttributedString
@Binding var presendCallback: (() -> Void)? @Binding var presendCallback: (() -> Void)?
@Binding var selectedRange: NSRange @Binding var selectedRange: NSRange
let composerView: WysiwygComposerView let composerView: WysiwygComposerView
let mode: ComposerMode let mode: ComposerMode
let placeholder: String
let composerFormattingEnabled: Bool let composerFormattingEnabled: Bool
let showResizeGrabber: Bool let showResizeGrabber: Bool
@Binding var isExpanded: Bool @Binding var isExpanded: Bool
let isEncrypted: Bool
let sendAction: () -> Void let sendAction: () -> Void
let editAction: () -> Void let editAction: () -> Void
let pasteAction: PasteHandler let pasteAction: PasteHandler
@ -37,7 +42,7 @@ struct MessageComposer: View {
} }
composerTextField composerTextField
.messageComposerStyle(header: header) .messageComposerStyle(header: header, isEncrypted: isEncrypted)
// Explicitly disable all animations to fix weirdness with the header immediately // Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it. // appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: mode) .animation(.noAnimation, value: mode)
@ -64,7 +69,7 @@ struct MessageComposer: View {
onAppearAction() onAppearAction()
} }
} else { } else {
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder, MessageComposerTextField(placeholder: placeholder,
text: $plainComposerText, text: $plainComposerText,
presendCallback: $presendCallback, presendCallback: $presendCallback,
selectedRange: $selectedRange, selectedRange: $selectedRange,
@ -195,8 +200,8 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
// MARK: - Style // MARK: - Style
extension View { extension View {
func messageComposerStyle(header: some View = EmptyView()) -> some View { func messageComposerStyle(header: some View = EmptyView(), isEncrypted: Bool) -> some View {
modifier(MessageComposerStyleModifier(header: header)) modifier(MessageComposerStyleModifier(header: header, isEncrypted: isEncrypted))
} }
} }
@ -204,13 +209,19 @@ private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular) private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
let header: Header let header: Header
let isEncrypted: Bool
func body(content: Content) -> some View { func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) { VStack(alignment: .leading, spacing: -6) {
header header
HStack(alignment: .top, spacing: 6) {
icon
.scaledOffset(y: 2)
content content
.tint(.compound.iconAccentTertiary) .tint(.compound.iconAccentTertiary)
}
.padding(.vertical, 10) .padding(.vertical, 10)
} }
.padding(.horizontal, 12.0) .padding(.horizontal, 12.0)
@ -224,6 +235,17 @@ private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
} }
} }
} }
@ViewBuilder
private var icon: some View {
if isEncrypted {
CompoundIcon(\.lockSolid, size: .xSmall, relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconSuccessPrimary)
} else {
CompoundIcon(\.lockOff, size: .xSmall, relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconTertiary)
}
}
} }
// MARK: - Previews // MARK: - Previews
@ -274,7 +296,8 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
] ]
static func messageComposer(_ content: NSAttributedString = .init(string: ""), static func messageComposer(_ content: NSAttributedString = .init(string: ""),
mode: ComposerMode = .default) -> MessageComposer { mode: ComposerMode = .default,
placeholder: String = L10n.richTextEditorComposerEncryptedPlaceholder) -> MessageComposer {
let viewModel = WysiwygComposerViewModel(minHeight: 22, let viewModel = WysiwygComposerViewModel(minHeight: 22,
maxExpandedHeight: 250) maxExpandedHeight: 250)
viewModel.setMarkdownContent(content.string) viewModel.setMarkdownContent(content.string)
@ -290,9 +313,11 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
selectedRange: .constant(NSRange(location: 0, length: 0)), selectedRange: .constant(NSRange(location: 0, length: 0)),
composerView: composerView, composerView: composerView,
mode: mode, mode: mode,
placeholder: placeholder,
composerFormattingEnabled: false, composerFormattingEnabled: false,
showResizeGrabber: false, showResizeGrabber: false,
isExpanded: .constant(false), isExpanded: .constant(false),
isEncrypted: false,
sendAction: { }, sendAction: { },
editAction: { }, editAction: { },
pasteAction: { _ in }, pasteAction: { _ in },
@ -307,14 +332,16 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
messageComposer(.init(string: "Some message"), messageComposer(.init(string: "Some message"),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default)) mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))
let longMessage = "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic."
messageComposer(.init(string: longMessage),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default))
messageComposer(mode: .reply(eventID: UUID().uuidString, messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: .loaded(sender: .init(id: "Kirk"), replyDetails: .loaded(sender: .init(id: "Kirk"),
eventID: "123", eventID: "123",
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
isThread: false)) isThread: false))
Color.clear.frame(height: 20)
messageComposer(.init(string: "Some new caption"), messageComposer(.init(string: "Some new caption"),
mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .addCaption)) mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .addCaption))
messageComposer(.init(string: "Some updated caption"), messageComposer(.init(string: "Some updated caption"),
@ -338,7 +365,8 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
VStack(spacing: 8) { VStack(spacing: 8) {
ForEach(replyTypes, id: \.self) { replyDetails in ForEach(replyTypes, id: \.self) { replyDetails in
messageComposer(mode: .reply(eventID: UUID().uuidString, messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: replyDetails, isThread: true)) replyDetails: replyDetails, isThread: true),
placeholder: L10n.actionReplyInThread)
} }
} }
} }