Retry send and cancel send for failed to send messages (#1088)

* is now possible to open the confirmation dialog and pass the transaction id

* task completed added some tests

* changelog

* title visibility set to .visible
This commit is contained in:
Mauro
2023-06-16 11:00:54 +02:00
committed by GitHub
parent 163e38b1a9
commit ac41704821
17 changed files with 281 additions and 23 deletions
+4
View File
@@ -440,6 +440,7 @@
A6F713461DB62AC06293E7B7 /* FilePreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820637A0F9C2F562FF40CBC8 /* FilePreviewScreenModels.swift */; };
A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; };
A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; };
A7F55D1C2A3B639A009D8E93 /* ConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */; };
A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; };
A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; };
A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; };
@@ -1121,6 +1122,7 @@
A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = "<group>"; };
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationDialog.swift; sourceTree = "<group>"; };
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = "<group>"; };
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = "<group>"; };
@@ -1928,6 +1930,7 @@
CED722AC2A3B0E7B000F10C8 /* URLSession.swift */,
897DF5E9A70CE05A632FC8AF /* UTType.swift */,
E992D7B8BE54B2AB454613AF /* XCUIElement.swift */,
A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -4206,6 +4209,7 @@
B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */,
1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */,
DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */,
A7F55D1C2A3B639A009D8E93 /* ConfirmationDialog.swift in Sources */,
5C164551F7D26E24F09083D3 /* StaticLocationScreenViewModelProtocol.swift in Sources */,
C58E305C380D3ADDF7912180 /* StickerRoomTimelineItem.swift in Sources */,
F32B271F60531BE92C6E62A1 /* StickerRoomTimelineView.swift in Sources */,
@@ -273,6 +273,8 @@
"screen_room_member_details_unblock_user" = "Unblock user";
"screen_room_member_list_pending_header_title" = "Pending";
"screen_room_member_list_room_members_header_title" = "Room members";
"screen_room_retry_send_menu_send_again_action" = "Send again";
"screen_room_retry_send_menu_title" = "Your message failed to send";
"screen_roomlist_a11y_create_message" = "Create a new conversation or room";
"screen_roomlist_main_space_title" = "All Chats";
"screen_server_confirmation_change_server" = "Change account provider";
@@ -387,6 +389,7 @@
"screen_room_details_security_title" = "Security";
"screen_room_details_topic_title" = "Topic";
"screen_room_error_failed_processing_media" = "Failed processing media to upload, please try again.";
"screen_room_retry_send_menu_remove_action" = "Remove";
"screen_session_verification_cancelled_title" = "Verification cancelled";
"screen_session_verification_positive_button_ready" = "Start";
"screen_signout_confirmation_dialog_submit" = "Sign out";
+6
View File
@@ -702,6 +702,12 @@ public enum L10n {
public static var screenRoomMemberListPendingHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_pending_header_title") }
/// Room members
public static var screenRoomMemberListRoomMembersHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_room_members_header_title") }
/// Remove
public static var screenRoomRetrySendMenuRemoveAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_remove_action") }
/// Send again
public static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") }
/// Your message failed to send
public static var screenRoomRetrySendMenuTitle: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_title") }
/// Create a new conversation or room
public static var screenRoomlistA11yCreateMessage: String { return L10n.tr("Localizable", "screen_roomlist_a11y_create_message") }
/// All Chats
@@ -685,6 +685,38 @@ class RoomProxyMock: RoomProxyProtocol {
return sendFileUrlFileInfoReturnValue
}
}
//MARK: - retrySend
var retrySendTransactionIDCallsCount = 0
var retrySendTransactionIDCalled: Bool {
return retrySendTransactionIDCallsCount > 0
}
var retrySendTransactionIDReceivedTransactionID: String?
var retrySendTransactionIDReceivedInvocations: [String] = []
var retrySendTransactionIDClosure: ((String) async -> Void)?
func retrySend(transactionID: String) async {
retrySendTransactionIDCallsCount += 1
retrySendTransactionIDReceivedTransactionID = transactionID
retrySendTransactionIDReceivedInvocations.append(transactionID)
await retrySendTransactionIDClosure?(transactionID)
}
//MARK: - cancelSend
var cancelSendTransactionIDCallsCount = 0
var cancelSendTransactionIDCalled: Bool {
return cancelSendTransactionIDCallsCount > 0
}
var cancelSendTransactionIDReceivedTransactionID: String?
var cancelSendTransactionIDReceivedInvocations: [String] = []
var cancelSendTransactionIDClosure: ((String) async -> Void)?
func cancelSend(transactionID: String) async {
cancelSendTransactionIDCallsCount += 1
cancelSendTransactionIDReceivedTransactionID = transactionID
cancelSendTransactionIDReceivedInvocations.append(transactionID)
await cancelSendTransactionIDClosure?(transactionID)
}
//MARK: - editMessage
var editMessageOriginalCallsCount = 0
@@ -0,0 +1,50 @@
//
// 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
protocol ConfirmationDialogProtocol {
var title: String { get }
}
extension View {
func confirmationDialog<Item, Actions>(item: Binding<Item?>,
titleVisibility: Visibility = .automatic,
@ViewBuilder actions: (Item) -> Actions) -> some View where Item: ConfirmationDialogProtocol, Actions: View {
let binding = Binding<Bool>(get: {
item.wrappedValue != nil
}, set: { newValue in
if !newValue {
item.wrappedValue = nil
}
})
return confirmationDialog(item.wrappedValue?.title ?? "", isPresented: binding, titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions)
}
func confirmationDialog<Item, Actions, Message>(item: Binding<Item?>,
titleVisibility: Visibility = .automatic,
@ViewBuilder actions: (Item) -> Actions,
@ViewBuilder message: (Item) -> Message) -> some View where Item: ConfirmationDialogProtocol, Actions: View, Message: View {
let binding = Binding<Bool>(get: {
item.wrappedValue != nil
}, set: { newValue in
if !newValue {
item.wrappedValue = nil
}
})
return confirmationDialog(item.wrappedValue?.title ?? "", isPresented: binding, titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions, message: message)
}
}
@@ -70,6 +70,9 @@ enum RoomScreenViewAction {
case handlePasteOrDrop(provider: NSItemProvider)
case tappedOnUser(userID: String)
case retrySend(transactionID: String?)
case cancelSend(transactionID: String?)
}
struct RoomScreenViewState: BindableState {
@@ -109,6 +112,8 @@ struct RoomScreenViewStateBindings {
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
var sendFailedConfirmationDialogInfo: SendFailedConfirmationDialogInfo?
}
struct TimelineItemActionMenuInfo: Identifiable {
@@ -119,6 +124,12 @@ struct TimelineItemActionMenuInfo: Identifiable {
}
}
struct SendFailedConfirmationDialogInfo: ConfirmationDialogProtocol {
let title = L10n.screenRoomRetrySendMenuTitle
let transactionID: String?
}
enum RoomScreenErrorType: Hashable {
/// A specific error message shown in an alert.
case alert(String)
@@ -105,10 +105,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
handlePasteOrDrop(provider)
case .tappedOnUser(userID: let userID):
Task { await handleTappedUser(userID: userID) }
case .displayEmojiPicker(let itemID):
guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return }
callback?(.displayEmojiPicker(itemID: itemID))
case .retrySend(let transactionID):
Task { await handleRetrySend(transactionID: transactionID) }
case .cancelSend(let transactionID):
Task { await handleCancelSend(transactionID: transactionID) }
}
}
@@ -470,6 +473,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
private func handleRetrySend(transactionID: String?) async {
guard let transactionID else {
return
}
await roomProxy.retrySend(transactionID: transactionID)
}
private func handleCancelSend(transactionID: String?) async {
guard let transactionID else {
return
}
await roomProxy.cancelSend(transactionID: transactionID)
}
private static let loadingIndicatorIdentifier = "RoomScreenLoadingIndicator"
private func showLoadingIndicator(with delay: Duration) -> Task<Void, Never> {
@@ -62,6 +62,14 @@ struct RoomScreen: View {
context.send(viewAction: .handlePasteOrDrop(provider: provider))
return true
}
.confirmationDialog(item: $context.sendFailedConfirmationDialogInfo, titleVisibility: .visible) { item in
Button(L10n.screenRoomRetrySendMenuSendAgainAction) {
context.send(viewAction: .retrySend(transactionID: item.transactionID))
}
Button(L10n.screenRoomRetrySendMenuRemoveAction, role: .destructive) {
context.send(viewAction: .cancelSend(transactionID: item.transactionID))
}
}
}
private var timeline: some View {
@@ -146,30 +146,47 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var contentWithTimestamp: some View {
if isTextItem {
if isTextItem || isMediaType {
ZStack(alignment: .bottomTrailing) {
contentWithReply
localizedSendInfo
}
} else if isMediaType {
ZStack(alignment: .bottomTrailing) {
contentWithReply
localizedSendInfo
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.element.system)
.cornerRadius(10)
.padding(.trailing, 4)
.padding(.bottom, 4)
interactiveLocalizedSendInfo
}
} else {
HStack(alignment: .bottom, spacing: 4) {
contentWithReply
localizedSendInfo
interactiveLocalizedSendInfo
}
}
}
@ViewBuilder
var interactiveLocalizedSendInfo: some View {
if timelineItem.properties.deliveryStatus == .sendingFailed {
backgroundedLocalizedSendInfo
.onTapGesture {
context.sendFailedConfirmationDialogInfo = .init(transactionID: timelineItem.properties.transactionID)
}
} else {
backgroundedLocalizedSendInfo
}
}
@ViewBuilder
var backgroundedLocalizedSendInfo: some View {
if isMediaType {
localizedSendInfo
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.element.system)
.cornerRadius(10)
.padding(.trailing, 4)
.padding(.bottom, 4)
} else {
localizedSendInfo
}
}
@ViewBuilder
var localizedSendInfo: some View {
HStack(spacing: 4) {
@@ -69,6 +69,9 @@ struct TimelineItemStatusView: View {
.resizable()
.foregroundColor(.element.alert)
.frame(width: 16, height: 16)
.onTapGesture {
context.sendFailedConfirmationDialogInfo = .init(transactionID: timelineItem.properties.transactionID)
}
}
// The bubbles handle the failure internally
}
@@ -326,6 +326,28 @@ class RoomProxy: RoomProxyProtocol {
}
}
func retrySend(transactionID: String) async {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
self.room.retrySend(txnId: transactionID)
}
}
func cancelSend(transactionID: String) async {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
self.room.cancelSend(txnId: transactionID)
}
}
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
@@ -101,6 +101,12 @@ protocol RoomProxyProtocol {
func sendFile(url: URL, fileInfo: FileInfo) async -> Result<Void, RoomProxyError>
/// Retries sending a failed message given its transaction ID
func retrySend(transactionID: String) async
/// Cancels a failed message given its transaction ID from the timeline
func cancelSend(transactionID: String) async
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
@@ -26,4 +26,6 @@ struct RoomTimelineItemProperties: Hashable {
var deliveryStatus: TimelineItemDeliveryStatus?
/// The read receipts of the item, ordered from newest to oldest
var orderedReadReceipts: [ReadReceipt] = []
/// The original transaction id transmitted by the client
var transactionID: String?
}
@@ -52,6 +52,10 @@ struct EventTimelineItemProxy {
var id: String {
item.uniqueIdentifier()
}
var transactionID: String? {
item.transactionId()
}
var deliveryStatus: TimelineItemDeliveryStatus? {
guard let localSendState = item.localSendState() else {
@@ -133,7 +133,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
blurhash: imageInfo.blurhash,
properties: RoomTimelineItemProperties(reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
@@ -184,7 +185,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -201,7 +203,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -218,7 +221,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -235,7 +239,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -252,7 +257,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -269,7 +275,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy,
@@ -286,7 +293,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(),
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
transactionID: eventItemProxy.transactionID))
}
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
@@ -248,6 +248,68 @@ class RoomScreenViewModelTests: XCTestCase {
XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1)
XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob")
}
func testRetrySend() async {
// Setup
let timelineController = MockRoomTimelineController()
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
let viewModel = RoomScreenViewModel(timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomProxy: roomProxyMock)
// Test
viewModel.context.send(viewAction: .retrySend(transactionID: "test retry send id"))
await Task.yield()
XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 1)
XCTAssert(roomProxyMock.retrySendTransactionIDReceivedInvocations == ["test retry send id"])
}
func testRetrySendNoTransactionID() async {
// Setup
let timelineController = MockRoomTimelineController()
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
let viewModel = RoomScreenViewModel(timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomProxy: roomProxyMock)
// Test
viewModel.context.send(viewAction: .retrySend(transactionID: nil))
await Task.yield()
XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 0)
}
func testCancelSend() async {
// Setup
let timelineController = MockRoomTimelineController()
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
let viewModel = RoomScreenViewModel(timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomProxy: roomProxyMock)
// Test
viewModel.context.send(viewAction: .cancelSend(transactionID: "test cancel send id"))
await Task.yield()
XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 1)
XCTAssert(roomProxyMock.cancelSendTransactionIDReceivedInvocations == ["test cancel send id"])
}
func testCancelSendNoTransactionID() async {
// Setup
let timelineController = MockRoomTimelineController()
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
let viewModel = RoomScreenViewModel(timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomProxy: roomProxyMock)
// Test
viewModel.context.send(viewAction: .cancelSend(transactionID: nil))
await Task.yield()
XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 0)
}
}
private extension TextRoomTimelineItem {
+1
View File
@@ -0,0 +1 @@
Failed to send messages can now be either retried or removed by tapping on the error icon/timestamp.