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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 {
|
||||
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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user