Various tweaks (#381)

* Add the message delivery status to the plain timeline styler and display it outside of the main content

* Fix glitchy timeline scroll to bottom button

* Fixed bubbled reactions padding

* Simplified the emoji picker, double tapping a timeline item directly opens it now and added a context menu option. Linked it to rust side reaction sending

* Fix the sliding sync cold cache: treat invalidated rooms as filled

* Make splash screen view full screen

* Fix the offline indicator popping up when first setting up the network monitor

* Expose presentationDetents on the NavigationStackCoordinator and start using them for the EmojiPicker

* Fix link tint color

* Linked TimelineReactionsView reaction sending

* Remove now unused/unnecessary classes

* Add changelog

* Fix formatting issue
This commit is contained in:
Stefan Ceriu
2022-12-21 11:18:45 +02:00
committed by GitHub
parent 5b9d45e461
commit 71f1f044c7
33 changed files with 170 additions and 534 deletions
@@ -377,6 +377,8 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS
}
}
var presentationDetents: Set<PresentationDetent> = []
// The currently presented sheet coordinator
// Sheets will be presented through the NavigationSplitCoordinator if provided
var sheetCoordinator: (any CoordinatorProtocol)? {
@@ -489,7 +491,8 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationStackCoordinatorView(navigationStackCoordinator: self))
AnyView(NavigationStackCoordinatorView(navigationStackCoordinator: self)
.presentationDetents(presentationDetents))
}
// MARK: - CustomStringConvertible
@@ -30,6 +30,8 @@ extension ElementL10n {
public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title")
/// Info
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
/// Failed loading messages
public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure")
/// Editing
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
/// Failed creating the permalink
-70
View File
@@ -1,70 +0,0 @@
//
// Copyright 2022 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
struct Benchmark {
private static var trackingIdentifiers = [String: CFAbsoluteTime]()
public static var trackingEnabled = false
static func startTrackingForIdentifier(_ identifier: String, message: String? = nil) {
guard trackingEnabled else {
return
}
let startTime = CFAbsoluteTimeGetCurrent()
trackingIdentifiers[identifier] = startTime
if let message {
MXLog.verbose("⏰ \(message).")
}
}
static func logElapsedDurationForIdentifier(_ identifier: String, message: String? = nil) {
guard trackingEnabled else {
return
}
guard let start = trackingIdentifiers[identifier] else {
assertionFailure("⏰ Invalid tracking identifier")
return
}
let elapsedTime = CFAbsoluteTimeGetCurrent() - start
if let message {
MXLog.verbose("⏰ \(message). Elapsed time: \(elapsedTime.round(to: 4)) seconds.")
} else {
MXLog.verbose("⏰ Elapsed time: \(elapsedTime.round(to: 4)) seconds.")
}
}
static func endTrackingForIdentifier(_ identifier: String, message: String? = nil) {
guard trackingEnabled else {
return
}
logElapsedDurationForIdentifier(identifier, message: message)
trackingIdentifiers[identifier] = nil
}
}
private extension Double {
func round(to places: Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
@@ -1,123 +0,0 @@
//
// Copyright 2022 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 UIKit
import Vision
enum ImageAnonymizerError: Error {
case noCgImageBased
}
enum ImageAnonymizer {
private static var allowedTextItems: [String] = [
"#",
"@",
"%",
"&",
"+",
"-",
"_",
"\"",
"?",
"*"
]
static func anonymizedImage(from image: UIImage,
confidenceLevel: Float = 0.5,
fillColor: UIColor = .red) async throws -> UIImage {
guard let cgImage = image.cgImage else {
throw ImageAnonymizerError.noCgImageBased
}
// create a handler with cgImage
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
var observations: [VNDetectedObjectObservation] = []
// create a text request
let textRequest = VNRecognizeTextRequest { request, error in
guard let results = request.results as? [VNRecognizedTextObservation],
error == nil else {
return
}
observations.append(contentsOf: results)
}
textRequest.recognitionLevel = .accurate
textRequest.revision = VNRecognizeTextRequestRevision2
// create a face request
let faceRequest = VNDetectFaceRectanglesRequest { request, error in
guard let results = request.results as? [VNFaceObservation],
error == nil else {
return
}
observations.append(contentsOf: results)
}
// revision3 doesn't work!
faceRequest.revision = VNDetectFaceRectanglesRequestRevision2
#if targetEnvironment(simulator)
// Avoid `Could not create inference context` errors on Apple Silicon
// https://www.caseyliss.com/2022/6/20/feedback-is-broken-stop-trying-to-make-radar-happen
faceRequest.usesCPUOnly = true
#endif
// perform requests
try handler.perform([
textRequest,
faceRequest
])
return render(image: image,
confidenceLevel: confidenceLevel,
fillColor: fillColor,
observations: observations)
}
private static func render(image: UIImage,
confidenceLevel: Float,
fillColor: UIColor,
observations: [VNDetectedObjectObservation]) -> UIImage {
let size = image.size
let result = UIGraphicsImageRenderer(size: size).image { rendererContext in
// first draw self
image.draw(in: CGRect(origin: .zero, size: size))
// set fill color
fillColor.setFill()
for observation in observations {
guard observation.confidence >= confidenceLevel else {
// ensure observation's confidence level
continue
}
if let textObservation = observation as? VNRecognizedTextObservation,
let text = textObservation.topCandidates(1).first?.string {
if Double(text) != nil || Self.allowedTextItems.contains(text) {
continue
}
}
let box = observation.boundingBox
// boc is normalized (and in starts from the lower left corner)
// convert it to a rect in the image
let rect = CGRect(x: box.minX * size.width,
y: size.height - box.maxY * size.height,
width: box.width * size.width,
height: box.height * size.height)
rendererContext.fill(rect)
}
}
return result
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ class NetworkMonitor {
init() {
queue = DispatchQueue(label: "io.element.elementx.networkmonitor")
pathMonitor = NWPathMonitor()
reachabilityPublisher = CurrentValueSubject<Bool, Never>(pathMonitor.currentPath.status == .satisfied)
reachabilityPublisher = CurrentValueSubject<Bool, Never>(true)
pathMonitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
@@ -53,23 +53,11 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
private func submitBugReport() async {
callback?(.submitStarted)
do {
var files: [URL] = []
if let screenshot = state.screenshot {
let anonymized = try await ImageAnonymizer.anonymizedImage(from: screenshot)
let tmpUrl = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("screenshot").appendingPathExtension("png")
// remove old screenshot if exists
if FileManager.default.fileExists(atPath: tmpUrl.path) {
try FileManager.default.removeItem(at: tmpUrl)
}
try anonymized.dataForPNGRepresentation().write(to: tmpUrl)
files.append(tmpUrl)
}
let result = try await bugReportService.submitBugReport(text: context.reportText,
includeLogs: context.sendingLogsEnabled,
includeCrashLog: true,
githubLabels: ServiceLocator.shared.settings.bugReportGHLabels,
files: files)
files: [])
MXLog.info("SubmitBugReport succeeded, result: \(result.reportUrl)")
callback?(.submitFinished)
} catch {
@@ -23,6 +23,7 @@ struct EmojiPickerScreenCoordinatorParameters {
enum EmojiPickerScreenCoordinatorAction {
case emojiSelected(emoji: String, itemId: String)
case dismiss
}
final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
@@ -44,12 +45,13 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
switch action {
case let .emojiSelected(emoji: emoji):
self.callback?(.emojiSelected(emoji: emoji, itemId: self.parameters.itemId))
case .dismiss:
self.callback?(.dismiss)
}
}
}
func toPresentable() -> AnyView {
AnyView(EmojiPickerScreen(context: viewModel.context)
.presentationDetents([.medium, .large]))
AnyView(EmojiPickerScreen(context: viewModel.context))
}
}
@@ -18,6 +18,7 @@ import Foundation
enum EmojiPickerScreenViewModelAction {
case emojiSelected(emoji: String)
case dismiss
}
struct EmojiPickerScreenViewState: BindableState {
@@ -27,6 +28,7 @@ struct EmojiPickerScreenViewState: BindableState {
enum EmojiPickerScreenViewAction {
case search(searchString: String)
case emojiTapped(emoji: EmojiPickerEmojiViewData)
case dismiss
}
struct EmojiPickerEmojiCategoryViewData: Identifiable {
@@ -39,6 +39,8 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr
state.categories = convert(emojiCategories: categories)
case let .emojiTapped(emoji: emoji):
callback?(.emojiSelected(emoji: emoji.value))
case .dismiss:
callback?(.dismiss)
}
}
@@ -21,39 +21,49 @@ struct EmojiPickerScreen: View {
@State var searchString = ""
var body: some View {
VStack {
Text(ElementL10n.reactions)
.padding(.top, 20)
EmojiPickerSearchFieldView(searchString: $searchString)
.padding(.horizontal, 10)
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 45))], spacing: 3) {
ForEach(context.viewState.categories) { category in
Section(header: EmojiPickerHeaderView(title: category.name)
.padding(.horizontal, 13)
.padding(.top, 10)) {
ForEach(category.emojis) { emoji in
Text(emoji.value)
.frame(width: 45, height: 45)
.onTapGesture {
context.send(viewAction: .emojiTapped(emoji: emoji))
}
}
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 45))], spacing: 3) {
ForEach(context.viewState.categories) { category in
Section(header: EmojiPickerHeaderView(title: category.name)
.padding(.horizontal, 13)
.padding(.top, 10)) {
ForEach(category.emojis) { emoji in
Text(emoji.value)
.frame(width: 45, height: 45)
.onTapGesture {
context.send(viewAction: .emojiTapped(emoji: emoji))
}
}
}
}
}
}
}
.navigationTitle(ElementL10n.reactions)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.searchable(text: $searchString)
.onChange(of: searchString) { _ in
context.send(viewAction: .search(searchString: searchString))
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .dismiss) } label: {
Text(ElementL10n.actionCancel)
}
.accessibilityIdentifier("dismissButton")
}
}
}
// MARK: - Previews
struct EmojiPickerScreen_Previews: PreviewProvider {
static var previews: some View {
EmojiPickerScreen(context: EmojiPickerScreenViewModel(emojiProvider: EmojiProvider()).context)
NavigationStack {
EmojiPickerScreen(context: EmojiPickerScreenViewModel(emojiProvider: EmojiProvider()).context)
}
}
}
@@ -1,45 +0,0 @@
//
// Copyright 2022 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 EmojiPickerSearchFieldView: View {
@Binding var searchString: String
@FocusState private var isSearchFocused: Bool
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
TextField(ElementL10n.search, text: $searchString)
.focused($isSearchFocused)
if isSearchFocused {
Spacer()
Button {
searchString = ""
isSearchFocused = false
} label: {
Text(ElementL10n.actionCancel)
}
}
}
}
}
struct EmojiPickerSearchFieldView_Previews: PreviewProvider {
static var previews: some View {
EmojiPickerSearchFieldView(searchString: .constant(""))
}
}
@@ -19,7 +19,10 @@ import SwiftUI
struct SplashScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(
Image(asset: Asset.Images.appLogo)
ZStack {
Image(asset: Asset.Images.appLogo)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
@@ -107,6 +107,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
let timelineController = parameters?.timelineController else {
fatalError()
}
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
itemId: itemId)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
@@ -118,10 +121,15 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
Task {
await timelineController.sendReaction(emoji, for: itemId)
}
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
navigationStackCoordinator.setSheetCoordinator(coordinator)
emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator)
emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large]
navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator)
}
private func displayRoomDetails() {
@@ -33,7 +33,6 @@ enum RoomScreenComposerMode: Equatable {
enum RoomScreenViewAction {
case headerTapped
case displayEmojiPicker(itemId: String)
case emojiTapped(emoji: String, itemId: String)
case paginateBackwards
case itemAppeared(id: String)
case itemDisappeared(id: String)
@@ -43,7 +42,6 @@ enum RoomScreenViewAction {
case sendReaction(key: String, eventID: String)
case cancelReply
case cancelEdit
case displayReactionsMenuForItemId(itemId: String)
}
struct RoomScreenViewState: BindableState {
@@ -54,7 +52,6 @@ struct RoomScreenViewState: BindableState {
var isBackPaginating = false
var showLoading = false
var bindings: RoomScreenViewStateBindings
var displayReactionsMenuForItemId = ""
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
@@ -102,20 +102,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
await sendCurrentMessage()
case .sendReaction(let key, _):
#warning("Reaction implementation awaiting SDK support.")
MXLog.warning("React with \(key) failed. Not implemented.")
case .sendReaction(let emoji, let itemId):
await timelineController.sendReaction(emoji, for: itemId)
case .displayEmojiPicker(let itemId):
callback?(.displayEmojiPicker(itemId: itemId))
case .displayReactionsMenuForItemId(let itemId):
state.displayReactionsMenuForItemId = itemId
case .cancelReply:
state.composerMode = .default
case .cancelEdit:
state.composerMode = .default
case .emojiTapped(let emoji, let itemId):
await timelineController.sendReaction(emoji, for: itemId)
state.displayReactionsMenuForItemId = ""
}
}
@@ -128,8 +122,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func paginateBackwards() async {
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
case .failure:
displayError(.alert(ElementL10n.roomTimelineBackpaginationFailure))
default:
#warning("Treat errors")
break
}
}
@@ -201,7 +197,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
var actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink, .reply
.react, .copy, .quote, .copyPermalink, .reply
]
if item.isEditable {
@@ -215,6 +211,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return .init(actions: actions)
}
// swiftlint:disable:next cyclomatic_complexity
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemId: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
@@ -222,6 +219,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
switch action {
case .react:
callback?(.displayEmojiPicker(itemId: item.id))
case .copy:
UIPasteboard.general.string = item.text
case .edit:
@@ -27,28 +27,25 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ScaledMetric private var senderNameVerticalPadding = 3
var body: some View {
VStack(alignment: alignment, spacing: -12) {
if !timelineItem.isOutgoing {
header
.zIndex(1)
}
VStack(alignment: alignment) {
if timelineItem.isOutgoing {
HStack {
Spacer()
styledContentWithReactions
if timelineItem.isOutgoing {
TimelineDeliveryStatusView(deliveryStatus: timelineItem.properties.deliveryStatus)
.padding(.top, 6)
}
}
.padding(.trailing, 16)
.padding(.leading, 56)
} else {
styledContentWithReactions
.padding(.leading, 24)
.padding(.trailing, 56)
ZStack(alignment: .trailingFirstTextBaseline) {
VStack(alignment: alignment, spacing: -12) {
if !timelineItem.isOutgoing {
header
.zIndex(1)
}
HStack {
if timelineItem.isOutgoing {
Spacer()
}
styledContentWithReactions
}
.padding(.horizontal, 16.0)
}
if timelineItem.isOutgoing {
TimelineDeliveryStatusView(deliveryStatus: timelineItem.properties.deliveryStatus)
}
}
}
@@ -85,7 +82,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
alignment: alignment) { key in
context.send(viewAction: .sendReaction(key: key, eventID: timelineItem.id))
}
.padding(.horizontal, 12)
}
}
}
@@ -28,7 +28,15 @@ struct TimelineItemPlainStylerView<Content: View>: View {
header
VStack(alignment: .leading, spacing: 4) {
content()
HStack(alignment: .firstTextBaseline) {
content()
Spacer()
if timelineItem.isOutgoing {
TimelineDeliveryStatusView(deliveryStatus: timelineItem.properties.deliveryStatus)
}
}
supplementaryViews
}
}
@@ -43,17 +43,18 @@ struct TimelineDeliveryStatusView: View {
}
var body: some View {
if showDeliveryStatus {
Image(systemName: systemImageName)
.task {
if case .sent = deliveryStatus {
try? await Task.sleep(nanoseconds: 1_000_000_000)
withAnimation {
showDeliveryStatus = false
}
Image(systemName: systemImageName)
.resizable()
.frame(width: 12.0, height: 12.0)
.opacity(showDeliveryStatus ? 1.0 : 0.0)
.task {
if case .sent = deliveryStatus {
try? await Task.sleep(nanoseconds: 1_000_000_000)
withAnimation {
showDeliveryStatus = false
}
}
}
}
}
}
@@ -1,56 +0,0 @@
//
// Copyright 2022 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 TimelineItemReactionsMenuView: View {
private let emojis = ["👍🏼", "👎🏼", "😄", "🙏🏼", "😇"]
var onEmojiSelected: ((String) -> Void)?
var onDisplayEmojiPicker: (() -> Void)?
var body: some View {
HStack {
HStack(spacing: 10) {
ForEach(emojis, id: \.self) { emoji in
Button {
onEmojiSelected?(emoji)
} label: {
Text(emoji)
}
}
}
.padding(10)
.background(.gray)
.cornerRadius(15)
HStack(spacing: 10) {
Text("➕")
}
.padding(10)
.background(.gray)
.cornerRadius(15)
.onTapGesture {
onDisplayEmojiPicker?()
}
}
}
}
struct TimelineItemReactionsMenuView_Previews: PreviewProvider {
static var previews: some View {
TimelineItemReactionsMenuView(onDisplayEmojiPicker: nil)
}
}
@@ -51,7 +51,8 @@ struct FormattedBodyText: View {
}
}
}
.tint(.element.accent)
// Should be .element.accent but that's currently black
.tint(.blue)
}
private var blockquoteAttributes: AttributeContainer {
@@ -22,6 +22,7 @@ struct TimelineItemContextMenuActions {
}
enum TimelineItemContextMenuAction: Identifiable, Hashable {
case react
case copy
case edit
case quote
@@ -59,6 +60,10 @@ public struct TimelineItemContextMenu: View {
private func viewsForActions(_ actions: [TimelineItemContextMenuAction]) -> some View {
ForEach(actions, id: \.self) { item in
switch item {
case .react:
Button { callback(item) } label: {
Label(ElementL10n.reactions, systemImage: "face.smiling")
}
case .copy:
Button { callback(item) } label: {
Label(ElementL10n.actionCopy, systemImage: "doc.on.doc")
@@ -69,13 +69,7 @@ class TimelineTableViewController: UIViewController {
applySnapshot()
}
}
var displayReactionsMenuForItemId = "" {
didSet {
tableView.reloadData()
}
}
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
@Binding private var scrollToBottomButtonVisible: Bool
@@ -193,7 +187,6 @@ class TimelineTableViewController: UIViewController {
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let opacity = self.opacity(for: timelineItem)
let displayReactionsMenuForItemId = self.displayReactionsMenuForItemId
let contextMenuBuilder = self.contextMenuBuilder
cell.item = timelineItem
@@ -201,38 +194,28 @@ class TimelineTableViewController: UIViewController {
if case .backPaginationIndicator = timelineItem {
timelineItem
} else {
VStack {
if displayReactionsMenuForItemId == timelineItem.id {
TimelineItemReactionsMenuView { emoji in
coordinator.send(viewAction: .emojiTapped(emoji: emoji, itemId: timelineItem.id))
} onDisplayEmojiPicker: {
coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
}
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(opacity)
.contextMenu {
contextMenuBuilder?(timelineItem.id)
}
.onAppear {
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
coordinator.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
}
.onTapGesture {
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
}
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(opacity)
.contextMenu {
contextMenuBuilder?(timelineItem.id)
}
.onAppear {
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
coordinator.send(viewAction: .displayReactionsMenuForItemId(itemId: timelineItem.id))
}
.onTapGesture {
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
}
}
.margins(.all, self.timelineStyle.rowInsets)
@@ -375,15 +358,9 @@ class TimelineTableViewController: UIViewController {
extension TimelineTableViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let isAtBottom = isAtBottom()
// Dispatches fix runtime warnings about making changes during a view update.
if !scrollToBottomButtonVisible, isAtBottom {
DispatchQueue.main.async { self.scrollToBottomButtonVisible = true }
} else if scrollToBottomButtonVisible, !isAtBottom {
DispatchQueue.main.async { self.scrollToBottomButtonVisible = false }
}
DispatchQueue.main.async { self.scrollToBottomButtonVisible = self.isAtBottom() }
paginateBackwardsPublisher.send(())
}
@@ -65,9 +65,6 @@ struct TimelineView: UIViewControllerRepresentable {
if tableViewController.composerMode != context.viewState.composerMode {
tableViewController.composerMode = context.viewState.composerMode
}
if tableViewController.displayReactionsMenuForItemId != context.viewState.displayReactionsMenuForItemId {
tableViewController.displayReactionsMenuForItemId = context.viewState.displayReactionsMenuForItemId
}
// Doesn't have an equatable conformance :(
tableViewController.contextMenuBuilder = context.viewState.contextMenuBuilder
@@ -95,8 +95,6 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
do {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
let client = try await Task.dispatch(on: .global()) {
try self.authenticationService.login(username: username,
password: password,
@@ -104,11 +102,8 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
deviceId: deviceId)
}
Benchmark.endTrackingForIdentifier("Login", message: "Finished login")
return await userSession(for: client)
} catch {
Benchmark.endTrackingForIdentifier("Login", message: "Login failed")
MXLog.error("Failed logging in with error: \(error)")
guard let error = error as? AuthenticationError else { return .failure(.failedLoggingIn) }
+19 -12
View File
@@ -27,7 +27,7 @@ class RoomProxy: RoomProxyProtocol {
private let serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.serial")
private var sendMessageBgTask: BackgroundTaskProtocol?
private var sendMessageBackgroundTask: BackgroundTaskProtocol?
private var memberAvatars = [String: String]()
private var memberDisplayNames = [String: String]()
@@ -151,10 +151,7 @@ class RoomProxy: RoomProxyProtocol {
do {
let outcome: PaginationOutcome = try await Task.dispatch(on: .global()) {
Benchmark.startTrackingForIdentifier("BackPagination \(id)", message: "Backpaginating \(count) message(s) in room \(id)")
let outcome = try self.room.paginateBackwards(limit: UInt16(count))
Benchmark.endTrackingForIdentifier("BackPagination \(id)", message: "Finished backpaginating \(count) message(s) in room \(id)")
return outcome
try self.room.paginateBackwards(limit: UInt16(count))
}
update(backPaginationOutcome: outcome)
return .success(())
@@ -164,9 +161,9 @@ class RoomProxy: RoomProxyProtocol {
}
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer {
sendMessageBgTask?.stop()
sendMessageBackgroundTask?.stop()
}
let transactionId = genTransactionId()
@@ -187,15 +184,25 @@ class RoomProxy: RoomProxyProtocol {
}
func sendReaction(_ reaction: String, for eventId: String) async -> Result<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
.success(())
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: .global()) {
do {
try self.room.sendReaction(eventId: eventId, key: reaction)
return .success(())
} catch {
return .failure(.failedSendingReaction)
}
}
}
func editMessage(_ newMessage: String, originalEventId: String) async -> Result<Void, RoomProxyError> {
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer {
sendMessageBgTask?.stop()
sendMessageBackgroundTask?.stop()
}
let transactionId = genTransactionId()
@@ -205,7 +212,7 @@ class RoomProxy: RoomProxyProtocol {
try self.room.edit(newMsg: newMessage, originalEventId: originalEventId, txnId: transactionId)
return .success(())
} catch {
return .failure(.failedSendingMessage)
return .failure(.failedEditingMessage)
}
}
}
@@ -23,6 +23,8 @@ enum RoomProxyError: Error {
case failedRetrievingMemberAvatarURL
case failedRetrievingMemberDisplayName
case failedSendingMessage
case failedSendingReaction
case failedEditingMessage
case failedRedactingEvent
case failedAddingTimelineListener
case failedRetrievingMembers
@@ -161,8 +161,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
return buildEmptyRoomSummary()
case .filled(let roomId):
return buildRoomSummaryForIdentifier(roomId)
case .invalidated:
return buildEmptyRoomSummary()
case .invalidated(let roomId):
return buildRoomSummaryForIdentifier(roomId)
}
}
@@ -64,7 +64,7 @@ enum RoomTimelineItemFixtures {
senderId: "",
senderDisplayName: "Helena"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "And John's speech was amazing!",
text: "And John's speech was amazing!And John's speech was amazing!And John's speech was amazing!",
timestamp: "5 PM",
inGroupState: .beginning,
isOutgoing: true,
@@ -100,8 +100,6 @@ class UserSessionStore: UserSessionStoreProtocol {
// MARK: - Private
private func restorePreviousLogin(_ credentials: KeychainCredentials) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
Benchmark.startTrackingForIdentifier("Login", message: "Started restoring previous login")
let builder = ClientBuilder()
.basePath(path: baseDirectory.path)
.username(username: credentials.userID)