mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+18
-22
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-56
@@ -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) }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user