Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rich text editor "expanded mode" #1656

Merged
merged 17 commits into from
Sep 11, 2023
4 changes: 4 additions & 0 deletions ElementX.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; };
245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA689E792E679F5E3956F21 /* UITimelineView.swift */; };
24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; };
24B7CD41342C143117ADA768 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B1CC9AA154F4D5435BF60A /* Comparable.swift */; };
24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */; };
25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */; };
256D76972BA3254F7CB7F88B /* LocationAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */; };
Expand Down Expand Up @@ -1491,6 +1492,7 @@
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarSize.swift; sourceTree = "<group>"; };
E26747B3154A5DBC3A7E24A5 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = "<group>"; };
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = "<group>"; };
E2DCA495ED42D2463DDAA94D /* TimelineBubbleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBubbleLayout.swift; sourceTree = "<group>"; };
E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModel.swift; sourceTree = "<group>"; };
E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2205,6 +2207,7 @@
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */,
B6E89E530A8E92EC44301CA1 /* Bundle.swift */,
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */,
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */,
B383DCD3DCB19E00FD478A5F /* ConfirmationDialog.swift */,
2141693488CE5446BB391964 /* Date.swift */,
BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */,
Expand Down Expand Up @@ -4460,6 +4463,7 @@
9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */,
0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */,
663E198678778F7426A9B27D /* Collection.swift in Sources */,
24B7CD41342C143117ADA768 /* Comparable.swift in Sources */,
0B57C2399B9E1CE5CE0D8005 /* ComposerToolbar.swift in Sources */,
56BAB81A0D03C2EF09B86294 /* ComposerToolbarModels.swift in Sources */,
5995C63B1C61DE1373AA2BCE /* ComposerToolbarViewModel.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "199",
"green" : "197",
"red" : "197"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "89",
"green" : "83",
"red" : "81"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
1 change: 1 addition & 0 deletions ElementX/Sources/Generated/Assets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal enum Asset {
internal enum Colors {
internal static let accentColor = ColorAsset(name: "colors/accent-color")
internal static let backgroundColor = ColorAsset(name: "colors/background-color")
internal static let grabber = ColorAsset(name: "colors/grabber")
}
internal enum Images {
internal static let appLogo = ImageAsset(name: "images/app-logo")
Expand Down
21 changes: 21 additions & 0 deletions ElementX/Sources/Other/Extensions/Comparable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
min(max(self, limits.lowerBound), limits.upperBound)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ struct ComposerToolbarViewStateBindings {
var composerPlainText = ""
var composerFocused = false
var composerActionsEnabled = false
var composerExpanded = false
var formatItems: [FormatItem] = .init()
var alertInfo: AlertInfo<UUID>?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText,
mode: state.composerMode))
}
state.bindings.composerActionsEnabled = false
case .cancelReply:
set(mode: .default)
case .cancelEdit:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ struct ComposerToolbar: View {
}
messageComposer
.environmentObject(context)
.onTapGesture {
guard !composerFocused else { return }
composerFocused = true
}
if !context.composerActionsEnabled {
sendButton
}
Expand All @@ -53,6 +57,7 @@ struct ComposerToolbar: View {
HStack(alignment: .bottom, spacing: 10) {
Button {
context.composerActionsEnabled = false
context.composerExpanded = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.compound.headingLG)
Expand Down Expand Up @@ -89,7 +94,9 @@ struct ComposerToolbar: View {
private var messageComposer: some View {
MessageComposer(plainText: $context.composerPlainText,
composerView: composerView,
mode: context.viewState.composerMode) {
mode: context.viewState.composerMode,
showResizeGrabber: context.viewState.bindings.composerActionsEnabled,
isExpanded: $context.composerExpanded) {
context.send(viewAction: .sendMessage)
} pasteAction: { provider in
context.send(viewAction: .handlePasteOrDrop(provider: provider))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ struct MessageComposer: View {
@Binding var plainText: String
let composerView: WysiwygComposerView
let mode: RoomScreenComposerMode
let showResizeGrabber: Bool
@Binding var isExpanded: Bool
let sendAction: EnterKeyHandler
let pasteAction: PasteHandler
let replyCancellationAction: () -> Void
Expand All @@ -32,14 +34,43 @@ struct MessageComposer: View {
@FocusState private var focused: Bool

@State private var isMultiline = false
@State private var composerTranslation: CGFloat = 0

var body: some View {
let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius)
VStack(spacing: 0) {
if showResizeGrabber {
resizeGrabber
}

mainContent
.padding(.horizontal, 12.0)
.clipShape(RoundedRectangle(cornerRadius: borderRadius))
.background {
let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius)
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
roundedRectangle
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
.opacity(focused ? 1 : 0)
}
}
// Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: mode)
}
.gesture(showResizeGrabber ? dragGesture : nil)
}

// MARK: - Private

private var mainContent: some View {
VStack(alignment: .leading, spacing: -6) {
header
HStack(alignment: .bottom) {
if ServiceLocator.shared.settings.richTextEditorEnabled {
composerView
.frame(minHeight: composerHeight, alignment: .top)
.tint(.compound.iconAccentTertiary)
.padding(.vertical, 10)
.focused($focused)
Expand All @@ -59,20 +90,11 @@ struct MessageComposer: View {
}
}
}
.padding(.horizontal, 12.0)
.clipped()
.background {
ZStack {
roundedRectangle
.fill(Color.compound.bgSubtleSecondary)
roundedRectangle
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
.opacity(focused ? 1 : 0)
}
}
// Explicitly disable all animations to fix weirdness with the header immediately
// appearing whilst the text field and keyboard are still animating up to it.
.animation(.noAnimation, value: mode)
}

private var composerHeight: CGFloat {
let baseHeight = isExpanded ? ComposerConstant.maxHeight : ComposerConstant.minHeight
return (baseHeight - composerTranslation).clamped(to: ComposerConstant.allowedHeightRange)
}

@ViewBuilder
Expand All @@ -95,6 +117,31 @@ struct MessageComposer: View {
return 20
}
}

private var resizeGrabber: some View {
Capsule()
.foregroundColor(Asset.Colors.grabber.swiftUIColor)
.frame(width: 36, height: 5)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
}

private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
composerTranslation += value.translation.height
}
.onEnded { _ in
withElementAnimation(.easeIn(duration: 0.3)) {
if composerTranslation > ComposerConstant.translationThreshold {
isExpanded = false
} else if composerTranslation < -ComposerConstant.translationThreshold {
isExpanded = true
}
composerTranslation = 0
}
}
}
}

private struct MessageComposerReplyHeader: View {
Expand Down Expand Up @@ -169,6 +216,8 @@ struct MessageComposer_Previews: PreviewProvider {
return MessageComposer(plainText: .constant(content),
composerView: composerView,
mode: mode,
showResizeGrabber: false,
isExpanded: .constant(false),
sendAction: { },
pasteAction: { _ in },
replyCancellationAction: { },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)

wysiwygViewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight)
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
}

Expand Down Expand Up @@ -124,3 +126,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar))
}
}

enum ComposerConstant {
static let minHeight: CGFloat = 22
static let maxHeight: CGFloat = 250
static let allowedHeightRange = minHeight...maxHeight
static let translationThreshold: CGFloat = 60
}
21 changes: 20 additions & 1 deletion ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,39 @@ import WysiwygComposer

struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
@State private var dragOver = false
let composerToolbar: ComposerToolbar

private let attachmentButtonPadding = 10.0

init(context: RoomScreenViewModel.Context, composerToolbar: ComposerToolbar) {
self.context = context
self.composerToolbar = composerToolbar
composerToolbarContext = composerToolbar.context
}

var body: some View {
timeline
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.safeAreaInset(edge: .bottom, spacing: 0) {
composerToolbar
.padding(.leading, attachmentButtonPadding)
.padding(.trailing, 12)
.padding(.top, 8)
.padding(.bottom)
.background {
if composerToolbarContext.composerActionsEnabled {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
.ignoresSafeArea()
}
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(context)
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(isNavigationBarHidden)
.toolbar { toolbar }
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
Expand Down Expand Up @@ -142,6 +157,10 @@ struct RoomScreen: View {
RoomHeaderView(context: context)
}
}

private var isNavigationBarHidden: Bool {
composerToolbarContext.composerActionsEnabled && composerToolbarContext.composerExpanded && UIDevice.current.userInterfaceIdiom == .pad
}
}

// MARK: - Previews
Expand Down
6 changes: 4 additions & 2 deletions ElementX/Sources/UITests/UITestsAppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,9 @@ class MockScreen: Identifiable {
var sessionVerificationControllerProxy = SessionVerificationControllerProxyMock.configureMock(requestDelay: .seconds(5))
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationControllerProxy)
return SessionVerificationScreenCoordinator(parameters: parameters)
case .userSessionScreen, .userSessionScreenReply:
case .userSessionScreen, .userSessionScreenReply, .userSessionScreenRTE:
let appSettings: AppSettings = ServiceLocator.shared.settings
appSettings.richTextEditorEnabled = id == .userSessionScreenRTE
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())

let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)))
Expand All @@ -383,7 +385,7 @@ class MockScreen: Identifiable {
navigationSplitCoordinator: navigationSplitCoordinator,
bugReportService: BugReportServiceMock(),
roomTimelineControllerFactory: MockRoomTimelineControllerFactory(),
appSettings: ServiceLocator.shared.settings,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics)

coordinator.start()
Expand Down
1 change: 1 addition & 0 deletions ElementX/Sources/UITests/UITestsScreenIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ enum UITestsScreenIdentifier: String {
case sessionVerification
case userSessionScreen
case userSessionScreenReply
case userSessionScreenRTE
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomDetailsScreenWithEmptyTopic
Expand Down
15 changes: 15 additions & 0 deletions UITests/Sources/UserSessionScreenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,19 @@ class UserSessionScreenTests: XCTestCase {

try await app.assertScreenshot(.userSessionScreenReply)
}

func testUserSessionRTE() async throws {
let roomName = "First room"
let app = Application.launch(.userSessionScreenRTE)

app.buttons[A11yIdentifiers.homeScreen.roomName(roomName)].tap()
XCTAssert(app.staticTexts[roomName].waitForExistence(timeout: 5.0))
try await Task.sleep(for: .seconds(1))

app.buttons[A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions].tap()
try await app.assertScreenshot(.userSessionScreenRTE, step: 1)

app.buttons[A11yIdentifiers.roomScreen.attachmentPickerTextFormatting].tap()
try await app.assertScreenshot(.userSessionScreenRTE, step: 2)
}
}
Loading