diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7d68a2e415..d55c7d5a37 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -289,10 +289,7 @@ final class ChatModel: ObservableObject { private func markCurrentChatRead(fromIndex i: Int = 0) { var j = i while j < reversedChatItems.count { - if case .rcvNew = reversedChatItems[j].meta.itemStatus { - reversedChatItems[j].meta.itemStatus = .rcvRead - reversedChatItems[j].viewTimestamp = .now - } + markChatItemRead_(j) j += 1 } } @@ -347,9 +344,19 @@ final class ChatModel: ObservableObject { // update preview decreaseUnreadCounter(cInfo) // update current chat - if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { - reversedChatItems[j].meta.itemStatus = .rcvRead - reversedChatItems[j].viewTimestamp = .now + if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + markChatItemRead_(i) + } + } + + private func markChatItemRead_(_ i: Int) { + let meta = reversedChatItems[i].meta + if case .rcvNew = meta.itemStatus { + reversedChatItems[i].meta.itemStatus = .rcvRead + reversedChatItems[i].viewTimestamp = .now + if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { + reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) + } } } @@ -513,4 +520,6 @@ final class Chat: ObservableObject, Identifiable { var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index f184568684..0faae0c929 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -161,5 +161,6 @@ struct CIFileView_Previews: PreviewProvider { ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 23224850a0..63c90d35cf 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -10,37 +10,20 @@ import SwiftUI import SimpleXChat struct CIMetaView: View { + @EnvironmentObject var chat: Chat var chatItem: ChatItem var metaColor = Color.secondary var body: some View { - HStack(alignment: .center, spacing: 4) { - if !chatItem.isDeletedContent { - if chatItem.meta.itemEdited { - statusImage("pencil", metaColor, 9) - } - - switch chatItem.meta.itemStatus { - case .sndSent: - statusImage("checkmark", metaColor) - case .sndErrorAuth: - statusImage("multiply", .red) - case .sndError: - statusImage("exclamationmark.triangle.fill", .yellow) - case .rcvNew: - statusImage("circlebadge.fill", Color.accentColor) - default: EmptyView() - } - } - - chatItem.timestampText - .font(.caption) - .foregroundColor(metaColor) + if chatItem.isDeletedContent { + chatItem.timestampText.font(.caption).foregroundColor(metaColor) + } else { + ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor) } } - private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View { - Image(systemName: systemName) + private func statusImage(_ icon: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View { + Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(color) @@ -48,6 +31,31 @@ struct CIMetaView: View { } } +func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear) -> Text { + var r = Text("") + if meta.itemEdited { + r = r + statusIconText("pencil", color) + } + if meta.disappearing { + r = r + statusIconText("timer", color).font(.caption2) + let ttl = meta.itemTimed?.ttl + if ttl != chatTTL { + r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color) + } + r = r + Text(" ") + } + if let (icon, color) = meta.statusIcon(color) { + r = r + statusIconText(icon, color) + Text(" ") + } else if !meta.disappearing { + r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") + } + return (r + meta.timestampText.foregroundColor(color)).font(.caption) +} + +private func statusIconText(_ icon: String, _ color: Color) -> Text { + Text(Image(systemName: icon)).foregroundColor(color) +} + struct CIMetaView_Previews: PreviewProvider { static var previews: some View { Group { @@ -56,5 +64,6 @@ struct CIMetaView_Previews: PreviewProvider { CIMetaView(chatItem: ChatItem.getDeletedContentSample()) } .previewLayout(.fixed(width: 360, height: 100)) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 13f7bfe3f7..fef3c59375 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -231,16 +231,12 @@ struct CIVoiceView_Previews: PreviewProvider { playbackState: .playing, playbackTime: TimeInterval(20) ) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer), revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false)) - .environmentObject(ChatModel()) } .previewLayout(.fixed(width: 360, height: 360)) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 803faed175..3e8f6f3865 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -61,16 +61,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider { ) Group { ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer), revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) - .environmentObject(ChatModel()) ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) - .environmentObject(ChatModel()) } .previewLayout(.fixed(width: 360, height: 360)) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 16bdeb96c1..24713fe3ae 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,7 +15,6 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) struct FramedItemView: View { - @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme var chatInfo: ChatInfo var chatItem: ChatItem @@ -40,7 +39,7 @@ struct FramedItemView: View { ciQuoteView(qi) .onTapGesture { if let proxy = scrollProxy, - let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { + let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { proxy.scrollTo(ci.viewId, anchor: .bottom) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 4d7e8ec13e..82a39a6bcc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -25,6 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text { } struct MsgContentView: View { + @EnvironmentObject var chat: Chat var text: String var formattedText: [FormattedText]? = nil var sender: String? = nil @@ -66,7 +67,7 @@ struct MsgContentView: View { if mt.isLive { v = v + typingIndicator(mt.recent) } - v = v + reserveSpaceForMeta(mt.timestampText, mt.itemEdited) + v = v + reserveSpaceForMeta(mt) } return v } @@ -78,10 +79,8 @@ struct MsgContentView: View { .foregroundColor(.secondary) } - private func reserveSpaceForMeta(_ mt: Text, _ edited: Bool) -> Text { - let reserve = rightToLeft ? "\n" : edited ? " " : " " - return (Text(reserve) + mt) - .font(.caption) + private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL) .foregroundColor(.clear) } } @@ -157,5 +156,6 @@ struct MsgContentView_Previews: PreviewProvider { sender: chatItem.memberDisplayName, meta: chatItem.meta ) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index a528d6b864..0b001c2a50 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -62,10 +62,10 @@ struct ChatItemContentView: View { case .sndGroupEvent: eventItemView() case .rcvConnEvent: eventItemView() case .sndConnEvent: eventItemView() - case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor) - case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor) - case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor) - case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor) + case let .rcvChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param) + case let .sndChatFeature(feature, enabled, param): chatFeatureView(feature, enabled.iconColor, param) + case let .rcvGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param) + case let .sndGroupFeature(feature, preference, param): chatFeatureView(feature, preference.enable.iconColor, param) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) } @@ -87,7 +87,7 @@ struct ChatItemContentView: View { CIEventView(chatItem: chatItem) } - private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { + private func chatFeatureView(_ feature: Feature, _ iconColor: Color, _ param: Int? = nil) -> some View { CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) } } @@ -106,12 +106,13 @@ struct ChatItemView_Previews: PreviewProvider { ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, false, true), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 70)) + .environmentObject(Chat.sampleData) } } struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { static var previews: some View { - let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false)) + let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( chatInfo: ChatInfo.sampleData.direct, @@ -159,5 +160,6 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) } .previewLayout(.fixed(width: 360, height: 70)) + .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 36b2883c47..63bdf2f490 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -396,7 +396,6 @@ struct ChatView: View { .frame(width: memberImageSize, height: memberImageSize) } ChatItemWithMenu( - chat: chat, ci: ci, showMember: showMember, maxWidth: maxWidth, @@ -405,13 +404,14 @@ struct ChatView: View { deletingItem: $deletingItem, composeState: $composeState, showDeleteMessage: $showDeleteMessage - ).padding(.leading, 8) + ) + .padding(.leading, 8) + .environmentObject(chat) } .padding(.trailing) .padding(.leading, 12) } else { ChatItemWithMenu( - chat: chat, ci: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, @@ -419,12 +419,14 @@ struct ChatView: View { deletingItem: $deletingItem, composeState: $composeState, showDeleteMessage: $showDeleteMessage - ).padding(.horizontal) + ) + .padding(.horizontal) + .environmentObject(chat) } } private struct ChatItemWithMenu: View { - var chat: Chat + @EnvironmentObject var chat: Chat var ci: ChatItem var showMember: Bool = false var maxWidth: CGFloat diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 0c60bf9119..3720916c24 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -642,7 +642,7 @@ struct ComposeView: View { Task { do { var prefs = contactUserPreferencesToPreferences(contact.mergedPreferences) - prefs.voice = Preference(allow: .yes) + prefs.voice = SimplePreference(allow: .yes) if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) { await MainActor.run { chatModel.updateContact(toContact) diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index 0cde3399c4..af16d221e2 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -22,6 +22,7 @@ struct ContactPreferencesView: View { VStack { List { + timedMessagesFeatureSection() featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete) featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice) @@ -48,10 +49,10 @@ struct ContactPreferencesView: View { } } - private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding) -> some View { + private func featureSection(_ feature: ChatFeature, _ userDefault: FeatureAllowed, _ pref: ContactUserPreference, _ allowFeature: Binding) -> some View { let enabled = FeatureEnabled.enabled( asymmetric: feature.asymmetric, - user: Preference(allow: allowFeature.wrappedValue.allowed), + user: SimplePreference(allow: allowFeature.wrappedValue.allowed), contact: pref.contactPreference ) return Section { @@ -62,16 +63,51 @@ struct ContactPreferencesView: View { } .frame(height: 36) infoRow("Contact allows", pref.contactPreference.allow.text) - } header: { - HStack { - Image(systemName: "\(feature.icon).fill") - .foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red) - Text(feature.text) - } - } footer: { - Text(feature.enabledDescription(enabled)) - .frame(height: 36, alignment: .topLeading) } + header: { featureHeader(feature, enabled) } + footer: { featureFooter(feature, enabled) } + } + + private func timedMessagesFeatureSection() -> some View { + let pref = contact.mergedPreferences.timedMessages + let enabled = FeatureEnabled.enabled( + asymmetric: ChatFeature.timedMessages.asymmetric, + user: TimedMessagesPreference(allow: featuresAllowed.timedMessagesAllowed ? .yes : .no), + contact: pref.contactPreference + ) + return Section { + Toggle("You allow", isOn: $featuresAllowed.timedMessagesAllowed) + .onChange(of: featuresAllowed.timedMessagesAllowed) { allow in + if allow { + if featuresAllowed.timedMessagesTTL == nil { + featuresAllowed.timedMessagesTTL = 86400 + } + } else { + featuresAllowed.timedMessagesTTL = currentFeaturesAllowed.timedMessagesTTL + } + } + infoRow("Contact allows", pref.contactPreference.allow.text) + if featuresAllowed.timedMessagesAllowed { + timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL) + } else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always { + infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl)) + } + } + header: { featureHeader(.timedMessages, enabled) } + footer: { featureFooter(.timedMessages, enabled) } + } + + private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View { + HStack { + Image(systemName: feature.iconFilled) + .foregroundColor(enabled.forUser ? .green : enabled.forContact ? .yellow : .red) + Text(feature.text) + } + } + + private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View { + Text(feature.enabledDescription(enabled)) + .frame(height: 36, alignment: .topLeading) } private func savePreferences() { @@ -92,6 +128,18 @@ struct ContactPreferencesView: View { } } +func timedMessagesTTLPicker(_ selection: Binding) -> some View { + Picker("Delete after", selection: selection) { + let selectedTTL = selection.wrappedValue + let ttlValues = TimedMessagesPreference.ttlValues + let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL]) + ForEach(values, id: \.self) { ttl in + Text(TimedMessagesPreference.ttlText(ttl)) + } + } + .frame(height: 36) +} + struct ContactPreferencesView_Previews: PreviewProvider { static var previews: some View { ContactPreferencesView( diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index c416223431..fe6aeea850 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -22,6 +22,7 @@ struct GroupPreferencesView: View { let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" VStack { List { + featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.voice, $preferences.voice.enable) @@ -35,6 +36,15 @@ struct GroupPreferencesView: View { } } } + .onChange(of: preferences.timedMessages.enable) { enable in + if enable == .on { + if preferences.timedMessages.ttl == nil { + preferences.timedMessages.ttl = 86400 + } + } else { + preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl + } + } .modifier(BackButton { if currentPreferences == preferences { dismiss() @@ -55,20 +65,25 @@ struct GroupPreferencesView: View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon - if (groupInfo.canEdit) { + let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on + if groupInfo.canEdit { + let enable = Binding( + get: { enableFeature.wrappedValue == .on }, + set: { on, _ in enableFeature.wrappedValue = on ? .on : .off } + ) settingsRow(icon, color: color) { - Picker(feature.text, selection: enableFeature) { - ForEach(GroupFeatureEnabled.values) { enable in - Text(enable.text) - } - } - .frame(height: 36) + Toggle(feature.text, isOn: enable) } - } - else { + if timedOn { + timedMessagesTTLPicker($preferences.timedMessages.ttl) + } + } else { settingsRow(icon, color: color) { infoRow(feature.text, enableFeature.wrappedValue.text) } + if timedOn { + infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl)) + } } } footer: { Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit)) diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index 90907d59ad..dc4f27e860 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -18,6 +18,7 @@ struct PreferencesView: View { var body: some View { VStack { List { + timedMessagesFeatureSection($preferences.timedMessages.allow) featureSection(.fullDelete, $preferences.fullDelete.allow) featureSection(.voice, $preferences.voice.allow) @@ -40,10 +41,27 @@ struct PreferencesView: View { } .frame(height: 36) } - } footer: { - Text(feature.allowDescription(allowFeature.wrappedValue)) - .frame(height: 36, alignment: .topLeading) } + footer: { featureFooter(feature, allowFeature) } + + } + + private func timedMessagesFeatureSection(_ allowFeature: Binding) -> some View { + Section { + let allow = Binding( + get: { allowFeature.wrappedValue == .always || allowFeature.wrappedValue == .yes }, + set: { yes, _ in allowFeature.wrappedValue = yes ? .yes : .no } + ) + settingsRow(ChatFeature.timedMessages.icon) { + Toggle(ChatFeature.timedMessages.text, isOn: allow) + } + } + footer: { featureFooter(.timedMessages, allowFeature) } + } + + private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding) -> some View { + Text(ChatFeature.timedMessages.allowDescription(allowFeature.wrappedValue)) + .frame(height: 36, alignment: .topLeading) } private func savePreferences() { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ba405a1805..e8b3cc5f1b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -125,35 +125,39 @@ extension NamedChat { public typealias ChatId = String public struct FullPreferences: Decodable, Equatable { - public var timedMessages: Preference - public var fullDelete: Preference - public var voice: Preference + public var timedMessages: TimedMessagesPreference + public var fullDelete: SimplePreference + public var voice: SimplePreference - public init(timedMessages: Preference, fullDelete: Preference, voice: Preference) { + public init(timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, voice: SimplePreference) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.voice = voice } public static let sampleData = FullPreferences( - timedMessages: Preference(allow: .no), - fullDelete: Preference(allow: .no), - voice: Preference(allow: .yes) + timedMessages: TimedMessagesPreference(allow: .no), + fullDelete: SimplePreference(allow: .no), + voice: SimplePreference(allow: .yes) ) } public struct Preferences: Codable { - public var timedMessages: Preference? - public var fullDelete: Preference? - public var voice: Preference? + public var timedMessages: TimedMessagesPreference? + public var fullDelete: SimplePreference? + public var voice: SimplePreference? - public init(timedMessages: Preference?, fullDelete: Preference?, voice: Preference?) { + public init(timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, voice: SimplePreference?) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.voice = voice } - public static let sampleData = Preferences(timedMessages: Preference(allow: .no), fullDelete: Preference(allow: .no), voice: Preference(allow: .yes)) + public static let sampleData = Preferences( + timedMessages: TimedMessagesPreference(allow: .no), + fullDelete: SimplePreference(allow: .no), + voice: SimplePreference(allow: .yes) + ) } public func fullPreferencesToPreferences(_ fullPreferences: FullPreferences) -> Preferences { @@ -172,7 +176,11 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac ) } -public struct Preference: Codable, Equatable { +public protocol Preference: Codable, Equatable { + var allow: FeatureAllowed { get set } +} + +public struct SimplePreference: Preference { public var allow: FeatureAllowed public init(allow: FeatureAllowed) { @@ -180,42 +188,96 @@ public struct Preference: Codable, Equatable { } } -public struct ContactUserPreferences: Decodable { - public var timedMessages: ContactUserPreference - public var fullDelete: ContactUserPreference - public var voice: ContactUserPreference +public struct TimedMessagesPreference: Preference { + public var allow: FeatureAllowed + public var ttl: Int? - public init(timedMessages: ContactUserPreference, fullDelete: ContactUserPreference, voice: ContactUserPreference) { + public init(allow: FeatureAllowed, ttl: Int? = nil) { + self.allow = allow + self.ttl = ttl + } + + public static var ttlValues: [Int?] { + [30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400] + } + + public static func ttlText(_ ttl: Int?) -> String { + guard let ttl = ttl else { return "off" } + if ttl == 0 { return "0 sec" } + let (m_, s) = divMod(ttl, by: 60) + let (h_, m) = divMod(m_, by: 60) + let (d_, h) = divMod(h_, by: 24) + let (mm, d) = divMod(d_, by: 30) + return maybe(mm, mm == 1 ? "1 month" : "\(mm) months") + + maybe(d, d == 1 ? "1 day" : d == 7 ? "1 week" : d == 14 ? "2 weeks" : "\(d) days") + + maybe(h, h == 1 ? "1 hour" : "\(h) hours") + + maybe(m, "\(m) min") + + maybe (s, "\(s) sec") + } + + public static func shortTtlText(_ ttl: Int?) -> String { + guard let ttl = ttl else { return "off" } + let m = ttl / 60 + if m == 0 { return "\(ttl)s" } + let h = m / 60 + if h == 0 { return "\(m)m" } + let d = h / 24 + if d == 0 { return "\(h)h" } + let mm = d / 30 + if mm > 0 { return "\(mm)mth" } + let w = d / 7 + return w == 0 || d % 7 != 0 ? "\(d)d" : "\(w)w" + } + + static func divMod(_ n: Int, by d: Int) -> (Int, Int) { + (n / d, n % d) + } + + static func maybe(_ n: Int, _ s: String) -> String { + n == 0 ? "" : s + } +} + +public struct ContactUserPreferences: Decodable { + public var timedMessages: ContactUserPreference + public var fullDelete: ContactUserPreference + public var voice: ContactUserPreference + + public init( + timedMessages: ContactUserPreference, + fullDelete: ContactUserPreference, + voice: ContactUserPreference + ) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.voice = voice } public static let sampleData = ContactUserPreferences( - timedMessages: ContactUserPreference( + timedMessages: ContactUserPreference( enabled: FeatureEnabled(forUser: false, forContact: false), - userPreference: .user(preference: Preference(allow: .no)), - contactPreference: Preference(allow: .no) + userPreference: ContactUserPref.user(preference: TimedMessagesPreference(allow: .no)), + contactPreference: TimedMessagesPreference(allow: .no) ), - fullDelete: ContactUserPreference( + fullDelete: ContactUserPreference( enabled: FeatureEnabled(forUser: false, forContact: false), - userPreference: .user(preference: Preference(allow: .no)), - contactPreference: Preference(allow: .no) + userPreference: ContactUserPref.user(preference: SimplePreference(allow: .no)), + contactPreference: SimplePreference(allow: .no) ), - voice: ContactUserPreference( + voice: ContactUserPreference( enabled: FeatureEnabled(forUser: true, forContact: true), - userPreference: .user(preference: Preference(allow: .yes)), - contactPreference: Preference(allow: .yes) + userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), + contactPreference: SimplePreference(allow: .yes) ) ) } -public struct ContactUserPreference: Decodable { +public struct ContactUserPreference: Decodable { public var enabled: FeatureEnabled - public var userPreference: ContactUserPref - public var contactPreference: Preference + public var userPreference: ContactUserPref

+ public var contactPreference: P - public init(enabled: FeatureEnabled, userPreference: ContactUserPref, contactPreference: Preference) { + public init(enabled: FeatureEnabled, userPreference: ContactUserPref

, contactPreference: P) { self.enabled = enabled self.userPreference = userPreference self.contactPreference = contactPreference @@ -231,7 +293,7 @@ public struct FeatureEnabled: Decodable { self.forContact = forContact } - public static func enabled(asymmetric: Bool, user: Preference, contact: Preference) -> FeatureEnabled { + public static func enabled(asymmetric: Bool, user: any Preference, contact: any Preference) -> FeatureEnabled { switch (user.allow, contact.allow) { case (.always, .no): return FeatureEnabled(forUser: false, forContact: asymmetric) case (.no, .always): return FeatureEnabled(forUser: asymmetric, forContact: false) @@ -253,20 +315,29 @@ public struct FeatureEnabled: Decodable { } } -public enum ContactUserPref: Decodable { - case contact(preference: Preference) // contact override is set - case user(preference: Preference) // global user default is used +public enum ContactUserPref: Decodable { + case contact(preference: P) // contact override is set + case user(preference: P) // global user default is used - public var preference: Preference { + public var preference: P { switch self { case let .contact(preference): return preference case let .user(preference): return preference } } + + var contactOverride: P? { + switch self { + case let .contact(preference): return preference + case .user: return nil + } + } } public protocol Feature { var iconFilled: String { get } + var hasParam: Bool { get } + var text: String { get } } public enum ChatFeature: String, Decodable, Feature { @@ -285,6 +356,13 @@ public enum ChatFeature: String, Decodable, Feature { } } + public var hasParam: Bool { + switch self { + case .timedMessages: return true + default: return false + } + } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -372,6 +450,13 @@ public enum GroupFeature: String, Decodable, Feature { public var id: Self { self } + public var hasParam: Bool { + switch self { + case .timedMessages: return true + default: return false + } + } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -482,32 +567,38 @@ public enum ContactFeatureAllowed: Identifiable, Hashable { } public struct ContactFeaturesAllowed: Equatable { - public var timedMessages: ContactFeatureAllowed + public var timedMessagesAllowed: Bool + public var timedMessagesTTL: Int? public var fullDelete: ContactFeatureAllowed public var voice: ContactFeatureAllowed - public init(timedMessages: ContactFeatureAllowed, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) { - self.timedMessages = timedMessages + public init(timedMessagesAllowed: Bool, timedMessagesTTL: Int?, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed) { + self.timedMessagesAllowed = timedMessagesAllowed + self.timedMessagesTTL = timedMessagesTTL self.fullDelete = fullDelete self.voice = voice } public static let sampleData = ContactFeaturesAllowed( - timedMessages: ContactFeatureAllowed.userDefault(.no), + timedMessagesAllowed: false, + timedMessagesTTL: nil, fullDelete: ContactFeatureAllowed.userDefault(.no), voice: ContactFeatureAllowed.userDefault(.yes) ) } public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactUserPreferences) -> ContactFeaturesAllowed { - ContactFeaturesAllowed( - timedMessages: contactUserPrefToFeatureAllowed(contactUserPreferences.timedMessages), + let pref = contactUserPreferences.timedMessages.userPreference + let allow = pref.contactOverride?.allow + return ContactFeaturesAllowed( + timedMessagesAllowed: allow == .yes || allow == .always, + timedMessagesTTL: pref.preference.ttl, fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice) ) } -public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed { +public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUserPreference) -> ContactFeatureAllowed { switch contactUserPreference.userPreference { case let .user(preference): return .userDefault(preference.allow) case let .contact(preference): @@ -521,18 +612,18 @@ public func contactUserPrefToFeatureAllowed(_ contactUserPreference: ContactUser public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeaturesAllowed) -> Preferences { Preferences( - timedMessages: contactFeatureAllowedToPref(contactFeaturesAllowed.timedMessages), + timedMessages: TimedMessagesPreference(allow: contactFeaturesAllowed.timedMessagesAllowed ? .yes : .no, ttl: contactFeaturesAllowed.timedMessagesTTL), fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice) ) } -public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> Preference? { +public func contactFeatureAllowedToPref(_ contactFeatureAllowed: ContactFeatureAllowed) -> SimplePreference? { switch contactFeatureAllowed { case .userDefault: return nil - case .always: return Preference(allow: .always) - case .yes: return Preference(allow: .yes) - case .no: return Preference(allow: .no) + case .always: return SimplePreference(allow: .always) + case .yes: return SimplePreference(allow: .yes) + case .no: return SimplePreference(allow: .no) } } @@ -555,12 +646,12 @@ public enum FeatureAllowed: String, Codable, Identifiable { } public struct FullGroupPreferences: Decodable, Equatable { - public var timedMessages: GroupPreference + public var timedMessages: TimedMessagesGroupPreference public var directMessages: GroupPreference public var fullDelete: GroupPreference public var voice: GroupPreference - public init(timedMessages: GroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) { + public init(timedMessages: TimedMessagesGroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) { self.timedMessages = timedMessages self.directMessages = directMessages self.fullDelete = fullDelete @@ -568,7 +659,7 @@ public struct FullGroupPreferences: Decodable, Equatable { } public static let sampleData = FullGroupPreferences( - timedMessages: GroupPreference(enable: .off), + timedMessages: TimedMessagesGroupPreference(enable: .off), directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on) @@ -576,12 +667,12 @@ public struct FullGroupPreferences: Decodable, Equatable { } public struct GroupPreferences: Codable { - public var timedMessages: GroupPreference? + public var timedMessages: TimedMessagesGroupPreference? public var directMessages: GroupPreference? public var fullDelete: GroupPreference? public var voice: GroupPreference? - public init(timedMessages: GroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) { + public init(timedMessages: TimedMessagesGroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) { self.timedMessages = timedMessages self.directMessages = directMessages self.fullDelete = fullDelete @@ -589,7 +680,7 @@ public struct GroupPreferences: Codable { } public static let sampleData = GroupPreferences( - timedMessages: GroupPreference(enable: .off), + timedMessages: TimedMessagesGroupPreference(enable: .off), directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), voice: GroupPreference(enable: .on) @@ -617,6 +708,20 @@ public struct GroupPreference: Codable, Equatable { } } +public struct TimedMessagesGroupPreference: Codable, Equatable { + public var enable: GroupFeatureEnabled + public var ttl: Int? + + public var on: Bool { + enable == .on + } + + public init(enable: GroupFeatureEnabled, ttl: Int? = nil) { + self.enable = enable + self.ttl = ttl + } +} + public enum GroupFeatureEnabled: String, Codable, Identifiable { case on case off @@ -790,6 +895,19 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var timedMessagesTTL: Int? { + switch self { + case let .direct(contact): + let pref = contact.mergedPreferences.timedMessages + return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil + case let .group(groupInfo): + let pref = groupInfo.fullGroupPreferences.timedMessages + return pref.on ? pref.ttl : nil + default: + return nil + } + } + public enum ShowEnableVoiceMessagesAlert { case userEnable case askContact @@ -1476,10 +1594,7 @@ public struct ChatItem: Identifiable, Decodable { } } - public var isRcvNew: Bool { - if case .rcvNew = meta.itemStatus { return true } - return false - } + public var isRcvNew: Bool { meta.isRcvNew } public var isDeletedContent: Bool { switch content { @@ -1618,7 +1733,7 @@ public struct ChatItem: Identifiable, Decodable { } public static func getChatFeatureSample(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> ChatItem { - let content = CIContent.rcvChatFeature(feature: feature, enabled: enabled) + let content = CIContent.rcvChatFeature(feature: feature, enabled: enabled, param: nil) return ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, content.text, .rcvRead, false, false, false), @@ -1677,12 +1792,29 @@ public struct CIMeta: Decodable { public var updatedAt: Date public var itemDeleted: Bool public var itemEdited: Bool + public var itemTimed: CITimed? public var itemLive: Bool? public var editable: Bool public var timestampText: Text { get { formatTimestampText(itemTs) } } public var recent: Bool { updatedAt + 10 > .now } public var isLive: Bool { itemLive == true } + public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil } + + public var isRcvNew: Bool { + if case .rcvNew = itemStatus { return true } + return false + } + + public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? { + switch itemStatus { + case .sndSent: return ("checkmark", metaColor) + case .sndErrorAuth: return ("multiply", .red) + case .sndError: return ("exclamationmark.triangle.fill", .yellow) + case .rcvNew: return ("circlebadge.fill", Color.accentColor) + default: return nil + } + } public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ itemLive: Bool = false, _ editable: Bool = true) -> CIMeta { CIMeta( @@ -1700,6 +1832,11 @@ public struct CIMeta: Decodable { } } +public struct CITimed: Decodable { + public var ttl: Int + public var deleteAt: Date? +} + let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) @@ -1753,10 +1890,10 @@ public enum CIContent: Decodable, ItemContent { case sndGroupEvent(sndGroupEvent: SndGroupEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent) case sndConnEvent(sndConnEvent: SndConnEvent) - case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled) - case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled) - case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference) - case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference) + case rcvChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) + case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) + case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) + case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) case rcvChatFeatureRejected(feature: ChatFeature) case rcvGroupFeatureRejected(groupFeature: GroupFeature) @@ -1776,16 +1913,22 @@ public enum CIContent: Decodable, ItemContent { case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text case let .sndConnEvent(sndConnEvent): return sndConnEvent.text - case let .rcvChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)" - case let .sndChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)" - case let .rcvGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)" - case let .sndGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)" + case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) + case let .sndGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) } } } + static func featureText(_ feature: Feature, _ value: String, _ param: Int?) -> String { + feature.hasParam && param != nil + ? "\(feature.text): \(TimedMessagesPreference.ttlText(param))" + : "\(feature.text): \(value)" + } + public var msgContent: MsgContent? { get { switch self {