Update Send button bg color (#5170)

* Update Send button bg color

Change the gradient bg to accent/rest.

* Tidy-up ComposerToolbar to match iOS 18 Figma.

Also simplifies the tests a bit.

* Add a .glassEffect to Compound's SendButton.

* Add a border to TimelineReplyView.

Also use the same sizes in both the message bubbles and the composer.

* Change icon size and container in message bubbles

- Container size = 36x36px
- Icon size = 24x24px

* Update icon of reply contents to be 24x24

* Update the VoiceMessageButton to match the designs.

* Adopt Liquid Glass in the ComposerToolbar.

* Generate and fix snapshots.

---------

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Aaron Thornburgh
2026-03-25 18:42:10 +01:00
committed by GitHub
parent a2481b59ba
commit 96b6d69d5a
181 changed files with 790 additions and 719 deletions
@@ -109,6 +109,14 @@ struct ComposerToolbarViewState: BindableState {
}
}
var sendButtonMode: SendButton.Mode {
composerMode.isEdit ? .edit : .send
}
var sendButtonAccessibilityLabel: String {
composerMode.isEdit ? L10n.actionConfirm : L10n.actionSend
}
var sendButtonDisabled: Bool {
if !canSend {
return true
@@ -797,6 +797,69 @@ private final class ComposerMentionReplacer: MentionReplacer {
}
}
// MARK: - Mocks
extension ComposerToolbarViewModel {
enum MockMode { case editing, recordVoiceMessage, previewVoiceMessage(isUploading: Bool), reply(isLoading: Bool) }
static func mock(focused: Bool = false,
message: String = "",
mockMode: MockMode? = nil,
hasSuggestions: Bool = false,
canSend: Bool = true) -> ComposerToolbarViewModel {
let suggestions: [SuggestionItem] = if hasSuggestions {
[.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""),
.init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar)), range: .init(), rawSuggestionText: "")]
} else {
[]
}
let roomProxy = JoinedRoomProxyMock(.init())
if !canSend {
roomProxy.identityStatusChangesPublisher = .init([.init(userId: RoomMemberProxyMock.mockAlice.userID, changedTo: .verificationViolation)])
}
let wysiwygViewModel = WysiwygComposerViewModel()
let viewModel = ComposerToolbarViewModel(roomProxy: roomProxy,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
viewModel.state.bindings.composerFocused = focused
viewModel.state.bindings.plainComposerText = NSAttributedString(string: message)
switch mockMode {
case .editing:
viewModel.state.composerMode = .edit(originalEventOrTransactionID: .eventID(""), type: .default)
case .recordVoiceMessage:
viewModel.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
case .previewVoiceMessage(let isUploading):
viewModel.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview,
title: L10n.commonVoiceMessage,
duration: 10.0),
waveform: .data(Array(repeating: 1.0, count: 1000)),
isUploading: isUploading)
case .reply(let isLoading):
let replyDetails: TimelineItemReplyDetails = if isLoading {
.loading(eventID: "")
} else {
.loaded(sender: .init(id: "", displayName: "Test"),
eventID: "",
eventContent: .message(.text(.init(body: "Hello World!"))))
}
viewModel.state.composerMode = .reply(eventID: UUID().uuidString, replyDetails: replyDetails, isThread: false)
case nil:
break
}
return viewModel
}
}
private struct PlainComposerContent {
let text: String
let mentionedUserIDs: Set<String>
@@ -13,10 +13,27 @@ import SwiftUI
import WysiwygComposer
struct ComposerToolbar: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject var context: ComposerToolbarViewModel.Context
@FocusState private var composerFocused: Bool
@State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// - When Liquid Glass is available, the buttons and composer are all 44pt x 44pt.
/// - On iOS 18 and below, the main buttons are 30pt x 30pt and the composer is 42pt high, so some
/// additional padding is required to centre the buttons vertically when there's a single line of text,
/// but preserve their position (using bottom alignment) when there's 2 or more lines of text.
private var buttonVerticalPadding: CGFloat {
Compound.supportsGlass ? 0 : 6
}
/// - When Liquid Glass is available, the buttons and composer are all 44pt x 44pt.
/// - On iOS 18 and below, the trailing button is 36pt x 36pt and the composer is 42pt high, so some
/// additional padding is required to centre the button (and maintain alignment as described above).
private var trailingButtonVerticalPadding: CGFloat {
Compound.supportsGlass ? 0 : 3
}
var body: some View {
VStack(spacing: 8) {
@@ -32,8 +49,8 @@ struct ComposerToolbar: View {
bottomBar
}
}
.padding(.leading, 5)
.padding(.trailing, 8)
.padding(.leading, 12)
.padding(.trailing, 12)
.padding(.bottom, context.composerFormattingEnabled ? 8 : 12)
.background {
if context.composerFormattingEnabled {
@@ -83,14 +100,14 @@ struct ComposerToolbar: View {
if !context.composerFormattingEnabled {
if context.viewState.isUploading {
ProgressView()
.scaledFrame(size: 44, relativeTo: .compound.headingLG)
.padding(.leading, 3)
.scaledFrame(size: Compound.supportsGlass ? 44 : 36, relativeTo: .compound.headingLG)
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
} else if context.viewState.showSendButton {
sendButton
.padding(.leading, 3)
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
} else {
voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
.padding(.leading, 3)
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
}
}
}
@@ -98,20 +115,20 @@ struct ComposerToolbar: View {
}
private var bottomBar: some View {
HStack(alignment: .center, spacing: 9) {
HStack(alignment: .center, spacing: 4) {
closeRTEButton
FormattingToolbar(formatItems: context.formatItems) { action in
context.send(viewAction: .composerAction(action: action.composerAction))
}
.padding(.horizontal, 5)
sendButton
.padding(.leading, 7)
}
}
private var topBarLayout: some Layout {
HStackLayout(alignment: .bottom, spacing: 5)
HStackLayout(alignment: .bottom, spacing: 12)
}
private var mainTopBarContent: some View {
@@ -119,6 +136,7 @@ struct ComposerToolbar: View {
topBarLayout {
if !context.composerFormattingEnabled {
RoomAttachmentPicker(context: context)
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
}
messageComposer
}
@@ -136,36 +154,22 @@ struct ComposerToolbar: View {
context.composerFormattingEnabled = false
context.composerExpanded = false
} label: {
Image(Asset.Images.closeRte.name)
.resizable()
.scaledToFit()
.scaledFrame(size: 30, relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .compound.headingLG)
CompoundIcon(\.close,
size: Compound.supportsGlass ? .medium : .small,
relativeTo: .compound.headingLG)
}
.buttonStyle(ComposerToolbarButtonStyle())
.accessibilityLabel(L10n.richTextEditorCloseFormattingOptions)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
}
private var sendButton: some View {
Group {
if context.viewState.composerMode.isEdit {
Button(action: sendMessage) {
CompoundIcon(\.check, size: .medium, relativeTo: .compound.headingLG)
.foregroundColor(.white)
.scaledPadding(6, relativeTo: .compound.headingLG)
.background(.compound.iconAccentTertiary, in: Circle())
.accessibilityLabel(L10n.actionConfirm)
}
} else {
SendButton(action: sendMessage)
.accessibilityLabel(L10n.actionSend)
}
}
.scaledPadding(4, relativeTo: .compound.headingLG)
.disabled(context.viewState.sendButtonDisabled)
.animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.accessibilityIdentifier(A11yIdentifiers.roomScreen.sendButton)
SendButton(mode: context.viewState.sendButtonMode, action: sendMessage)
.accessibilityLabel(context.viewState.sendButtonAccessibilityLabel)
.disabled(context.viewState.sendButtonDisabled)
.animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled)
.keyboardShortcut(.return, modifiers: [.command])
.accessibilityIdentifier(A11yIdentifiers.roomScreen.sendButton)
}
private var messageComposer: some View {
@@ -200,8 +204,6 @@ struct ComposerToolbar: View {
}
.environmentObject(context)
.focused($composerFocused)
.padding(.leading, context.composerFormattingEnabled ? 7 : 0)
.padding(.trailing, context.composerFormattingEnabled ? 4 : 0)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.messageComposer)
.onTapGesture {
guard !composerFocused else { return }
@@ -278,11 +280,13 @@ struct ComposerToolbar: View {
case .recordVoiceMessage(let state):
topBarLayout {
voiceMessageTrashButton
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
VoiceMessageRecordingComposer(recorderState: state)
}
case .previewVoiceMessage(let state, let waveform, let isUploading):
topBarLayout {
voiceMessageTrashButton
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
}
.disabled(isUploading)
@@ -300,15 +304,9 @@ struct ComposerToolbar: View {
}
private var voiceMessageTrashButton: some View {
Button(role: .destructive) {
VoiceMessageTrashButton {
context.send(viewAction: .voiceMessage(.deleteRecording))
} label: {
CompoundIcon(\.delete)
.scaledToFit()
.scaledFrame(size: 30, relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .compound.headingLG)
}
.buttonStyle(.compound(.textLink))
.accessibilityLabel(L10n.a11yDelete)
}
@@ -325,168 +323,103 @@ struct ComposerToolbar: View {
}
}
struct ComposerToolbarButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
if #available(iOS 26, *) {
configuration.label
.modifier(GlassStyle())
} else {
configuration.label
.modifier(FlatStyle(isPressed: configuration.isPressed))
}
}
@available(iOS 26, *)
private struct GlassStyle: ViewModifier {
@Environment(\.isEnabled) private var isEnabled
func body(content: Content) -> some View {
if isEnabled {
label(content: content)
.snapshotableGlassEffect(.regular.interactive(),
snapshotBackground: .compound.bgSubtleSecondary,
in: .circle)
} else {
label(content: content)
.background(.compound.bgSubtlePrimary, in: .circle)
}
}
func label(content: Content) -> some View {
content
.foregroundStyle(isEnabled ? .compound.iconPrimary : .compound.iconDisabled)
.scaledPadding(10, relativeTo: .compound.headingLG)
}
}
private struct FlatStyle: ViewModifier {
@Environment(\.isEnabled) private var isEnabled
let isPressed: Bool
func body(content: Content) -> some View {
content
.foregroundStyle(.compound.iconOnSolidPrimary)
.scaledPadding(5, relativeTo: .compound.headingLG)
.background(backgroundColor(isPressed: isPressed), in: .circle)
}
private func backgroundColor(isPressed: Bool) -> Color {
guard isEnabled else { return .compound.bgActionPrimaryDisabled }
return isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest
}
}
}
// MARK: - Previews
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static let wysiwygViewModel = WysiwygComposerViewModel()
static let composerViewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
static let timelineViewModel = TimelineViewModel.mock
static let suggestions: [SuggestionItem] = [
.init(suggestionType: .user(.init(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)), range: .init(), rawSuggestionText: ""),
.init(suggestionType: .user(.init(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: .mockMXCUserAvatar)), range: .init(), rawSuggestionText: "")
]
static let viewModel = ComposerToolbarViewModel.mock()
static let focusedViewModel = ComposerToolbarViewModel.mock(focused: true, message: "Hello, World!")
static let editingViewModel = ComposerToolbarViewModel.mock(message: "Hello, Wrold!", mockMode: .editing)
static let multiLineViewModel = ComposerToolbarViewModel.mock(message: "Hello, World! This is a loooong message that wraps onto multiple lines.")
static let voiceMessageRecordingViewModel = ComposerToolbarViewModel.mock(mockMode: .recordVoiceMessage)
static let voiceMessagePreviewViewModel = ComposerToolbarViewModel.mock(mockMode: .previewVoiceMessage(isUploading: false))
static let voiceMessageUploadingViewModel = ComposerToolbarViewModel.mock(mockMode: .previewVoiceMessage(isUploading: true))
static let replyLoadingViewModel = ComposerToolbarViewModel.mock(mockMode: .reply(isLoading: true))
static let replyLoadedViewModel = ComposerToolbarViewModel.mock(mockMode: .reply(isLoading: false))
static let suggestionsViewModel = ComposerToolbarViewModel.mock(hasSuggestions: true)
static let disabledViewModel = ComposerToolbarViewModel.mock(canSend: false)
static var previews: some View {
ComposerToolbar.mock(focused: true)
// Putting them is VStack allows the completion suggestion preview to work properly in tests
VStack(spacing: 8) {
// The mock functon can't be used in this context because it does not hold a reference to the view model, losing the combine subscriptions
ComposerToolbar(context: composerViewModel.context)
ComposerToolbar(context: viewModel.context)
ComposerToolbar(context: focusedViewModel.context)
ComposerToolbar(context: editingViewModel.context)
ComposerToolbar(context: multiLineViewModel.context)
.padding(.bottom)
ComposerToolbar(context: voiceMessageRecordingViewModel.context)
ComposerToolbar(context: voiceMessagePreviewViewModel.context)
ComposerToolbar(context: voiceMessageUploadingViewModel.context)
.padding(.bottom)
ComposerToolbar(context: disabledViewModel.context)
}
// Putting them in a VStack allows the completion suggestion preview to work properly in tests
VStack(spacing: 8) {
ComposerToolbar(context: suggestionsViewModel.context)
}
.previewDisplayName("With Suggestions")
VStack(spacing: 8) {
ComposerToolbar.textWithVoiceMessage(focused: false)
ComposerToolbar.textWithVoiceMessage(focused: true)
ComposerToolbar.voiceMessageRecordingMock()
ComposerToolbar.voiceMessagePreviewMock(uploading: false)
ComposerToolbar(context: replyLoadingViewModel.context)
ComposerToolbar(context: replyLoadedViewModel.context)
}
.previewDisplayName("Voice Message")
VStack(spacing: 8) {
ComposerToolbar.replyLoadingPreviewMock(isLoading: true)
ComposerToolbar.replyLoadingPreviewMock(isLoading: false)
}
.environmentObject(viewModel.context)
.environmentObject(timelineViewModel.context)
.previewDisplayName("Reply")
VStack(spacing: 8) {
ComposerToolbar.disabledPreviewMock()
}
.previewDisplayName("Disabled")
}
}
extension ComposerToolbar {
static func mock(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.composerEmpty = focused
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.composerEmpty = focused
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
static func voiceMessageRecordingMock() -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
static func voiceMessagePreviewMock(uploading: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview,
title: L10n.commonVoiceMessage,
duration: 10.0),
waveform: .data(waveformData),
isUploading: uploading)
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
static func replyLoadingPreviewMock(isLoading: Bool) -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.composerMode = isLoading ? .reply(eventID: UUID().uuidString,
replyDetails: .loading(eventID: ""),
isThread: false) :
.reply(eventID: UUID().uuidString,
replyDetails: .loaded(sender: .init(id: "",
displayName: "Test"),
eventID: "", eventContent: .message(.text(.init(body: "Hello World!")))), isThread: false)
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
static func disabledPreviewMock() -> ComposerToolbar {
let wysiwygViewModel = WysiwygComposerViewModel()
var composerViewModel: ComposerToolbarViewModel {
let model = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
mediaProvider: MediaProviderMock(configuration: .init()),
mentionDisplayHelper: ComposerMentionDisplayHelper.mock,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: ComposerDraftServiceMock(.init()))
model.state.canSend = false
return model
}
return ComposerToolbar(context: composerViewModel.context)
}
}
@@ -17,7 +17,7 @@ struct FormattingToolbar: View {
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 4) {
HStack(spacing: 5) {
ForEach(formatItems) { item in
Button {
formatAction(item.type)
@@ -27,7 +27,7 @@ struct FormattingToolbar: View {
.padding(8)
.background(item.backgroundColor)
.cornerRadius(8)
.padding(4)
.padding(.vertical, Compound.supportsGlass ? 10 : 3)
}
.disabled(item.state == .disabled)
.accessibilityIdentifier(item.accessibilityIdentifier)
@@ -63,11 +63,13 @@ private extension FormatItem {
struct FormattingToolbar_Previews: PreviewProvider, TestablePreview {
static let items = FormatType.allCases.map { FormatItem(type: $0, state: .enabled) }
static let reversedItems = FormatType.allCases.map { FormatItem(type: $0, state: .reversed) }
static let disabledItems = FormatType.allCases.map { FormatItem(type: $0, state: .disabled) }
static var previews: some View {
VStack(spacing: 16.0) {
FormattingToolbar(formatItems: items) { _ in }
FormattingToolbar(formatItems: reversedItems) { _ in }
FormattingToolbar(formatItems: disabledItems) { _ in }
}
}
@@ -140,11 +140,7 @@ private struct MessageComposerReplyHeader: View {
let action: () -> Void
var body: some View {
TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 13, style: .circular))
TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails, maxWidth: .infinity)
.overlay(alignment: .topTrailing) {
Button(action: action) {
CompoundIcon(\.close, size: .small, relativeTo: .compound.bodySMSemibold)
@@ -206,28 +202,46 @@ extension View {
}
private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
@Environment(\.isEnabled) private var isEnabled
let header: Header
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
func body(content: Content) -> some View {
if #available(iOS 26, *) {
if isEnabled {
mainContent(content: content)
.snapshotableGlassEffect(.regular.interactive(), // Doesn't need to be interactive but Apple does it 🤷‍♂️
snapshotBackground: .compound.bgSubtleSecondary,
in: composerShape)
} else {
mainContent(content: content)
.background(.compound.bgSubtlePrimary, in: composerShape)
}
} else {
mainContent(content: content)
.background {
ZStack {
composerShape
.fill(Color.compound.bgSubtleSecondary)
composerShape
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
}
}
}
}
func mainContent(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) {
header
content
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
.padding(.vertical, Compound.supportsGlass ? 11 : 10)
}
.padding(.horizontal, 12.0)
.padding(.horizontal, Compound.supportsGlass ? 16 : 12)
.clipShape(composerShape)
.background {
ZStack {
composerShape
.fill(Color.compound.bgSubtleSecondary)
composerShape
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
}
}
}
}
@@ -13,19 +13,17 @@ import WysiwygComposer
struct RoomAttachmentPicker: View {
@ObservedObject var context: ComposerToolbarViewModel.Context
@Environment(\.isEnabled) private var isEnabled
var body: some View {
// Use a menu instead of the popover/sheet shown in Figma because overriding the colour scheme
// results in a rendering bug on 17.1: https://github.com/element-hq/element-x-ios/issues/2157
Menu {
menuContent
} label: {
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .compound.headingLG)
.scaledPadding(7, relativeTo: .compound.headingLG)
.foregroundColor(isEnabled ? .compound.iconPrimary : .compound.iconDisabled)
CompoundIcon(\.plus,
size: Compound.supportsGlass ? .medium : .small,
relativeTo: .compound.headingLG)
}
.buttonStyle(RoomAttachmentPickerButtonStyle())
.buttonStyle(ComposerToolbarButtonStyle())
.accessibilityLabel(L10n.actionAddToTimeline)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions)
}
@@ -79,13 +77,6 @@ struct RoomAttachmentPicker: View {
}
}
private struct RoomAttachmentPickerButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(configuration.isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest)
}
}
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
static let viewModel = ComposerToolbarViewModel(roomProxy: JoinedRoomProxyMock(.init()),
wysiwygViewModel: WysiwygComposerViewModel(),
@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Compound
import DSWaveformImage
import DSWaveformImageViews
import Foundation
@@ -52,14 +53,12 @@ struct VoiceMessagePreviewComposer: View {
.onChange(of: isDragging) { _, newValue in
onScrubbing(newValue)
}
.padding(.vertical, 4.0)
.padding(.horizontal, 6.0)
.padding(.vertical, Compound.supportsGlass ? 7 : 4)
.padding(.horizontal, Compound.supportsGlass ? 8 : 6)
.padding(.trailing, Compound.supportsGlass ? 8 : 0)
.background {
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
}
RoundedRectangle(cornerRadius: Compound.supportsGlass ? 21 : 12)
.fill(.compound.bgSubtleSecondary)
}
}
@@ -96,6 +95,8 @@ private extension DateFormatter {
}()
}
// MARK: - Previews
struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview {
static let playerState = AudioPlayerState(id: .recorderPreview,
title: L10n.commonVoiceMessage,
@@ -23,6 +23,11 @@ struct VoiceMessageRecordingButton: View {
private let impactFeedbackGenerator = UIImpactFeedbackGenerator()
private var recordIconColour: Color {
guard isEnabled else { return .compound.iconDisabled }
return Compound.supportsGlass ? .compound.iconPrimary : .compound.iconSecondary
}
var body: some View {
Button {
impactFeedbackGenerator.impactOccurred()
@@ -35,15 +40,19 @@ struct VoiceMessageRecordingButton: View {
} label: {
switch mode {
case .idle:
CompoundIcon(\.micOn, size: .medium, relativeTo: .compound.headingLG)
.foregroundColor(isEnabled ? .compound.iconSecondary : .compound.iconDisabled)
.scaledPadding(10, relativeTo: .compound.headingLG)
CompoundIcon(Compound.supportsGlass ? \.micOnSolid : \.micOn,
size: .medium,
relativeTo: .compound.headingLG)
.foregroundColor(recordIconColour)
.scaledPadding(Compound.supportsGlass ? 10 : 6, relativeTo: .compound.headingLG)
case .recording:
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .compound.headingLG)
CompoundIcon(\.stopSolid,
size: Compound.supportsGlass ? .medium : .small,
relativeTo: .compound.headingLG)
.foregroundColor(.compound.iconOnSolidPrimary)
.scaledPadding(6, relativeTo: .compound.headingLG)
.background(.compound.bgActionPrimaryRest, in: Circle())
.scaledPadding(4, relativeTo: .compound.headingLG)
.scaledPadding(Compound.supportsGlass ? 10 : 8, relativeTo: .compound.headingLG)
.background(.compound.bgActionPrimaryRest, in: .circle)
.compositingGroup()
}
}
.buttonStyle(VoiceMessageRecordingButtonStyle())
@@ -52,17 +61,32 @@ struct VoiceMessageRecordingButton: View {
}
private struct VoiceMessageRecordingButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.6 : 1)
if #available(iOS 26, *) {
if isEnabled {
configuration.label
.snapshotableGlassEffect(.regular.interactive(),
snapshotBackground: .compound.bgSubtleSecondary,
in: .circle)
} else {
configuration.label
.background(.compound.bgSubtlePrimary, in: .circle)
}
} else {
configuration.label
.opacity(configuration.isPressed ? 0.6 : 1)
}
}
}
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
HStack(spacing: 8) {
HStack(spacing: 12) {
VoiceMessageRecordingButton(mode: .idle)
.disabled(true)
VoiceMessageRecordingButton(mode: .idle)
VoiceMessageRecordingButton(mode: .recording)
}
}
@@ -15,18 +15,17 @@ struct VoiceMessageRecordingComposer: View {
var body: some View {
VoiceMessageRecordingView(recorderState: recorderState)
.padding(.vertical, 8.0)
.padding(.horizontal, 12.0)
.padding(.vertical, Compound.supportsGlass ? 14 : 8)
.padding(.horizontal, Compound.supportsGlass ? 16 : 12)
.background {
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
}
RoundedRectangle(cornerRadius: Compound.supportsGlass ? 21 : 12)
.fill(.compound.bgSubtleSecondary)
}
}
}
// MARK: - Previews
struct VoiceMessageRecordingComposer_Previews: PreviewProvider, TestablePreview {
static let recorderState = AudioRecorderState()
@@ -0,0 +1,65 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct VoiceMessageTrashButton: View {
let action: () -> Void
var body: some View {
if #available(iOS 26, *) {
Button(role: .destructive, action: action) {
CompoundIcon(\.delete, size: .medium, relativeTo: .compound.headingLG)
.modifier(GlassStyle())
.compositingGroup()
}
} else {
Button(role: .destructive, action: action) {
CompoundIcon(\.delete)
.scaledToFit()
.scaledFrame(size: 30, relativeTo: .compound.headingLG)
}
.buttonStyle(.compound(.textLink))
}
}
@available(iOS 26, *)
private struct GlassStyle: ViewModifier {
@Environment(\.isEnabled) private var isEnabled
func body(content: Content) -> some View {
if isEnabled {
label(content: content)
.snapshotableGlassEffect(.regular.tint(.compound.bgCriticalPrimary).interactive(),
snapshotBackground: .compound.bgCriticalPrimary,
in: .circle)
} else {
label(content: content)
.background(.compound.bgSubtlePrimary, in: .circle)
}
}
private func label(content: Content) -> some View {
content
.foregroundStyle(isEnabled ? .compound.iconOnSolidPrimary : .compound.iconDisabled)
.scaledPadding(10, relativeTo: .compound.headingLG)
}
}
}
// MARK: - Previews
struct VoiceMessageTrashButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
HStack(spacing: 12) {
VoiceMessageTrashButton { }
.disabled(true)
VoiceMessageTrashButton { }
}
}
}
@@ -197,19 +197,20 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let viewModels = makeViewModels()
static let readOnlyViewModels = makeViewModels(canSendMessage: false)
static let tombstonedViewModels = makeViewModels(hasSuccessor: true)
static let composerViewModel = ComposerToolbarViewModel.mock()
static var previews: some View {
ElementNavigationStack {
RoomScreen(context: viewModels.room.context,
timelineContext: viewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
.previewDisplayName("Normal")
ElementNavigationStack {
RoomScreen(context: readOnlyViewModels.room.context,
timelineContext: readOnlyViewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
.previewDisplayName("Read-only")
.snapshotPreferences(expect: readOnlyViewModels.room.context.$viewState.map { !$0.canSendMessage })
@@ -217,7 +218,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
ElementNavigationStack {
RoomScreen(context: tombstonedViewModels.room.context,
timelineContext: tombstonedViewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
.previewDisplayName("Tombstoned")
.snapshotPreferences(expect: tombstonedViewModels.room.context.$viewState.map(\.hasSuccessor))