diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 68df35d973..d36b3ade8f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -301,9 +301,9 @@ func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo { throw r } -func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? { +func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { let chatModel = ChatModel.shared - let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live) + let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) let r: ChatResponse if type == .direct { var cItem: ChatItem! diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index f93e73411c..dbcee3fbb4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -32,7 +32,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen 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(shortTimeText(ttl)).foregroundColor(color) } r = r + Text(" ") } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6a1f221905..f853103b4d 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -277,8 +277,8 @@ struct ComposeView: View { ZStack(alignment: .leading) { SendMessageView( composeState: $composeState, - sendMessage: { - sendMessage() + sendMessage: { ttl in + sendMessage(ttl: ttl) resetLinkPreview() }, sendLiveMessage: sendLiveMessage, @@ -296,6 +296,9 @@ struct ComposeView: View { }, finishVoiceMessageRecording: finishVoiceMessageRecording, allowVoiceMessagesToContact: allowVoiceMessagesToContact, + // TODO in 5.2 - allow if ttl is not configured + // timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), + timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages) && chat.chatInfo.timedMessagesTTL != nil, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible ) @@ -425,7 +428,7 @@ struct ComposeView: View { && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) { cancelCurrentVoiceRecording() clearCurrentDraft() - sendMessage() + sendMessage(ttl: nil) resetLinkPreview() } else if (composeState.inProgress) { clearCurrentDraft() @@ -470,7 +473,7 @@ struct ComposeView: View { let lm = composeState.liveMessage if (composeState.sendEnabled || composeState.quoting) && (lm == nil || lm?.sentMsg == nil), - let ci = await sendMessageAsync(typedMsg, live: true) { + let ci = await sendMessageAsync(typedMsg, live: true, ttl: nil) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg)) } @@ -486,7 +489,7 @@ struct ComposeView: View { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { if let sentMsg = liveMessageToSend(liveMessage, typedMsg), - let ci = await sendMessageAsync(sentMsg, live: true) { + let ci = await sendMessageAsync(sentMsg, live: true, ttl: nil) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) } @@ -578,15 +581,15 @@ struct ComposeView: View { } } - private func sendMessage() { + private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") - _ = await sendMessageAsync(nil, live: false) + _ = await sendMessageAsync(nil, live: false, ttl: ttl) } } - private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? { + private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage @@ -606,36 +609,36 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) case let .mediaPreviews(mediaPreviews: media): let last = media.count - 1 if last >= 0 { for i in 0.. ChatItem? { + func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData if let data = data, let savedFile = saveAnyImage(data) { - return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live) + return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? { + func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) { - return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live) + return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? { + func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { if let chatItem = await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, file: file, quotedItemId: quoted, msg: mc, - live: live + live: live, + ttl: ttl ) { await MainActor.run { chatModel.removeLiveDummy(animated: false) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 490d770b62..d610dec105 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -13,7 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000 struct SendMessageView: View { @Binding var composeState: ComposeState - var sendMessage: () -> Void + var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil var cancelLiveMessage: (() -> Void)? = nil @@ -23,6 +23,7 @@ struct SendMessageView: View { var startVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil var allowVoiceMessagesToContact: (() -> Void)? = nil + var timedMessageAllowed: Bool = false var onMediaAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace @@ -32,6 +33,9 @@ struct SendMessageView: View { @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 + @State private var showCustomDisappearingMessageDialogue = false + @State private var showCustomTimePicker = false + @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() var maxHeight: CGFloat = 360 var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @@ -147,15 +151,17 @@ struct SendMessageView: View { .padding([.top, .trailing], 4) } - @ViewBuilder private func sendMessageButton() -> some View { - let v = Button(action: sendMessage) { + private func sendMessageButton() -> some View { + Button { + sendMessage(nil) + } label: { Image(systemName: composeState.editing || composeState.liveMessage != nil - ? "checkmark.circle.fill" - : "arrow.up.circle.fill") - .resizable() - .foregroundColor(.accentColor) - .frame(width: sendButtonSize, height: sendButtonSize) - .opacity(sendButtonOpacity) + ? "checkmark.circle.fill" + : "arrow.up.circle.fill") + .resizable() + .foregroundColor(.accentColor) + .frame(width: sendButtonSize, height: sendButtonSize) + .opacity(sendButtonOpacity) } .disabled( !composeState.sendEnabled || @@ -164,22 +170,61 @@ struct SendMessageView: View { composeState.endLiveDisabled ) .frame(width: 29, height: 29) + .contextMenu{ + sendButtonContextMenuItems() + } + .padding([.bottom, .trailing], 4) + .confirmationDialog("Send disappearing message", isPresented: $showCustomDisappearingMessageDialogue, titleVisibility: .visible) { + Button("30 seconds") { sendMessage(30) } + Button("1 minute") { sendMessage(60) } + Button("5 minutes") { sendMessage(300) } + Button("Custom time") { showCustomTimePicker = true } + } + .sheet(isPresented: $showCustomTimePicker, onDismiss: { selectedDisappearingMessageTime = customDisappearingMessageTimeDefault.get() }) { + if #available(iOS 16.0, *) { + disappearingMessageCustomTimePicker() + .presentationDetents([.fraction(0.6)]) + } else { + disappearingMessageCustomTimePicker() + } + } + } + private func disappearingMessageCustomTimePicker() -> some View { + CustomTimePickerView( + selection: $selectedDisappearingMessageTime, + confirmButtonText: "Send", + confirmButtonAction: { + if let time = selectedDisappearingMessageTime { + sendMessage(time) + customDisappearingMessageTimeDefault.set(time) + } + }, + description: "Delete after" + ) + } + + @ViewBuilder private func sendButtonContextMenuItems() -> some View { if composeState.liveMessage == nil, - case .noContextItem = composeState.contextItem, - !composeState.voicePreview && !composeState.editing, - let send = sendLiveMessage, - let update = updateLiveMessage { - v.contextMenu{ + !composeState.editing { + if case .noContextItem = composeState.contextItem, + !composeState.voicePreview, + let send = sendLiveMessage, + let update = updateLiveMessage { Button { startLiveMessage(send: send, update: update) } label: { Label("Send live message", systemImage: "bolt.fill") } } - .padding([.bottom, .trailing], 4) - } else { - v.padding([.bottom, .trailing], 4) + if timedMessageAllowed { + Button { + hideKeyboard() + showCustomDisappearingMessageDialogue = true + } label: { + Label("Disappearing message", systemImage: "stopwatch") + } + } } } @@ -365,7 +410,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, - sendMessage: {}, + sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible ) @@ -375,7 +420,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, - sendMessage: {}, + sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible ) diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index bcdbeaa1c4..c669d95853 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -89,9 +89,16 @@ struct ContactPreferencesView: View { } infoRow("Contact allows", pref.contactPreference.allow.text) if featuresAllowed.timedMessagesAllowed { - timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL) + DropdownCustomTimePicker( + selection: $featuresAllowed.timedMessagesTTL, + label: "Delete after", + dropdownValues: TimedMessagesPreference.ttlValues, + customPickerConfirmButtonText: "Select", + customPickerDescription: "Delete after" + ) + .frame(height: 36) } else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always { - infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl)) + infoRow("Delete after", timeText(pref.contactPreference.ttl)) } } header: { featureHeader(.timedMessages, enabled) } @@ -129,18 +136,6 @@ 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 42f06618ec..652e649ec0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -75,14 +75,21 @@ struct GroupPreferencesView: View { Toggle(feature.text, isOn: enable) } if timedOn { - timedMessagesTTLPicker($preferences.timedMessages.ttl) + DropdownCustomTimePicker( + selection: $preferences.timedMessages.ttl, + label: "Delete after", + dropdownValues: TimedMessagesPreference.ttlValues, + customPickerConfirmButtonText: "Select", + customPickerDescription: "Delete after" + ) + .frame(height: 36) } } else { settingsRow(icon, color: color) { infoRow(Text(feature.text), enableFeature.wrappedValue.text) } if timedOn { - infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl)) + infoRow("Delete after", timeText(preferences.timedMessages.ttl)) } } } footer: { diff --git a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift new file mode 100644 index 0000000000..e9c13676d6 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift @@ -0,0 +1,223 @@ +// +// CustomTimePicker.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.05.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CustomTimePicker: View { + @Binding var selection: Int? + @State var timeUnitsLimits = TimeUnitLimits.defaultUnitsLimits + @State private var selectedUnit: CustomTimeUnit = .second + @State private var selectedDuration: Int = 1 + + struct TimeUnitLimits { + var timeUnit: CustomTimeUnit + var minValue: Int = 1 + var maxValue: Int + + public static func defaultUnitLimits(_ unit: CustomTimeUnit) -> TimeUnitLimits { + switch unit { + case .second: return TimeUnitLimits.init(timeUnit: .second, maxValue: 120) + case .minute: return TimeUnitLimits.init(timeUnit: .minute, maxValue: 120) + case .hour: return TimeUnitLimits.init(timeUnit: .hour, maxValue: 72) + case .day: return TimeUnitLimits.init(timeUnit: .day, maxValue: 30) + case .week: return TimeUnitLimits.init(timeUnit: .week, maxValue: 12) + case .month: return TimeUnitLimits.init(timeUnit: .month, maxValue: 3) + } + } + + public static var defaultUnitsLimits: [TimeUnitLimits] {[ + defaultUnitLimits(.second), + defaultUnitLimits(.minute), + defaultUnitLimits(.hour), + defaultUnitLimits(.day), + defaultUnitLimits(.week), + defaultUnitLimits(.month), + ]} + } + + var body: some View { + HStack(spacing: 0) { + Group { + Picker("Duration", selection: $selectedDuration) { + let selectedUnitLimits = timeUnitsLimits.first(where: { $0.timeUnit == selectedUnit }) ?? TimeUnitLimits.defaultUnitLimits(selectedUnit) + let selectedUnitValues = Array(selectedUnitLimits.minValue...selectedUnitLimits.maxValue) + let values = selectedUnitValues + (selectedUnitValues.contains(selectedDuration) ? [] : [selectedDuration]) + ForEach(values, id: \.self) { value in + Text("\(value)") + } + } + Picker("Unit", selection: $selectedUnit) { + ForEach(timeUnitsLimits.map { $0.timeUnit }, id: \.self) { timeUnit in + Text(timeUnit.text) + } + } + } + .pickerStyle(.wheel) + .frame(minWidth: 0) + .compositingGroup() + .clipped() + } + .onAppear { + if let selection = selection, + selection > 0 { + (selectedUnit, selectedDuration) = CustomTimeUnit.toTimeUnit(seconds: selection) + } else { + selection = selectedUnit.toSeconds * selectedDuration + } + } + .onChange(of: selectedUnit) { unit in + if let maxValue = timeUnitsLimits.first(where: { $0.timeUnit == unit })?.maxValue, + selectedDuration > maxValue { + selectedDuration = maxValue + } else { + selection = unit.toSeconds * selectedDuration + } + } + .onChange(of: selectedDuration) { duration in + selection = selectedUnit.toSeconds * duration + } + } +} + +extension UIPickerView { + open override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height) + } +} + +struct CustomTimePickerView: View { + @Environment(\.dismiss) var dismiss + @Binding var selection: Int? + var confirmButtonText: LocalizedStringKey + var confirmButtonAction: () -> Void + var description: LocalizedStringKey? = nil + var timeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits + + var body: some View { + NavigationView { + customTimePickerView() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + confirmButtonAction() + dismiss() + } label: { + Text(confirmButtonText) + .fontWeight(.medium) + } + .disabled(selection == nil) + } + } + } + } + + private func customTimePickerView() -> some View { + VStack(alignment: .leading) { + List { + Group { + Section(description ?? "") { + CustomTimePicker(selection: $selection) + } + } + .listRowInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .listStyle(.insetGrouped) + } + } +} + +struct DropdownCustomTimePicker: View { + @Binding var selection: Int? + var label: LocalizedStringKey + var dropdownValues: [Int?] + var customPickerConfirmButtonText: LocalizedStringKey + var customPickerDescription: LocalizedStringKey? = nil + var customPickerTimeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits + @State private var dropdownSelection: DropdownSelection = .dropdownValue(value: nil) + @State private var showCustomTimePicker = false + @State private var selectedCustomTime: Int? = nil + + enum DropdownSelection: Hashable { + case dropdownValue(value: Int?) + case custom + } + + var body: some View { + Picker(label, selection: $dropdownSelection) { + let values: [DropdownSelection] = + dropdownValues.map { .dropdownValue(value: $0) } + + (dropdownValues.contains(selection) ? [] : [.dropdownValue(value: selection)]) + + [.custom] + ForEach(values, id: \.self) { v in + switch v { + case let .dropdownValue(value): Text(timeText(value)) + case .custom: Text(NSLocalizedString("custom", comment: "dropdown time picker choice")) + } + } + } + .onAppear { + dropdownSelection = .dropdownValue(value: selection) + } + .onChange(of: selection) { v in + logger.debug("*** .onChange(of: selection)") + dropdownSelection = .dropdownValue(value: v) + } + .onChange(of: dropdownSelection) { v in + logger.debug("*** .onChange(of: dropdownSelection)") + switch v { + case let .dropdownValue(value): selection = value + case .custom: showCustomTimePicker = true + } + } + .sheet( + isPresented: $showCustomTimePicker, + onDismiss: { + dropdownSelection = .dropdownValue(value: selection) + selectedCustomTime = nil + } + ) { + if #available(iOS 16.0, *) { + customTimePicker() + .presentationDetents([.fraction(0.6)]) + } else { + customTimePicker() + } + } + } + + private func customTimePicker() -> some View { + CustomTimePickerView( + selection: $selectedCustomTime, + confirmButtonText: customPickerConfirmButtonText, + confirmButtonAction: { + if let time = selectedCustomTime { + selection = time + } + }, + description: customPickerDescription, + timeUnitsLimits: customPickerTimeUnitsLimits + ) + .onAppear { + selectedCustomTime = selection + } + } +} + +struct CustomTimePicker_Previews: PreviewProvider { + static var previews: some View { + CustomTimePicker( + selection: Binding.constant(300) + ) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 52132fb6e4..a7161e08e0 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -72,7 +72,7 @@ struct TerminalView: View { SendMessageView( composeState: $composeState, - sendMessage: sendMessage, + sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible @@ -108,7 +108,7 @@ struct TerminalView: View { .onDisappear { terminalItem = nil } } - func sendMessage() { + func consoleSendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 714de5b2b2..8533060c84 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -48,6 +48,7 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" +let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -76,6 +77,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, + DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, ] enum SimpleXLinkMode: String, Identifiable { @@ -112,6 +114,8 @@ let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.sta let onboardingStageDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ONBOARDING_STAGE, withDefault: .onboardingComplete) +let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME) + func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index de0fde24a1..f970fc394a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -173,6 +173,7 @@ 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; + 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; @@ -444,6 +445,7 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; + 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; @@ -612,6 +614,7 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */, 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */, 64466DCB29FFE3E800E3D48D /* MailView.swift */, + 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, ); path = Helpers; sourceTree = ""; @@ -1190,6 +1193,7 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, + 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index eb21b347d8..ebee5973a5 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -37,7 +37,7 @@ public enum ChatCommand { case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(itemId: Int64) - case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool) + case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) @@ -141,9 +141,10 @@ public enum ChatCommand { case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatItemInfo(itemId): return "/_get item info \(itemId)" - case let .apiSendMessage(type, id, file, quotedItemId, mc, live): + case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) - return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)" + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9369fccccc..668b901113 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -280,60 +280,107 @@ public struct TimedMessagesPreference: Preference { } public static var ttlValues: [Int?] { - [30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil] + [3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil] + } +} + +public enum CustomTimeUnit { + case second + case minute + case hour + case day + case week + case month + + public var toSeconds: Int { + switch self { + case .second: return 1 + case .minute: return 60 + case .hour: return 3600 + case .day: return 86400 + case .week: return 7 * 86400 + case .month: return 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 - ? NSLocalizedString("1 month", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "message ttl"), mm) - ) - + maybe(d, - d == 1 - ? NSLocalizedString("1 day", comment: "message ttl") - : d == 7 - ? NSLocalizedString("1 week", comment: "message ttl") - : d == 14 - ? NSLocalizedString("2 weeks", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "message ttl"), d) - ) - + maybe(h, - h == 1 - ? NSLocalizedString("1 hour", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "message ttl"), h) - ) - + maybe(m, String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "message ttl"), m)) - + maybe(s, String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "message ttl"), s)) + public var text: String { + switch self { + case .second: return NSLocalizedString("seconds", comment: "time unit") + case .minute: return NSLocalizedString("minutes", comment: "time unit") + case .hour: return NSLocalizedString("hours", comment: "time unit") + case .day: return NSLocalizedString("days", comment: "time unit") + case .week: return NSLocalizedString("weeks", comment: "time unit") + case .month: return NSLocalizedString("months", comment: "time unit") + } } - public static func shortTtlText(_ ttl: Int?) -> LocalizedStringKey { - 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" + public static func toTimeUnit(seconds: Int) -> (CustomTimeUnit, Int) { + let tryUnits = [month, week, day, hour, minute] + var selectedUnit: (CustomTimeUnit, Int)? = nil + for unit in tryUnits { + let (v, r) = divMod(seconds, by: unit.toSeconds) + if r == 0 { + selectedUnit = (unit, v) + break + } + } + return selectedUnit ?? (CustomTimeUnit.second, seconds) } - static func divMod(_ n: Int, by d: Int) -> (Int, Int) { + private 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 static func toText(seconds: Int) -> String { + let (unit, value) = toTimeUnit(seconds: seconds) + switch unit { + case .second: + return String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "time interval"), value) + case .minute: + return String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "time interval"), value) + case .hour: + return value == 1 + ? NSLocalizedString("1 hour", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "time interval"), value) + case .day: + return value == 1 + ? NSLocalizedString("1 day", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "time interval"), value) + case .week: + return value == 1 + ? NSLocalizedString("1 week", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d weeks", comment: "time interval"), value) + case .month: + return value == 1 + ? NSLocalizedString("1 month", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), value) + } } + + public static func toShortText(seconds: Int) -> LocalizedStringKey { + let (unit, value) = toTimeUnit(seconds: seconds) + switch unit { + case .second: return "\(value)s" + case .minute: return "\(value)m" + case .hour: return "\(value)h" + case .day: return "\(value)d" + case .week: return "\(value)w" + case .month: return "\(value)mth" + } + } +} + + +public func timeText(_ seconds: Int?) -> String { + guard let seconds = seconds else { return "off" } + if seconds == 0 { return "0 sec" } + return CustomTimeUnit.toText(seconds: seconds) +} + +public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey { + guard let seconds = seconds else { return "off" } + if seconds == 0 { return "0s" } + return CustomTimeUnit.toShortText(seconds: seconds) } public struct ContactUserPreferences: Decodable { @@ -2249,13 +2296,13 @@ public enum CIContent: Decodable, ItemContent { static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam - ? "\(feature.text): \(TimedMessagesPreference.ttlText(param))" + ? "\(feature.text): \(timeText(param))" : "\(feature.text): \(enabled)" } public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String { allowed != .no && feature.hasParam && param != nil - ? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, TimedMessagesPreference.ttlText(param)) + ? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, timeText(param)) : allowed != .no ? String.localizedStringWithFormat(NSLocalizedString("offered %@", comment: "feature offered item"), feature.text) : String.localizedStringWithFormat(NSLocalizedString("cancelled %@", comment: "feature offered item"), feature.text)