ios: allow to set disappearance interval when sending message; allow to configure custom interval (#2428)

* ios: allow to set disappearance interval when sending message; allow to configure custom interval

* custom time picker wip

* improve interaction with time picker - touch area, cancel, keyboard, preference

* dropdown picker, refactor, text

* button condition

* weeks limit

* refactor

* update texts

* simplify

* fix null selection

* texts, set current, switch columns

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
spaced4ndy 2023-05-15 16:07:55 +04:00 committed by GitHub
parent d62761b3a8
commit 25156bb56c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 442 additions and 112 deletions

View file

@ -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!

View file

@ -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(" ")
}

View file

@ -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..<last {
if case (_, .video(_, _, _)) = media[i] {
sent = await sendVideo(media[i])
sent = await sendVideo(media[i], ttl: ttl)
} else {
sent = await sendImage(media[i])
sent = await sendImage(media[i], ttl: ttl)
}
_ = try? await Task.sleep(nanoseconds: 100_000000)
}
if case (_, .video(_, _, _)) = media[last] {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live)
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live)
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
}
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
}
}
@ -691,30 +694,31 @@ struct ComposeView: View {
}
}
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> 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)

View file

@ -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,8 +151,10 @@ 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")
@ -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,
!composeState.editing {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
let send = sendLiveMessage,
let update = updateLiveMessage {
v.contextMenu{
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
)

View file

@ -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<Int?>) -> 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(

View file

@ -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: {

View file

@ -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)
)
}
}

View file

@ -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")))

View file

@ -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<LAMode>(defaults: UserDefaults.sta
let onboardingStageDefault = EnumDefault<OnboardingStage>(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))
}

View file

@ -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 = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
@ -612,6 +614,7 @@
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */,
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */,
64466DCB29FFE3E800E3D48D /* MailView.swift */,
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -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 */,

View file

@ -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)"

View file

@ -280,62 +280,109 @@ 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 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 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 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 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")
}
}
static func divMod(_ n: Int, by d: Int) -> (Int, Int) {
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)
}
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 {
public var timedMessages: ContactUserPreference<TimedMessagesPreference>
public var fullDelete: ContactUserPreference<SimplePreference>
@ -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)