mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-29 04:39:53 +00:00
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:
parent
d62761b3a8
commit
25156bb56c
12 changed files with 442 additions and 112 deletions
|
@ -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!
|
||||
|
|
|
@ -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(" ")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: {
|
||||
|
|
223
apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
Normal file
223
apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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")))
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue