mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Delivery Status Redesign (#931)
This commit is contained in:
@@ -30,6 +30,7 @@ final class AppSettings {
|
||||
case startChatFlowEnabled
|
||||
case startChatUserSuggestionsEnabled
|
||||
case invitesFlowEnabled
|
||||
case readReceiptsEnabled
|
||||
}
|
||||
|
||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||
@@ -186,4 +187,9 @@ final class AppSettings {
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.invitesFlowEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var invitesFlowEnabled
|
||||
|
||||
// MARK: Receipts
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.readReceiptsEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var readReceiptsEnabled
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ struct RoomScreen: View {
|
||||
RoomAttachmentPicker(context: context)
|
||||
messageComposer
|
||||
}
|
||||
.padding()
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
struct TimelineItemBubbledStylerView<Content: View, DeliveryStatus: View>: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@ViewBuilder let deliveryStatus: () -> DeliveryStatus
|
||||
|
||||
@ScaledMetric private var senderNameVerticalPadding = 3
|
||||
private let cornerRadius: CGFloat = 12
|
||||
@@ -45,10 +46,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
.padding(.horizontal, 16.0)
|
||||
.padding(timelineItem.isOutgoing ? .leading : .trailing, 40) // Extra padding to differentiate alignment.
|
||||
}
|
||||
|
||||
if let deliveryStatus = timelineItem.properties.deliveryStatus {
|
||||
TimelineDeliveryStatusView(deliveryStatus: deliveryStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +82,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
context.send(viewAction: .sendReaction(key: key, eventID: timelineItem.id))
|
||||
}
|
||||
}
|
||||
|
||||
deliveryStatus()
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +116,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
|
||||
if timelineItem.properties.deliveryStatus == .sendingFailed {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.element.alert)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
.bubbleStyle(inset: true,
|
||||
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
|
||||
|
||||
@@ -17,29 +17,29 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
struct TimelineItemPlainStylerView<Content: View, DeliveryStatus: View>: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@ViewBuilder let deliveryStatus: () -> DeliveryStatus
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
header
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
contentWithReply
|
||||
|
||||
Spacer()
|
||||
|
||||
if let deliveryStatus = timelineItem.properties.deliveryStatus {
|
||||
TimelineDeliveryStatusView(deliveryStatus: deliveryStatus)
|
||||
header
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
contentWithReply
|
||||
|
||||
Spacer()
|
||||
}
|
||||
supplementaryViews
|
||||
}
|
||||
supplementaryViews
|
||||
}
|
||||
deliveryStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
|
||||
content()
|
||||
}
|
||||
.contextMenu {
|
||||
context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in
|
||||
TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -21,6 +21,12 @@ import SwiftUI
|
||||
|
||||
struct TimelineStyler<Content: View>: View {
|
||||
@Environment(\.timelineStyle) private var style
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
context.viewState.items.last(where: { !$0.isUnsent })?.id == timelineItem.id &&
|
||||
timelineItem.isOutgoing
|
||||
}
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
@@ -28,9 +34,96 @@ struct TimelineStyler<Content: View>: View {
|
||||
var body: some View {
|
||||
switch style {
|
||||
case .plain:
|
||||
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content)
|
||||
TimelineItemPlainStylerView(timelineItem: timelineItem, content: content) {
|
||||
deliveryStatusView
|
||||
}
|
||||
case .bubbles:
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content)
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, content: content) {
|
||||
deliveryStatusView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var deliveryStatusView: some View {
|
||||
switch timelineItem.properties.deliveryStatus {
|
||||
case .sending:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
case .sent:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
case .none:
|
||||
if isLastOutgoingMessage {
|
||||
// We always display the sent icon for the latest echoed outgoing message
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
case .sendingFailed:
|
||||
if style == .plain {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.element.alert)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
// The bubbles handle the failure internally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let base = TextRoomTimelineItem(id: UUID().uuidString, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: ""))
|
||||
|
||||
static let sent: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sent
|
||||
return result
|
||||
}()
|
||||
|
||||
static let sending: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sending
|
||||
return result
|
||||
}()
|
||||
|
||||
static let failed: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sendingFailed
|
||||
return result
|
||||
}()
|
||||
|
||||
static let last: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.items.last?.id ?? UUID().uuidString
|
||||
let result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: ""))
|
||||
return result
|
||||
}()
|
||||
|
||||
static var testView: some View {
|
||||
VStack {
|
||||
TimelineStyler(timelineItem: sent) {
|
||||
Text("Sent")
|
||||
}
|
||||
TimelineStyler(timelineItem: sending) {
|
||||
Text("Sending")
|
||||
}
|
||||
TimelineStyler(timelineItem: base) {
|
||||
Text("Normal")
|
||||
}
|
||||
TimelineStyler(timelineItem: last) {
|
||||
Text("Last")
|
||||
}
|
||||
TimelineStyler(timelineItem: failed) {
|
||||
Text("Failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
testView
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
|
||||
testView
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineStyle, .plain)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-28
@@ -16,45 +16,27 @@
|
||||
|
||||
import SwiftUI
|
||||
struct TimelineDeliveryStatusView: View {
|
||||
let deliveryStatus: TimelineItemDeliveryStatus
|
||||
|
||||
@State var showDeliveryStatus: Bool
|
||||
|
||||
enum Status {
|
||||
case sending
|
||||
case sent
|
||||
}
|
||||
|
||||
let deliveryStatus: Status
|
||||
|
||||
private var systemImageName: String {
|
||||
switch deliveryStatus {
|
||||
case .sending:
|
||||
return "circle"
|
||||
case .sent:
|
||||
return "checkmark.circle"
|
||||
case .sendingFailed:
|
||||
return "exclamationmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
init(deliveryStatus: TimelineItemDeliveryStatus) {
|
||||
self.deliveryStatus = deliveryStatus
|
||||
|
||||
switch deliveryStatus {
|
||||
case .sending, .sendingFailed:
|
||||
showDeliveryStatus = true
|
||||
case let .sent(elapsedTime: elapsedTime):
|
||||
showDeliveryStatus = elapsedTime < 3
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.frame(width: 16.0, height: 16.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +44,7 @@ struct TimelineDeliveryStatusView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent(elapsedTime: Date().timeIntervalSince1970))
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,6 +22,6 @@ struct RoomTimelineItemProperties: Hashable {
|
||||
var isEdited = false
|
||||
/// The aggregated reactions that have been sent for this item.
|
||||
var reactions: [AggregatedReaction] = []
|
||||
/// The delivery status for this item.
|
||||
/// The delivery status for this item. If a sent message is echoed the value is nil.
|
||||
var deliveryStatus: TimelineItemDeliveryStatus?
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ enum TimelineItemProxy {
|
||||
/// The delivery status for the item.
|
||||
enum TimelineItemDeliveryStatus: Hashable {
|
||||
case sending
|
||||
case sent(elapsedTime: TimeInterval)
|
||||
case sent
|
||||
case sendingFailed
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ struct EventTimelineItemProxy {
|
||||
}
|
||||
|
||||
var deliveryStatus: TimelineItemDeliveryStatus? {
|
||||
guard let localSendState = item.localSendState() else { return nil }
|
||||
guard let localSendState = item.localSendState() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch localSendState {
|
||||
case .notSentYet:
|
||||
@@ -62,7 +64,7 @@ struct EventTimelineItemProxy {
|
||||
case .sendingFailed:
|
||||
return .sendingFailed
|
||||
case .sent:
|
||||
return .sent(elapsedTime: Date().timeIntervalSince1970 - timestamp.timeIntervalSince1970)
|
||||
return .sent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +111,10 @@ struct EventTimelineItemProxy {
|
||||
let debugInfo = item.debugInfo()
|
||||
return TimelineItemDebugInfo(model: debugInfo.model, originalJSON: debugInfo.originalJson, latestEditJSON: debugInfo.latestEditJson)
|
||||
}
|
||||
|
||||
var readReceipts: [String: Receipt] {
|
||||
item.readReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineItemContentKind {
|
||||
@@ -162,3 +168,12 @@ struct TimelineItemDebugInfo: Identifiable, CustomStringConvertible {
|
||||
return String(data: jsonData, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension Receipt {
|
||||
var dateTimestamp: Date? {
|
||||
guard let timestamp else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
|
||||
fatalError("Unknown timeline item")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .text(let item as RoomTimelineItemProtocol, _),
|
||||
@@ -100,6 +100,26 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
|
||||
return item.id
|
||||
}
|
||||
}
|
||||
|
||||
var isUnsent: Bool {
|
||||
switch self {
|
||||
case .text(let item as EventBasedTimelineItemProtocol, _),
|
||||
.image(let item as EventBasedTimelineItemProtocol, _),
|
||||
.video(let item as EventBasedTimelineItemProtocol, _),
|
||||
.audio(let item as EventBasedTimelineItemProtocol, _),
|
||||
.file(let item as EventBasedTimelineItemProtocol, _),
|
||||
.emote(let item as EventBasedTimelineItemProtocol, _),
|
||||
.notice(let item as EventBasedTimelineItemProtocol, _),
|
||||
.redacted(let item as EventBasedTimelineItemProtocol, _),
|
||||
.encrypted(let item as EventBasedTimelineItemProtocol, _),
|
||||
.sticker(let item as EventBasedTimelineItemProtocol, _),
|
||||
.unsupported(let item as EventBasedTimelineItemProtocol, _),
|
||||
.state(let item as EventBasedTimelineItemProtocol, _):
|
||||
return item.properties.deliveryStatus == .sending || item.properties.deliveryStatus == .sendingFailed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not it is possible to send a reaction to this timeline item.
|
||||
var isReactable: Bool {
|
||||
|
||||
Reference in New Issue
Block a user