mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Completion Suggestion view for user mentions (#1859)
* created the list but I need to find a way to overlay it * make the list able to have an intrinsic height * best solution so far but does not work with expansion * needs testing * more scalable solution * tests completed * changelog * injecting the media provider * fix tests * pr suggestions * better testing
This commit is contained in:
@@ -340,10 +340,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
|
||||
|
||||
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy, areSuggestionsEnabled: appSettings.mentionsEnabled)
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
emojiProvider: emojiProvider)
|
||||
emojiProvider: emojiProvider,
|
||||
completionSuggestionService: completionSuggestionService)
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
|
||||
@@ -197,6 +197,35 @@ class BugReportServiceMock: BugReportServiceProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
||||
var areSuggestionsEnabled: Bool {
|
||||
get { return underlyingAreSuggestionsEnabled }
|
||||
set(value) { underlyingAreSuggestionsEnabled = value }
|
||||
}
|
||||
var underlyingAreSuggestionsEnabled: Bool!
|
||||
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> {
|
||||
get { return underlyingSuggestionsPublisher }
|
||||
set(value) { underlyingSuggestionsPublisher = value }
|
||||
}
|
||||
var underlyingSuggestionsPublisher: AnyPublisher<[SuggestionItem], Never>!
|
||||
|
||||
//MARK: - setSuggestionTrigger
|
||||
|
||||
var setSuggestionTriggerCallsCount = 0
|
||||
var setSuggestionTriggerCalled: Bool {
|
||||
return setSuggestionTriggerCallsCount > 0
|
||||
}
|
||||
var setSuggestionTriggerReceivedSuggestionTrigger: SuggestionPattern?
|
||||
var setSuggestionTriggerReceivedInvocations: [SuggestionPattern?] = []
|
||||
var setSuggestionTriggerClosure: ((SuggestionPattern?) -> Void)?
|
||||
|
||||
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) {
|
||||
setSuggestionTriggerCallsCount += 1
|
||||
setSuggestionTriggerReceivedSuggestionTrigger = suggestionTrigger
|
||||
setSuggestionTriggerReceivedInvocations.append(suggestionTrigger)
|
||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
||||
}
|
||||
}
|
||||
class NotificationCenterMock: NotificationCenterProtocol {
|
||||
|
||||
//MARK: - post
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import WysiwygComposer
|
||||
|
||||
enum SuggestionItem: Identifiable, Equatable {
|
||||
case user(item: MentionSuggestionItem)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .user(let user):
|
||||
return user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MentionSuggestionItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
}
|
||||
|
||||
extension WysiwygComposer.SuggestionPattern {
|
||||
var toElementPattern: SuggestionPattern? {
|
||||
switch key {
|
||||
case .at:
|
||||
return SuggestionPattern(type: .user, text: text)
|
||||
// Not yet supported
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
|
||||
let areSuggestionsEnabled: Bool
|
||||
let suggestionsPublisher: AnyPublisher<[SuggestionItem], Never>
|
||||
|
||||
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionPattern?, Never>(nil)
|
||||
|
||||
init(roomProxy: RoomProxyProtocol, areSuggestionsEnabled: Bool) {
|
||||
self.areSuggestionsEnabled = areSuggestionsEnabled
|
||||
guard areSuggestionsEnabled else {
|
||||
suggestionsPublisher = Empty().eraseToAnyPublisher()
|
||||
return
|
||||
}
|
||||
suggestionsPublisher = suggestionTriggerSubject
|
||||
.combineLatest(roomProxy.members)
|
||||
.map { suggestionTrigger, members -> [SuggestionItem] in
|
||||
guard let suggestionTrigger else {
|
||||
return []
|
||||
}
|
||||
|
||||
switch suggestionTrigger.type {
|
||||
case .user:
|
||||
return members.filter { member in
|
||||
guard !member.isAccountOwner && member.membership == .join else {
|
||||
return false
|
||||
}
|
||||
|
||||
let containedInUserID = member.userID.localizedStandardContains(suggestionTrigger.text.lowercased())
|
||||
|
||||
let containedInDisplayName: Bool
|
||||
if let displayName = member.displayName {
|
||||
containedInDisplayName = displayName.localizedStandardContains(suggestionTrigger.text.lowercased())
|
||||
} else {
|
||||
containedInDisplayName = false
|
||||
}
|
||||
|
||||
return containedInUserID || containedInDisplayName
|
||||
}
|
||||
.map { SuggestionItem.user(item: .init(id: $0.userID, displayName: $0.displayName, avatarURL: $0.avatarURL)) }
|
||||
}
|
||||
}
|
||||
.debounce(for: 0.5, scheduler: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?) {
|
||||
suggestionTriggerSubject.value = suggestionTrigger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol CompletionSuggestionServiceProtocol {
|
||||
// To be removed once we suggestions and mentions are always enabled
|
||||
var areSuggestionsEnabled: Bool { get }
|
||||
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
|
||||
|
||||
func setSuggestionTrigger(_ suggestionTrigger: SuggestionPattern?)
|
||||
}
|
||||
|
||||
extension CompletionSuggestionServiceMock {
|
||||
struct CompletionSuggestionServiceMockConfiguration {
|
||||
var areSuggestionsEnabled = true
|
||||
var suggestions: [SuggestionItem] = []
|
||||
}
|
||||
|
||||
convenience init(configuration: CompletionSuggestionServiceMockConfiguration) {
|
||||
self.init()
|
||||
underlyingAreSuggestionsEnabled = configuration.areSuggestionsEnabled
|
||||
underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ enum ComposerToolbarViewAction {
|
||||
struct ComposerToolbarViewState: BindableState {
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
var composerEmpty = true
|
||||
var areSuggestionsEnabled = true
|
||||
var suggestions: [SuggestionItem] = []
|
||||
|
||||
var bindings: ComposerToolbarViewStateBindings
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarView
|
||||
|
||||
final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol {
|
||||
private let wysiwygViewModel: WysiwygComposerViewModel
|
||||
private let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
@@ -37,10 +38,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
|
||||
private var currentLinkData: WysiwygLinkData?
|
||||
|
||||
init(wysiwygViewModel: WysiwygComposerViewModel) {
|
||||
init(wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol) {
|
||||
self.wysiwygViewModel = wysiwygViewModel
|
||||
self.completionSuggestionService = completionSuggestionService
|
||||
|
||||
super.init(initialViewState: ComposerToolbarViewState(bindings: .init()))
|
||||
super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, bindings: .init()), imageProvider: mediaProvider)
|
||||
|
||||
context.$viewState
|
||||
.map(\.composerMode)
|
||||
@@ -69,6 +71,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
.weakAssign(to: \.state.bindings.formatItems, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
wysiwygViewModel.$suggestionPattern
|
||||
.sink { [weak self] suggestionPattern in
|
||||
self?.completionSuggestionService.setSuggestionTrigger(suggestionPattern?.toElementPattern)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
completionSuggestionService.suggestionsPublisher
|
||||
.weakAssign(to: \.state.suggestions, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
|
||||
import Combine
|
||||
|
||||
struct SuggestionPattern: Equatable {
|
||||
enum SuggestionType: Equatable {
|
||||
case user
|
||||
}
|
||||
|
||||
let type: SuggestionType
|
||||
let text: String
|
||||
}
|
||||
|
||||
protocol ComposerToolbarViewModelProtocol {
|
||||
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
|
||||
var context: ComposerToolbarViewModelType.Context { get }
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// Copyright 2021 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.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CompletionSuggestionView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
let items: [SuggestionItem]
|
||||
|
||||
private enum Constants {
|
||||
static let topPadding: CGFloat = 8.0
|
||||
static let listItemPadding: CGFloat = 4.0
|
||||
static let lineSpacing: CGFloat = 10.0
|
||||
static let maxHeight: CGFloat = 300.0
|
||||
static let maxVisibleRows = 4
|
||||
|
||||
/*
|
||||
As of iOS 16.0, SwiftUI's List uses `UICollectionView` instead
|
||||
of `UITableView` internally, this value is an adjustment to apply
|
||||
to the list items in order to be as close as possible as the
|
||||
`UITableView` display.
|
||||
*/
|
||||
@available(iOS 16.0, *)
|
||||
static let collectionViewPaddingCorrection: CGFloat = -5.0
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var showBackgroundShadow = true
|
||||
@State private var prototypeListItemFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
if items.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ZStack {
|
||||
MentionSuggestionItemView(imageProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil))
|
||||
.background(ViewFrameReader(frame: $prototypeListItemFrame))
|
||||
.hidden()
|
||||
if showBackgroundShadow {
|
||||
BackgroundView {
|
||||
list()
|
||||
}
|
||||
} else {
|
||||
list()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func list() -> some View {
|
||||
List(items) { item in
|
||||
Button { } label: {
|
||||
switch item {
|
||||
case .user(let mention):
|
||||
MentionSuggestionItemView(imageProvider: imageProvider, item: mention)
|
||||
}
|
||||
}
|
||||
.modifier(ListItemPaddingModifier(isFirst: items.first?.id == item.id))
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.frame(height: min(Constants.maxHeight,
|
||||
min(contentHeightForRowCount(Constants.maxVisibleRows),
|
||||
contentHeightForRowCount(items.count))))
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
}
|
||||
|
||||
private func contentHeightForRowCount(_ count: Int) -> CGFloat {
|
||||
(prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding
|
||||
}
|
||||
|
||||
private struct ListItemPaddingModifier: ViewModifier {
|
||||
private let isFirst: Bool
|
||||
|
||||
init(isFirst: Bool) {
|
||||
self.isFirst = isFirst
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
var topPadding: CGFloat = isFirst ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding
|
||||
var bottomPadding: CGFloat = Constants.listItemPadding
|
||||
if #available(iOS 16.0, *) {
|
||||
topPadding += Constants.collectionViewPaddingCorrection
|
||||
bottomPadding += Constants.collectionViewPaddingCorrection
|
||||
}
|
||||
|
||||
return content
|
||||
.padding(.top, topPadding)
|
||||
.padding(.bottom, bottomPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BackgroundView<Content: View>: View {
|
||||
var content: () -> Content
|
||||
|
||||
private let shadowRadius: CGFloat = 20.0
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
.clipShape(RoundedCornerShape(radius: shadowRadius, corners: [.topLeft, .topRight]))
|
||||
.shadow(color: .black.opacity(0.20), radius: 20.0, x: 0.0, y: 3.0)
|
||||
.mask(Rectangle().padding(.init(top: -(shadowRadius * 2), leading: 0.0, bottom: 0.0, trailing: 0.0)))
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview {
|
||||
static let multipleItems: [SuggestionItem] = (0...10).map { index in
|
||||
SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil))
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
// Putting them is VStack allows the preview to work properly in tests
|
||||
VStack {
|
||||
CompletionSuggestionView(imageProvider: MockMediaProvider(),
|
||||
items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)),
|
||||
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))])
|
||||
}
|
||||
VStack {
|
||||
CompletionSuggestionView(imageProvider: MockMediaProvider(),
|
||||
items: multipleItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ struct ComposerToolbar: View {
|
||||
@FocusState private var composerFocused: Bool
|
||||
@ScaledMetric private var sendButtonIconSize = 16
|
||||
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
|
||||
|
||||
@State private var frame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
@@ -34,8 +36,21 @@ struct ComposerToolbar: View {
|
||||
bottomBar
|
||||
}
|
||||
}
|
||||
.background {
|
||||
ViewFrameReader(frame: $frame)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if context.viewState.areSuggestionsEnabled {
|
||||
suggestionView
|
||||
.offset(y: -frame.height)
|
||||
}
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
private var suggestionView: some View {
|
||||
CompletionSuggestionView(imageProvider: context.imageProvider, items: context.viewState.suggestions)
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(alignment: .bottom, spacing: 5) {
|
||||
@@ -176,8 +191,24 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
|
||||
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
static let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
|
||||
mediaProvider: MockMediaProvider())
|
||||
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil)),
|
||||
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory))]
|
||||
|
||||
static var previews: some View {
|
||||
ComposerToolbar.mock()
|
||||
|
||||
// Putting them is VStack allows the completion suggestion preview to work properly in tests
|
||||
VStack {
|
||||
// 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,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommandHandler: { _ in false })
|
||||
}
|
||||
.previewDisplayName("With Suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +217,9 @@ struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
extension ComposerToolbar {
|
||||
static func mock() -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
|
||||
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider())
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommandHandler: { _ in false })
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MentionSuggestionItemView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
let item: MentionSuggestionItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
LoadableAvatarImage(url: item.avatarURL,
|
||||
name: item.displayName,
|
||||
contentID: item.id,
|
||||
avatarSize: .custom(42),
|
||||
imageProvider: imageProvider)
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.displayName ?? item.id)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
if item.displayName != nil {
|
||||
Text(item.id)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview {
|
||||
static let mockMediaProvider = MockMediaProvider()
|
||||
|
||||
static var previews: some View {
|
||||
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: URL.documentsDirectory))
|
||||
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil))
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ struct RoomAttachmentPicker: View {
|
||||
}
|
||||
|
||||
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel())
|
||||
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider())
|
||||
|
||||
static var previews: some View {
|
||||
RoomAttachmentPicker(context: viewModel.context)
|
||||
|
||||
@@ -24,6 +24,7 @@ struct RoomScreenCoordinatorParameters {
|
||||
let timelineController: RoomTimelineControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let emojiProvider: EmojiProviderProtocol
|
||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
}
|
||||
|
||||
enum RoomScreenCoordinatorAction {
|
||||
@@ -54,7 +55,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
|
||||
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
roomProxy: parameters.roomProxy,
|
||||
@@ -66,7 +67,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
maxCompressedHeight: ComposerConstant.maxHeight,
|
||||
maxExpandedHeight: ComposerConstant.maxHeight,
|
||||
parserStyle: .elementX)
|
||||
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
|
||||
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, completionSuggestionService: parameters.completionSuggestionService, mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -233,7 +233,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
appSettings.$readReceiptsEnabled
|
||||
.weakAssign(to: \.state.readReceiptsEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
||||
roomProxy.members
|
||||
.map { members in
|
||||
members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
|
||||
|
||||
@@ -235,7 +235,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -244,7 +245,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -255,7 +257,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -266,7 +269,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -278,7 +282,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
@@ -292,7 +297,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -306,7 +312,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -320,7 +327,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -335,7 +343,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -349,7 +358,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -363,7 +373,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -377,7 +388,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
@@ -391,7 +403,8 @@ class MockScreen: Identifiable {
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
emojiProvider: EmojiProvider())
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
Reference in New Issue
Block a user