Delivery Status Redesign (#931)

This commit is contained in:
Mauro
2023-05-22 15:17:06 +02:00
committed by GitHub
parent 9afc0c0747
commit 8ed8cf6f0c
35 changed files with 233 additions and 101 deletions
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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 {