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 51a3350f0b
commit b4d6fe43c3
181 changed files with 790 additions and 719 deletions

View File

@@ -867,6 +867,10 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "VoiceMessageRoomTimelineView_Previews")
}
func testVoiceMessageTrashButton() async throws {
try await performAccessibilityAudit(named: "VoiceMessageTrashButton_Previews")
}
func testWaveformCursorView() async throws {
try await performAccessibilityAudit(named: "WaveformCursorView_Previews")
}

View File

@@ -188,6 +188,7 @@
1C6B2A1A5A9699DD4C9755B3 /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; };
1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; };
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; };
1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */; };
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; };
1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; };
1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; };
@@ -279,6 +280,7 @@
2DA27D78560D5F79B917E163 /* AudioConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44E35AA87F49503E7B3BF6E /* AudioConverter.swift */; };
2DD9D0FE7CB5CFC80D071451 /* AppLockScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3E67E09FE5A35D73818C39 /* AppLockScreenModels.swift */; };
2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */; };
2E6AD068D7767BDC33626F1C /* SnapshotableGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */; };
2E8C6672D0EE7D5B1BEDB8E2 /* ServerConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7478623CECC9438014244BA /* ServerConfirmationScreen.swift */; };
2EAA1B35D9CA24F090F48792 /* SpaceRoomListProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACD4855BDAD0799FD86B7B5 /* SpaceRoomListProxyProtocol.swift */; };
2F2906AE9BC3D0E79A6F98F8 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E89E530A8E92EC44301CA1 /* Bundle.swift */; };
@@ -2504,6 +2506,7 @@
AB26D5444A4A7E095222DE8B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = "<group>"; };
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = "<group>"; };
AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = "<group>"; };
AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = "<group>"; };
AC43313F21511C853D34544E /* SoftLogoutScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -2686,6 +2689,7 @@
CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
CA90BD288E5AE6BC643AFDDF /* TemplateScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenCoordinator.swift; sourceTree = "<group>"; };
CACA846B3E3E9A521D98B178 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotableGlassEffect.swift; sourceTree = "<group>"; };
CB7B588A06911B455AC0B4C9 /* ManageRoomMemberSheetViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRoomMemberSheetViewModelProtocol.swift; sourceTree = "<group>"; };
CB98BFD8E93C7FCCEDEC46F9 /* SpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenViewModel.swift; sourceTree = "<group>"; };
CBBCC6E74774E79B599625D0 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -3754,6 +3758,7 @@
7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */,
839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */,
AEB5FF7A09B79B0C6B528F7C /* SFNumberedListView.swift */,
CB04B2D794885025DACFCEFB /* SnapshotableGlassEffect.swift */,
A8558D41DD4B553A752C868A /* StackedAvatarsView.swift */,
E10765FBC83B34A3BC4ADB23 /* TimelineScrollToBottomButton.swift */,
16D353E10A64172D863769BF /* TombstonedAvatarImage.swift */,
@@ -5307,6 +5312,7 @@
46A2AD86F7E618F468F6FAF5 /* VoiceMessageRecordingButton.swift */,
73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */,
5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */,
ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */,
);
path = View;
sourceTree = "<group>";
@@ -8701,6 +8707,7 @@
274CE3C986841D15FD530BF5 /* ShimmerModifier.swift in Sources */,
5038E69A5E6A89DE1A345E04 /* ShouldScrollOnKeyboardDidShow.swift in Sources */,
77920AFA8091AC6B9F190C90 /* Signposter.swift in Sources */,
2E6AD068D7767BDC33626F1C /* SnapshotableGlassEffect.swift in Sources */,
F08F7BC07CA9AEF5CD157918 /* Snapshotting.swift in Sources */,
8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */,
A3A7A05E8F9B7EB0E1A09A2A /* SoftLogoutScreenCoordinator.swift in Sources */,
@@ -8919,6 +8926,7 @@
55DF6DEEF2CEEF40F84B53B0 /* VoiceMessageRoomPlaybackView.swift in Sources */,
024E70451A7CD9E4E034D8A9 /* VoiceMessageRoomTimelineItem.swift in Sources */,
1224084B7E289E0830BA2C54 /* VoiceMessageRoomTimelineView.swift in Sources */,
1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */,
CA12AE0DCD57D49CD96C699A /* WaveformCursorView.swift in Sources */,
63CDC201A5980F304F6D0A1C /* WaveformInteractionModifier.swift in Sources */,
B773ACD8881DB18E876D950C /* WaveformSource.swift in Sources */,

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,25 +0,0 @@
{
"images" : [
{
"filename" : "close-rte.svg",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "close-rte-dark.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Subtract" fill-rule="evenodd" clip-rule="evenodd" d="M4.3934 4.3934C-1.46447 10.2513 -1.46447 19.7487 4.3934 25.6066C10.2513 31.4645 19.7487 31.4645 25.6066 25.6066C31.4645 19.7487 31.4645 10.2513 25.6066 4.3934C19.7487 -1.46447 10.2513 -1.46447 4.3934 4.3934ZM11.4645 19.9497L15 16.4142L18.5355 19.9497C18.7359 20.1501 18.9716 20.2503 19.2426 20.2503C19.5137 20.2503 19.7494 20.1501 19.9497 19.9497C20.1501 19.7494 20.2503 19.5137 20.2503 19.2426C20.2503 18.9716 20.1501 18.7359 19.9497 18.5355L16.4142 15L19.9497 11.4645C20.1501 11.2641 20.2503 11.0284 20.2503 10.7574C20.2503 10.4863 20.1501 10.2506 19.9497 10.0503C19.7494 9.84991 19.5137 9.74973 19.2426 9.74973C18.9716 9.74973 18.7359 9.84991 18.5355 10.0503L15 13.5858L11.4645 10.0503C11.2641 9.84991 11.0284 9.74973 10.7574 9.74973C10.4863 9.74973 10.2506 9.84991 10.0503 10.0503C9.84991 10.2506 9.74973 10.4863 9.74973 10.7574C9.74973 11.0284 9.84991 11.2641 10.0503 11.4645L13.5858 15L10.0503 18.5355C9.84991 18.7359 9.74973 18.9716 9.74973 19.2426C9.74973 19.5137 9.84991 19.7494 10.0503 19.9497C10.2506 20.1501 10.4863 20.2503 10.7574 20.2503C11.0284 20.2503 11.2641 20.1501 11.4645 19.9497Z" fill="#EBEEF2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Subtract" fill-rule="evenodd" clip-rule="evenodd" d="M4.3934 4.3934C-1.46447 10.2513 -1.46447 19.7487 4.3934 25.6066C10.2513 31.4645 19.7487 31.4645 25.6066 25.6066C31.4645 19.7487 31.4645 10.2513 25.6066 4.3934C19.7487 -1.46447 10.2513 -1.46447 4.3934 4.3934ZM11.4645 19.9497L15 16.4142L18.5355 19.9497C18.7359 20.1501 18.9716 20.2503 19.2426 20.2503C19.5137 20.2503 19.7494 20.1501 19.9497 19.9497C20.1501 19.7494 20.2503 19.5137 20.2503 19.2426C20.2503 18.9716 20.1501 18.7359 19.9497 18.5355L16.4142 15L19.9497 11.4645C20.1501 11.2641 20.2503 11.0284 20.2503 10.7574C20.2503 10.4863 20.1501 10.2506 19.9497 10.0503C19.7494 9.84991 19.5137 9.74973 19.2426 9.74973C18.9716 9.74973 18.7359 9.84991 18.5355 10.0503L15 13.5858L11.4645 10.0503C11.2641 9.84991 11.0284 9.74973 10.7574 9.74973C10.4863 9.74973 10.2506 9.84991 10.0503 10.0503C9.84991 10.2506 9.74973 10.4863 9.74973 10.7574C9.74973 11.0284 9.84991 11.2641 10.0503 11.4645L13.5858 15L10.0503 18.5355C9.84991 18.7359 9.74973 18.9716 9.74973 19.2426C9.74973 19.5137 9.84991 19.7494 10.0503 19.9497C10.2506 20.1501 10.4863 20.2503 10.7574 20.2503C11.0284 20.2503 11.2641 20.1501 11.4645 19.9497Z" fill="#1B1D22"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,16 +0,0 @@
{
"images" : [
{
"filename" : "composer-attachment-light.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -1,3 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 15C0 23.2843 6.71573 30 15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15ZM16 21V16H21C21.2833 16 21.5208 15.9042 21.7125 15.7125C21.9042 15.5208 22 15.2833 22 15C22 14.7167 21.9042 14.4792 21.7125 14.2875C21.5208 14.0958 21.2833 14 21 14H16V9C16 8.71667 15.9042 8.47917 15.7125 8.2875C15.5208 8.09583 15.2833 8 15 8C14.7167 8 14.4792 8.09583 14.2875 8.2875C14.0958 8.47917 14 8.71667 14 9L14 14L9 14C8.71667 14 8.47917 14.0958 8.2875 14.2875C8.09583 14.4792 8 14.7167 8 15C8 15.2833 8.09583 15.5208 8.2875 15.7125C8.47917 15.9042 8.71667 16 9 16H14V21C14 21.2833 14.0958 21.5208 14.2875 21.7125C14.4792 21.9042 14.7167 22 15 22C15.2833 22 15.5208 21.9042 15.7125 21.7125C15.9042 21.5208 16 21.2833 16 21Z" fill="#1B1D22"/>
</svg>

Before

Width:  |  Height:  |  Size: 914 B

View File

@@ -1,16 +0,0 @@
{
"images" : [
{
"filename" : "stop-recording.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="stop_FILL1_wght400_GRAD0_opsz24 1">
<path id="Vector" d="M6 16V8C6 7.45 6.19583 6.97917 6.5875 6.5875C6.97917 6.19583 7.45 6 8 6H16C16.55 6 17.0208 6.19583 17.4125 6.5875C17.8042 6.97917 18 7.45 18 8V16C18 16.55 17.8042 17.0208 17.4125 17.4125C17.0208 17.8042 16.55 18 16 18H8C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16Z" fill="white"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 477 B

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,16 +0,0 @@
{
"images" : [
{
"filename" : "media-pause.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -1,3 +0,0 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 14C9.45 14 8.97917 13.8042 8.5875 13.4125C8.19583 13.0208 8 12.55 8 12V2C8 1.45 8.19583 0.979167 8.5875 0.5875C8.97917 0.195833 9.45 0 10 0C10.55 0 11.0208 0.195833 11.4125 0.5875C11.8042 0.979167 12 1.45 12 2V12C12 12.55 11.8042 13.0208 11.4125 13.4125C11.0208 13.8042 10.55 14 10 14ZM2 14C1.45 14 0.979167 13.8042 0.5875 13.4125C0.195833 13.0208 0 12.55 0 12V2C0 1.45 0.195833 0.979167 0.5875 0.5875C0.979167 0.195833 1.45 0 2 0C2.55 0 3.02083 0.195833 3.4125 0.5875C3.80417 0.979167 4 1.45 4 2V12C4 12.55 3.80417 13.0208 3.4125 13.4125C3.02083 13.8042 2.55 14 2 14Z" fill="#656D77"/>
</svg>

Before

Width:  |  Height:  |  Size: 703 B

View File

@@ -1,16 +0,0 @@
{
"images" : [
{
"filename" : "media-play.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -1,3 +0,0 @@
<svg width="11" height="14" viewBox="0 0 11 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.525 13.0252C1.19167 13.2418 0.854167 13.2543 0.5125 13.0627C0.170833 12.871 0 12.5752 0 12.1752V1.82518C0 1.42518 0.170833 1.12935 0.5125 0.937683C0.854167 0.746017 1.19167 0.758517 1.525 0.975183L9.675 6.15018C9.975 6.35018 10.125 6.63352 10.125 7.00018C10.125 7.36685 9.975 7.65018 9.675 7.85018L1.525 13.0252Z" fill="#656D77"/>
</svg>

Before

Width:  |  Height:  |  Size: 446 B

View File

@@ -31,15 +31,10 @@ internal enum Asset {
internal enum Images {
internal static let appLogo = ImageAsset(name: "images/app-logo")
internal static let backgroundBottom = ImageAsset(name: "images/background-bottom")
internal static let closeRte = ImageAsset(name: "images/close-rte")
internal static let composerAttachment = ImageAsset(name: "images/composer-attachment")
internal static let stopRecording = ImageAsset(name: "images/stop-recording")
internal static let launchBackground = ImageAsset(name: "images/launch-background")
internal static let locationMarkerShape = ImageAsset(name: "images/location-marker-shape")
internal static let mapBlurred = ImageAsset(name: "images/mapBlurred")
internal static let placeholderMap = ImageAsset(name: "images/placeholderMap")
internal static let mediaPause = ImageAsset(name: "images/media-pause")
internal static let mediaPlay = ImageAsset(name: "images/media-play")
internal static let notificationsPromptGraphic = ImageAsset(name: "images/notifications-prompt-graphic")
internal static let pollWinner = ImageAsset(name: "images/poll-winner")
}

View File

@@ -0,0 +1,22 @@
//
// 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 SwiftUI
extension View {
/// Similar to the `.glassProminent` button style, `.glassEffect` breaks our preview tests so this modifier provides a fallback.
/// https://github.com/pointfreeco/swift-snapshot-testing/issues/1029#issuecomment-3366942138
@ViewBuilder
@available(iOS 26, *)
func snapshotableGlassEffect(_ glass: Glass, snapshotBackground: Color, in shape: some Shape) -> some View {
if !ProcessInfo.isRunningUnitTests {
glassEffect(glass, in: shape)
} else {
background(snapshotBackground, in: shape)
}
}
}

View File

@@ -224,6 +224,7 @@ enum TestablePreviewsDictionary {
"VoiceMessageRecordingView_Previews" : VoiceMessageRecordingView_Previews.self,
"VoiceMessageRoomPlaybackView_Previews" : VoiceMessageRoomPlaybackView_Previews.self,
"VoiceMessageRoomTimelineView_Previews" : VoiceMessageRoomTimelineView_Previews.self,
"VoiceMessageTrashButton_Previews" : VoiceMessageTrashButton_Previews.self,
"WaveformCursorView_Previews" : WaveformCursorView_Previews.self,
]
}

View File

@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct VoiceMessageButton: View {
@@ -25,12 +26,19 @@ struct VoiceMessageButton: View {
let state: State
let action: () -> Void
let iconSize: CompoundIcon.Size
let iconColor: Color
init(state: State, size: Size, action: @escaping () -> Void) {
switch size {
case .small:
_buttonSize = .init(wrappedValue: 30)
iconSize = .small
iconColor = .compound.iconPrimary
case .medium:
_buttonSize = .init(wrappedValue: 36)
iconSize = .medium
iconColor = .compound.iconSecondary
}
self.state = state
@@ -41,9 +49,12 @@ struct VoiceMessageButton: View {
Button(action: action) {
buttonLabel
.frame(width: buttonSize, height: buttonSize)
.overlay {
Circle().stroke(.compound.borderInteractiveSecondary)
}
}
.animation(nil, value: state)
.buttonStyle(VoiceMessageButtonStyle())
.buttonStyle(VoiceMessageButtonStyle(color: iconColor))
.disabled(state == .loading)
.accessibilityLabel(accessibilityLabel)
}
@@ -54,14 +65,9 @@ struct VoiceMessageButton: View {
case .loading:
ProgressView()
case .playing, .paused:
let imageAsset = state == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay
let offset: CGFloat = state == .playing ? 0 : 2
Image(asset: imageAsset)
.resizable()
.scaledToFit()
.scaledFrame(width: 12, height: 14)
.offset(x: offset)
CompoundIcon(state == .playing ? \.pauseSolid : \.playSolid,
size: iconSize,
relativeTo: .compound.headingLG)
}
}
@@ -80,9 +86,11 @@ struct VoiceMessageButton: View {
private struct VoiceMessageButtonStyle: ButtonStyle {
@Environment(\.isEnabled) var isEnabled: Bool
let color: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundColor(isEnabled ? .compound.textSecondary.opacity(configuration.isPressed ? 0.6 : 1) : .compound.iconDisabled)
.foregroundColor(isEnabled ? color.opacity(configuration.isPressed ? 0.6 : 1) : .compound.iconDisabled)
.background(Circle()
.foregroundColor(configuration.isPressed ? .compound.bgSubtlePrimary : .compound.bgCanvasDefault))
}
@@ -114,6 +122,6 @@ struct VoiceMessageButton_Previews: PreviewProvider, TestablePreview {
}
}
.padding()
.background(Color.gray)
.background(.compound.bgSubtleSecondary)
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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,32 +154,18 @@ 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)
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])
@@ -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)
}
}

View File

@@ -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 }
}
}

View File

@@ -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,20 +202,25 @@ extension View {
}
private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
@Environment(\.isEnabled) private var isEnabled
let header: Header
func body(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) {
header
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
content
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
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)
}
.padding(.horizontal, 12.0)
.clipShape(composerShape)
} else {
mainContent(content: content)
.background {
ZStack {
composerShape
@@ -231,6 +232,19 @@ private struct MessageComposerStyleModifier<Header: View>: ViewModifier {
}
}
func mainContent(content: Content) -> some View {
VStack(alignment: .leading, spacing: -6) {
header
content
.tint(.compound.iconAccentTertiary)
.padding(.vertical, Compound.supportsGlass ? 11 : 10)
}
.padding(.horizontal, Compound.supportsGlass ? 16 : 12)
.clipShape(composerShape)
}
}
// MARK: - Previews
struct MessageComposer_Previews: PreviewProvider, TestablePreview {

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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 {
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)
}
}

View File

@@ -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()

View File

@@ -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 { }
}
}
}

View File

@@ -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))

View File

@@ -17,8 +17,25 @@ enum TimelineReplyViewPlacement {
struct TimelineReplyView: View {
let placement: TimelineReplyViewPlacement
let timelineItemReplyDetails: TimelineItemReplyDetails?
var maxWidth: CGFloat?
private let backgroundShape = RoundedRectangle(cornerRadius: 6)
var body: some View {
content
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: maxWidth, alignment: .leading)
.padding(4.0)
.background {
ZStack {
backgroundShape.fill(.compound.bgCanvasDefault)
backgroundShape.stroke(.compound.borderInteractiveSecondary)
}
}
}
@ViewBuilder
private var content: some View {
if let timelineItemReplyDetails {
switch timelineItemReplyDetails {
case .loaded(let sender, _, let content):
@@ -29,7 +46,7 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .systemIcon("waveform"), cornerRadii: iconCornerRadii))
icon: .init(kind: .systemIcon("waveform")))
case .emote(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -38,12 +55,12 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .icon(\.document), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.document)))
case .image(let content):
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: .init(kind: .mediaSource(content.thumbnailInfo?.source ?? content.imageInfo.source), cornerRadii: iconCornerRadii))
icon: .init(kind: .mediaSource(content.thumbnailInfo?.source ?? content.imageInfo.source)))
case .notice(let content):
ReplyView(sender: sender,
plainBody: content.body,
@@ -56,33 +73,33 @@ struct TimelineReplyView: View {
ReplyView(sender: sender,
plainBody: content.caption ?? content.filename,
formattedBody: content.formattedCaption,
icon: content.thumbnailInfo.map { .init(kind: .mediaSource($0.source), cornerRadii: iconCornerRadii) })
icon: content.thumbnailInfo.map { .init(kind: .mediaSource($0.source)) })
case .voice:
ReplyView(sender: sender,
plainBody: L10n.commonVoiceMessage,
formattedBody: nil,
icon: .init(kind: .icon(\.micOn), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.micOn)))
case .location:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLocation,
formattedBody: nil,
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.locationPin)))
}
case .poll(let question):
ReplyView(sender: sender,
plainBody: question,
formattedBody: nil,
icon: .init(kind: .icon(\.polls), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.polls)))
case .liveLocation:
ReplyView(sender: sender,
plainBody: L10n.commonSharedLiveLocation,
formattedBody: nil,
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.locationPin)))
case .redacted:
ReplyView(sender: sender,
plainBody: L10n.commonMessageRemoved,
formattedBody: nil,
icon: .init(kind: .icon(\.delete), cornerRadii: iconCornerRadii))
icon: .init(kind: .icon(\.delete)))
}
default:
LoadingReplyView()
@@ -90,15 +107,6 @@ struct TimelineReplyView: View {
}
}
private var iconCornerRadii: Double {
switch placement {
case .composer:
return 9.0
case .timeline:
return 4.0
}
}
private struct LoadingReplyView: View {
var body: some View {
ReplyView(sender: .init(id: "@alice:matrix.org"), plainBody: "Hello world", formattedBody: nil)
@@ -117,7 +125,7 @@ struct TimelineReplyView: View {
}
let kind: Kind
let cornerRadii: Double
let cornerRadii = 4.0
}
@EnvironmentObject private var context: TimelineViewModel.Context
@@ -180,7 +188,7 @@ struct TimelineReplyView: View {
.aspectRatio(contentMode: .fit)
.padding(8.0)
case .icon(let keyPath):
CompoundIcon(keyPath, size: .small, relativeTo: .body)
CompoundIcon(keyPath, size: .medium, relativeTo: .body)
}
}
}

