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
@@ -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")
}
+8
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 */,
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -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

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

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

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

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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"
}
}
@@ -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

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

-5
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")
}
@@ -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)
}
}
}
@@ -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,
]
}
@@ -6,31 +6,39 @@
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct VoiceMessageButton: View {
@ScaledMetric private var buttonSize: CGFloat
enum State {
case loading
case playing
case paused
}
enum Size {
case small
case medium
}
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)
}
}
@@ -79,10 +85,12 @@ 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)
}
}
@@ -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))
@@ -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)
}
}
}
@@ -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()
}
@@ -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))
}
@@ -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")
}
@@ -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))
}
}
}
@@ -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.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec1bb382778667fe1489e8d085214f4d79bd81d74510696d3adebcdfcfc713af
size 93456
oid sha256:5ff036210c4b851fb52b45c7e6f75d264862879674d70f55f949001ad8f98b8f
size 93726
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec1bb382778667fe1489e8d085214f4d79bd81d74510696d3adebcdfcfc713af
size 93456
oid sha256:5ff036210c4b851fb52b45c7e6f75d264862879674d70f55f949001ad8f98b8f
size 93726
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1a1809c4a5c833ad4ccd2edcd245fc004c8016b6669ae821af9dce5295acc8f
size 49654
oid sha256:d8a3a472e8305e0d4d795a8c521f48c88f5d85dbc33a0645de279513f5c4ca4f
size 49824
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1a1809c4a5c833ad4ccd2edcd245fc004c8016b6669ae821af9dce5295acc8f
size 49654
oid sha256:d8a3a472e8305e0d4d795a8c521f48c88f5d85dbc33a0645de279513f5c4ca4f
size 49824
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:511988d473710cfeb8cd4c79eaf061513ec7d2eddaee4924d90054501ed03e02
size 100630
oid sha256:18442638b7a6dc3fe925ab9b58ffc14edd8e01b9bee0aac5e6f9ea79c6f67b74
size 100601
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6140d86983701122a6048d7a62e53af13b1f6cc0131cdf8b3bd0f12728295fae
size 102243
oid sha256:5ac299342a3a05374528ec4db27c2b98db7a114b97e5130b1c2c10d2c4335e14
size 102203
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:073505b559115b954dd522f9022c0b9fa7a8c127b5ec2aec726e9442a38c76d5
size 55503
oid sha256:de431a055160ecdada6bcd5222b1d59f25d134ae5f5b89bf6ce5dced1cd17db8
size 56727
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1964389ab91210768b61f7305eb6b5f5a5b313bb1decbb872bf44480441c98c7
size 56778
oid sha256:f6e26a949fa8acf0f8acedc876add044a6a253eda6d3e89ce4f6d0740b286f11
size 57958
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85e45276b103434f723b08aaf54cfe426abc13f0a7679613d5fbe8a83325ec94
size 75323
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d5021fa24b1d5f192ed76bb390dbff053100b5623047a63c152f0f35e30bcbd
size 75731
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c35db320a8a6cc7f7fa972d7b21f22eef64c3f7c030e6851242901f4bc430e7
size 34471
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1ef324a6f8e718d4e0aa24d40f2fd24d4bc160cab1e9fda842a9d632b15b1c1
size 34887
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e29fbc6ee608e2b8c8577ebbaa8e5e81c62178843596f27be5b880a602678ecb
size 93521
oid sha256:e26965b752ba937daa43aa9968c34df7f3fcc6de55aece0587be7a5401f668e5
size 93829
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89e98c6a45920ba04a672f8870a31cc2c3a65f29598b4fc50d555fe7e1bb6d1d
size 94425
oid sha256:9111498f1151b9c79dca8de75acd398b3f91b6aba1831052f92dc79dab4aebea
size 94638
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce454947bab757b85d78c786f3c7da0b8e9097bcf2e830ddbf43fe7a8a8f6896
size 50581
oid sha256:ddafeda34e05246030b24e2552bdbab5d9a0aa0ea4670e8002ab4816843602b8
size 51811
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:beeab7135aadf7adfa80e8480e9d303daee66235fe92d7f07427fa7db9888798
size 51446
oid sha256:4e54d5e504f21680c2ca362b205e3729e07f86c5c87e746e756b2516529cd88a
size 52581
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc4ac296b7324d7c671f5d9b2d9177503fc65da88ecb2e4ceb448c63b1719d94
size 98949
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5436ea7dff07d90938cf0272f031d7ca5ea421492af01210a207635758dd1dea
size 99763
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07352ff97ea1b150f87496cf6b120301d691787b955d00f174f20544d71dfd6c
size 56086
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c261a9f1e2f89bb4e4dc05dddb0841c43b78741ca2035ade2f8b23c411c3ab3c
size 56920
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b900bb2a2da0750a6f4bf14e07b5ae0b884325844f7672417f05b91a6d07aeb
size 108348
oid sha256:bfc947f1ae8f865872359c5bce68891ad85791f1acc42ea336157e5d3fe30c05
size 108160
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46ba3c105adda9a91f1b6227b15455200b8b1f5b8e87d6826067cdfec5dca334
size 108764
oid sha256:2c83ebe6eb8b73c72e4d1e7802489348f636417eb4d4ce653486b23b8933101e
size 108566
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a2a6c34e7e14ecee0dcc66f519f95344ce67a9029f21727d6325d195e3320d0
size 62683
oid sha256:2be65102a1175ad965313481caf2de0e90916cff9da0ac1bc2ed2524b9174bd4
size 62283
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2120e2fc71bf507895247ed0443a0be8e031fb64918e298b0a8e567101e0c4f7
size 63096
oid sha256:16d292aef7301122c2da9b9438fe148dfc3864c7b27cb3aa036a53a7cf732792
size 62678
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea5b800aba78730d31cd47cafb275f9683ee9e4fe40208fa386408727c5be388
size 75401
oid sha256:9cd6bbd629067f53ad6885129dfee81c43f78ccf7c515f005a91df295c717532
size 148095
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e8778d01225ec03f6be4de5a6ead3d521b6a846f7dc930c15cdcd8c56ceffa6
size 75812
oid sha256:14ae4ba470af977284c488c79a3f37306f86bdc13fc0b883e022e41c61791f23
size 149150
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70bc2fff42a40d63ce3d536644a26735c887b7d6af9696a9963520cb14d2a83d
size 34626
oid sha256:09023dab14a8f82bc0c0fadebfe61950f96a48b66243ad5aad0122a986ee4821
size 100349
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aeea98e9077d43b967681191d5e2da15dbfa788a745c3e2f8a1752698df6d412
size 35045
oid sha256:6d43565e0927d3378f0ad641cdc1893ec04daf2f8528e0cc73d5bb4fb2c16d3a
size 102182
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:648dd14dff578845c45011f62423b82b0f31663646e578b76b42fd457236f63a
size 121182
oid sha256:c1dc34c35df174c7c8d4b3665d97deea97008ea5f3130d42dc18a7f4c5333491
size 121929
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:648dd14dff578845c45011f62423b82b0f31663646e578b76b42fd457236f63a
size 121182
oid sha256:c1dc34c35df174c7c8d4b3665d97deea97008ea5f3130d42dc18a7f4c5333491
size 121929
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a01eb9a4ba39c43296196e1ce434e8826e7c44626ed04ca21786808e75056834
size 74214
oid sha256:b358f50697a6e5fe55f0107f7671efe30aceec8e13fc1bc2fecbaefee5327628
size 75449
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a01eb9a4ba39c43296196e1ce434e8826e7c44626ed04ca21786808e75056834
size 74214
oid sha256:b358f50697a6e5fe55f0107f7671efe30aceec8e13fc1bc2fecbaefee5327628
size 75449
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:140c478c95147258894ea18dfa0b67c35b6ca209d7927b83582df4f2e842b4cf
size 154385
oid sha256:37af44a7e611caacb2d2d06a0c09de7617b218123e1dff1271745cbb407588aa
size 155052
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50af185e5040f2dde21b1049fcc98afe0b6e1dc25db061d0ff1115c75058d0f9
size 158479
oid sha256:f261b91e9329722a372ad27c690d1159c8f963c5d6c0dd350805902720a4152a
size 159112
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db3e0649000604635f786518ae36f6a787ae6fabdd519f77309cc97b1f43e46c
size 106925
oid sha256:0954a711d9f64525a2721964433b7d9fcc9dcab5ca097d12e79abfadb771b30d
size 108469
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a74b6591689cff1445daf23d518d489d1d307b6f3b85dfff9b726e2a11b1453
size 110263
oid sha256:860d42a1031f730f40d0936d86218d94f0148b26b74342a7355756fec483eadf
size 111525
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53bf1224988a6b72e2d1661a0a3a664541e0e8f317a78face45bf08a53138e9d
size 77999
oid sha256:aca922a460d54a446c16884f2f3ce5f509b28a57e887db5d60c72b7ee004d9a9
size 87437
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53bf1224988a6b72e2d1661a0a3a664541e0e8f317a78face45bf08a53138e9d
size 77999
oid sha256:aca922a460d54a446c16884f2f3ce5f509b28a57e887db5d60c72b7ee004d9a9
size 87437
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3a67f6ea471afb4802522f3489f23cde7b7b560a6e36d02fca28239d4a0cae3
size 33560
oid sha256:1fc7f452a2c8b245313ae70c06fe00ce93a207e2a90113d5d34deb418c33a0a6
size 41161
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3a67f6ea471afb4802522f3489f23cde7b7b560a6e36d02fca28239d4a0cae3
size 33560
oid sha256:1fc7f452a2c8b245313ae70c06fe00ce93a207e2a90113d5d34deb418c33a0a6
size 41161
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4c8ec3fa76dfde7b52c37fb7349b1be4ac326f4a82ca854f5cc7694e0b63a58
size 2062156
oid sha256:43d866593057761090bb2b96e39ed678833441aa870bac82ac269274a5d78f87
size 2062660
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da8862b27011b05259ea038c13b8ccb7b1ae1f72187a9db11624c940f087f5d3
size 2088241
oid sha256:17a6b41858d52fce6ce52b4d7708a95b70e71ac6ea054a02baac31d3ba6ff9d9
size 2088606
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da93cfeea45c3b2f4d3a96ecba3d1e091b7749deb3fdc52a67130fe5b65731cd
size 892730
oid sha256:e08dbc6a8b42d40bf6ef8ef2fbfc9eea5f27934203543b2ea26f9f574dfbb759
size 892677
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:71e2e99f99fad5143cf775f97a797bc49ddbe7ed33e96f9e828ea5861a49611d
size 920079
oid sha256:e984b80f719db3626eca2185eb0cde375079c92ef4f52a8faf259df7e86a42d0
size 919828
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d15de26595447324d07c37743b11dad007f642ac89a3e7e3c48cd780bc4ecb81
size 1294587
oid sha256:1b8e1da549bebefa2452f375d0c14a195d4e985ac9cb7841b36121107787d043
size 1295023
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18fc1f6ab7d0fb4594ab1967673562b47de74b5279d1cd48d38184d871a35b16
size 1297733
oid sha256:7dd31cdcf9a2f90466254b2d82f96287884d0a36551fdf825c211736e817eb3a
size 1298213
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea260f8c6615aca8ea3f3afcad493e1698a8c4112ea521312bdaab043499957d
size 574431
oid sha256:26e5ccab47d30ce36dc519114ee5d487e113ee95d2601f42a5efbd1f8f24f547
size 574401
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:17b2a4d650fe20eca470d66dd074921413cdaf2cfbe5b7623f10765b47674096
size 576073
oid sha256:0b4ab15d397ed6bfc8f9b3c723ec692f26fe5115d87e6963b372de472d9de775
size 575953
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:445596e14d6bdf911e848b362a4611d5f58d7d7be5f1d85d9b64b28273e5a251
size 144598
oid sha256:8abd361ead9b4ba3a4fcc775dbb5f30e9efacea8b04aed07efd568bc06f450e0
size 150126
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b3fc285832442e302533f1b4ad593618b677a89c7d73db57085aa3fe6ed03cfa
size 146614
oid sha256:f56532ab4444f2b83fa58ee52cc6e8fe9370bf33dd1fbe411bff843fdc8744d6
size 152141
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:92382bb10c78fb31d2736089f78cf70a78929542ba374959ade0954155864af1
size 88014
oid sha256:44ab513cb0d75b8665c8a0672aac12da40a1c1e620f002f0bee426f904dfdd4b
size 92914
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e54dede9347e647422d7f84c4a8f0e7fb48a5f01965110ceb2629a9bb874708
size 88915
oid sha256:e0ca5e74f5d67f2f494df163a5d04d820bde4144f2d3ece3a0a082f5176e4656
size 93836
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:642e6b0ed22b29ca18c501193f44c307c150d8282ce4ff666d264c99e8055531
size 90187
oid sha256:b2e3f268b8061428159124d723cd7226d5cd651fba44bc5e2d0dc27ff20e9d41
size 86287
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08095d0d99f6d2d02f5b4064d0197647b4e71ae731494bde5d076e597ed7151d
size 91259
oid sha256:990a7554e18ebedbf097699e682af78fb3e40523a72a6b7ad35d3c91bd01383c
size 87338
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:905dcb4978da9630055c40ba9b5a0186c3fe1a5b92460f3fc1fab273f60bb5cc
size 43584
oid sha256:a33973e5a643555e3ed785f1f34f7fff71e67f2be8099f1c4e654117dec62d05
size 39920
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7185a60c207517a7c804d577f285fa94311fdb206c30fd42910400a88f96d054
size 44153
oid sha256:673f2f990cb59bf7b2f6e5e1fe25ce0dabb5a4c380ff69e2ee1a0a3ad1aa0cea
size 40373
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f4a59dc601b2272595612cc15929689e4d908e3443e6354e549dcd22d94d51d
size 209057
oid sha256:5d1cee861368f1ff16fe9046f94033574f715b9cc517246eafca1b8d5ad2b52d
size 205021
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1b4bb1ac2b44f08067e8e290c469572e9d778d8d7d900d1572b19f884b9a5d5
size 212362
oid sha256:6db9d709a5e97790d355d95f22432984964294c7a923f002d282ed19a3edf835
size 208254
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98f071a43f4d4b0ed53abaedd96ebc5713485aaff4cfdc06fc71165934a93313
size 146556
oid sha256:9474ca31071c9dafb275f1c00245d63590f2d3b6cc063a8193c38abb83f70720
size 144717
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2dd55299a8dd12aaaa833f73f8986a5b0c970cf38d7936c396162fe8bfebc298
size 149858
oid sha256:240cbd13759c06666d4cd34d23e054b2166a115ad0ea878619e1a19563765394
size 147531
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b438428319dc35cd36119e2f1268ad4e7233d5944784ee79a40d72529b8ed7ea
size 216228
oid sha256:45146b4cba38e487a255a30f521274416921068cf75d640cf7a153139c7239d5
size 212374
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f236a885a1f4eff5d8897910c22be6ccea17f36c9e5370816681eb84d3cec471
size 222241
oid sha256:53068a58b7f9f8c338c7fe528da9c0115f91df80c1de27cd33c1c8aeaf9228ea
size 218748
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3162a5a49b5fcd25ac7880ecd7837ef7c1efa3599d1b1fa0065dbd43b645dcaf
size 152742
oid sha256:6727a8eb29ec64e3a1065c7564aed13db06059fe8b3863d433cb41a4162f66b3
size 149930
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e5bd2e69da096c9c913fc90507afedb23e4dd7b61c4c1ac360da8cb9f442080
size 158688
oid sha256:864f452d4ca827d1e64b2a3345b6c09a45108ce087640d4b8075820003198348
size 155737

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