diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index ccc8d769da..436819c14f 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -136,6 +136,9 @@ struct ContentView: View { .sheet(isPresented: $showWhatsNew) { WhatsNewView() } + if chatModel.setDeliveryReceipts { + SetDeliveryReceiptsView() + } IncomingCallView() } .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f166cfbff3..62f14c7923 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -13,6 +13,7 @@ import SimpleXChat final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? + @Published var setDeliveryReceipts = false @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? @Published var users: [UserInfo] = [] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 1e73abf393..ed5d18b1f9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1091,6 +1091,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool m.currentUser = try apiGetActiveUser() if m.currentUser == nil { onboardingStageDefault.set(.step1_SimpleXInfo) + privacyDeliveryReceiptsSet.set(true) m.onboardingStage = .step1_SimpleXInfo } else if start { try startChat(refreshInvitations: refreshInvitations) @@ -1120,6 +1121,9 @@ func startChat(refreshInvitations: Bool = true) throws { m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 ? .step3_CreateSimpleXAddress : savedOnboardingStage + if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { + m.setDeliveryReceipts = true + } } } ChatReceiver.shared.start() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 6d9b50d031..fb105742ae 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -57,6 +57,22 @@ private func serverHost(_ s: String) -> String { } } +enum SendReceipts: Identifiable, Hashable { + case yes + case no + case userDefault(Bool) + + var id: Self { self } + + var text: LocalizedStringKey { + switch self { + case .yes: "yes" + case .no: "no" + case let .userDefault(on): on ? "default (yes)" : "default (no)" + } + } +} + struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @@ -68,6 +84,7 @@ struct ChatInfoView: View { @Binding var connectionCode: String? @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil + @State private var sendReceipts = SendReceipts.yes @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { @@ -117,6 +134,7 @@ struct ChatInfoView: View { Section { if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() + sendReceiptsOption() if let connStats = connectionStats, connStats.ratchetSyncAllowed { synchronizeConnectionButton() @@ -153,7 +171,7 @@ struct ChatInfoView: View { connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) - if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { Button("Abort changing address") { alert = .abortSwitchAddressAlert } @@ -295,6 +313,17 @@ struct ChatInfoView: View { } } + private func sendReceiptsOption() -> some View { + Picker(selection: $sendReceipts) { + ForEach([.yes, .no, .userDefault(true)]) { (opt: SendReceipts) in + Text(opt.text) + } + } label: { + Label("Send receipts", systemImage: "checkmark.message") + } + .frame(height: 36) + } + private func synchronizeConnectionButton() -> some View { Button { syncContactConnection(force: false) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index d6d33c4a1a..4db32bc74f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -13,7 +13,13 @@ struct ChatItemInfoView: View { @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? + @State private var selection: CIInfoTab = .history @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + + enum CIInfoTab { + case history + case quote + } var body: some View { NavigationView { @@ -35,43 +41,66 @@ struct ChatItemInfoView: View { } @ViewBuilder private func itemInfoView() -> some View { + if let qi = ci.quotedItem { + TabView(selection: $selection) { + historyTab() + .tabItem { + Label("History", systemImage: "clock") + } + .tag(CIInfoTab.history) + quoteTab(qi) + .tabItem { + Label("In reply to", systemImage: "arrowshape.turn.up.left") + } + .tag(CIInfoTab.quote) + } + } else { + historyTab() + } + } + + @ViewBuilder private func details() -> some View { let meta = ci.meta + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .padding(.bottom) + + infoRow("Sent at", localTimestamp(meta.itemTs)) + if !ci.chatDir.sent { + infoRow("Received at", localTimestamp(meta.createdAt)) + } + switch (meta.itemDeleted) { + case let .deleted(deletedTs): + if let deletedTs = deletedTs { + infoRow("Deleted at", localTimestamp(deletedTs)) + } + case let .moderated(deletedTs, _): + if let deletedTs = deletedTs { + infoRow("Moderated at", localTimestamp(deletedTs)) + } + default: EmptyView() + } + if let deleteAt = meta.itemTimed?.deleteAt { + infoRow("Disappears at", localTimestamp(deleteAt)) + } + if developerTools { + infoRow("Database ID", "\(meta.itemId)") + infoRow("Record updated at", localTimestamp(meta.updatedAt)) + } + } + } + + @ViewBuilder private func historyTab() -> some View { GeometryReader { g in + let maxWidth = (g.size.width - 32) * 0.84 ScrollView { VStack(alignment: .leading, spacing: 16) { - Text(title) - .font(.largeTitle) - .bold() - .padding(.bottom) - - let maxWidth = (g.size.width - 32) * 0.84 - infoRow("Sent at", localTimestamp(meta.itemTs)) - if !ci.chatDir.sent { - infoRow("Received at", localTimestamp(meta.createdAt)) - } - switch (meta.itemDeleted) { - case let .deleted(deletedTs): - if let deletedTs = deletedTs { - infoRow("Deleted at", localTimestamp(deletedTs)) - } - case let .moderated(deletedTs, _): - if let deletedTs = deletedTs { - infoRow("Moderated at", localTimestamp(deletedTs)) - } - default: EmptyView() - } - if let deleteAt = meta.itemTimed?.deleteAt { - infoRow("Disappears at", localTimestamp(deleteAt)) - } - if developerTools { - infoRow("Database ID", "\(meta.itemId)") - infoRow("Record updated at", localTimestamp(meta.updatedAt)) - } - + details() + Divider().padding(.vertical) if let chatItemInfo = chatItemInfo, !chatItemInfo.itemVersions.isEmpty { - Divider().padding(.vertical) - Text("History") .font(.title2) .padding(.bottom, 4) @@ -81,16 +110,21 @@ struct ChatItemInfoView: View { } } } + else { + Text("No history") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } } + .padding() } - .padding() .frame(maxHeight: .infinity, alignment: .top) } } - + @ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { VStack(alignment: .leading, spacing: 4) { - versionText(itemVersion) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) .allowsHitTesting(false) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -119,9 +153,9 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func versionText(_ itemVersion: ChatItemVersion) -> some View { - if itemVersion.msgContent.text != "" { - messageText(itemVersion.msgContent.text, itemVersion.formattedText, nil) + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { + if text != "" { + messageText(text, formattedText, sender) } else { Text("no text") .italic() @@ -129,6 +163,60 @@ struct ChatItemInfoView: View { } } + @ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View { + GeometryReader { g in + let maxWidth = (g.size.width - 32) * 0.84 + ScrollView { + VStack(alignment: .leading, spacing: 16) { + details() + Divider().padding(.vertical) + Text("In reply to") + .font(.title2) + .padding(.bottom, 4) + quotedMsgView(qi, maxWidth) + } + .padding() + } + .frame(maxHeight: .infinity, alignment: .top) + } + } + + @ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 4) { + textBubble(qi.text, qi.formattedText, qi.getSender(nil)) + .allowsHitTesting(false) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(quotedMsgFrameColor(qi, colorScheme)) + .cornerRadius(18) + .contextMenu { + if qi.text != "" { + Button { + showShareSheet(items: [qi.text]) + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + UIPasteboard.general.string = qi.text + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + Text(localTimestamp(qi.sentAt)) + .foregroundStyle(.secondary) + .font(.caption) + .padding(.horizontal, 12) + } + .frame(maxWidth: maxWidth, alignment: .leading) + } + + func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color { + (qi.chatDir?.sent ?? false) + ? (colorScheme == .light ? sentColorLight : sentColorDark) + : Color(uiColor: .tertiarySystemGroupedBackground) + } + private func itemInfoShareText() -> String { let meta = ci.meta var shareText: [String] = [title, ""] @@ -156,6 +244,24 @@ struct ChatItemInfoView: View { String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)) ] } + if let qi = ci.quotedItem { + shareText += ["", NSLocalizedString("In reply to", comment: "copied message info")] + let t = qi.text + shareText += [""] + if let sender = qi.getSender(nil) { + shareText += [String.localizedStringWithFormat( + NSLocalizedString("%@ at %@:", comment: "copied message info, at