View File

@@ -197,12 +197,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(8)
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails, maxWidth: .infinity)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
.onTapGesture {
if context.viewState.timelineKind != .pinned {
@@ -212,8 +207,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden()
}

View File

@@ -90,9 +90,9 @@ struct MediaFileRoomTimelineContent: View {
.foregroundStyle(.compound.textPrimary)
.lineLimit(2)
} icon: {
CompoundIcon(icon, size: .xSmall, relativeTo: .body)
CompoundIcon(icon, size: .medium, relativeTo: .body)
.foregroundColor(.compound.iconPrimary)
.scaledPadding(8)
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
}

View File

@@ -88,6 +88,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let roomProxyMock = JoinedRoomProxyMock(.init(name: "Preview room"))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let focussedEventID = "RoomTimelineItemFixtures.default.5"
static let composerViewModel = ComposerToolbarViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
focussedEventID: focussedEventID,
timelineController: MockTimelineController(),
@@ -105,7 +106,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
ElementNavigationStack {
RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
.previewDisplayName("Timeline")
}

View File

@@ -143,6 +143,7 @@ struct TimelineView_Previews: PreviewProvider { // Not testable as this preview
static let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id",
name: "Preview room"))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let composerViewModel = ComposerToolbarViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockTimelineController(),
userSession: UserSessionMock(.init()),
@@ -159,7 +160,7 @@ struct TimelineView_Previews: PreviewProvider { // Not testable as this preview
ElementNavigationStack {
RoomScreen(context: roomViewModel.context,
timelineContext: timelineViewModel.context,
composerToolbar: ComposerToolbar.mock())
composerToolbar: ComposerToolbar(context: composerViewModel.context))
}
}
}

