mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-13
@@ -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(),
|
||||
|
||||
+8
-7
@@ -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,
|
||||
|
||||
+35
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -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()
|
||||
|
||||
|
||||
+65
@@ -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))
|
||||
}
|
||||
|
||||
+2
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user