View File

@@ -1731,6 +1731,14 @@ extension PreviewTests {
}
}
@Test
func voiceMessageTrashButton() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.
for (index, preview) in VoiceMessageTrashButton_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
@Test
func waveformCursorView() async throws {
AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings.

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85e45276b103434f723b08aaf54cfe426abc13f0a7679613d5fbe8a83325ec94
size 75323

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d5021fa24b1d5f192ed76bb390dbff053100b5623047a63c152f0f35e30bcbd
size 75731

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c35db320a8a6cc7f7fa972d7b21f22eef64c3f7c030e6851242901f4bc430e7
size 34471

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1ef324a6f8e718d4e0aa24d40f2fd24d4bc160cab1e9fda842a9d632b15b1c1
size 34887

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc4ac296b7324d7c671f5d9b2d9177503fc65da88ecb2e4ceb448c63b1719d94
size 98949

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5436ea7dff07d90938cf0272f031d7ca5ea421492af01210a207635758dd1dea
size 99763

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07352ff97ea1b150f87496cf6b120301d691787b955d00f174f20544d71dfd6c
size 56086

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c261a9f1e2f89bb4e4dc05dddb0841c43b78741ca2035ade2f8b23c411c3ab3c
size 56920

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4c8ec3fa76dfde7b52c37fb7349b1be4ac326f4a82ca854f5cc7694e0b63a58
size 2062156
oid sha256:43d866593057761090bb2b96e39ed678833441aa870bac82ac269274a5d78f87
size 2062660

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8862b27011b05259ea038c13b8ccb7b1ae1f72187a9db11624c940f087f5d3
size 2088241
oid sha256:17a6b41858d52fce6ce52b4d7708a95b70e71ac6ea054a02baac31d3ba6ff9d9
size 2088606

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da93cfeea45c3b2f4d3a96ecba3d1e091b7749deb3fdc52a67130fe5b65731cd
size 892730
oid sha256:e08dbc6a8b42d40bf6ef8ef2fbfc9eea5f27934203543b2ea26f9f574dfbb759
size 892677

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71e2e99f99fad5143cf775f97a797bc49ddbe7ed33e96f9e828ea5861a49611d
size 920079
oid sha256:e984b80f719db3626eca2185eb0cde375079c92ef4f52a8faf259df7e86a42d0
size 919828

Some files were not shown because too many files have changed in this diff Show More