diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index fca43254fe..f0e3c66ec5 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -160,6 +160,9 @@ dependencies { // Video support implementation "com.google.android.exoplayer:exoplayer:2.17.1" + // Wheel picker + implementation 'com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 9f8e76da32..725377fcd8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1715,18 +1715,18 @@ sealed class CIContent: ItemContent { companion object { fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { - "${feature.text}: ${TimedMessagesPreference.ttlText(param)}" + "${feature.text}: ${timeText(param)}" } else { "${feature.text}: $enabled" } fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when { allowed != FeatureAllowed.NO && feature.hasParam && param != null -> - String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_offered_item_with_param), feature.text, timeText(param)) allowed != FeatureAllowed.NO -> - String.format(generalGetString(R.string.feature_offered_item), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_offered_item), feature.text, timeText(param)) else -> - String.format(generalGetString(R.string.feature_cancelled_item), feature.text, TimedMessagesPreference.ttlText(param)) + String.format(generalGetString(R.string.feature_cancelled_item), feature.text, timeText(param)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e38e6d0b9b..1a66d6d44b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -165,6 +165,7 @@ class AppPreferences(val context: Context) { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) + val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -289,6 +290,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_THEMES = "Themes" private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion" private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" + private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" } } @@ -579,8 +581,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false): AChatItem? { - val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live) + suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) val r = sendCmd(cmd) return when (r) { is CR.NewChatItem -> r.chatItem @@ -1893,7 +1895,7 @@ sealed class CC { class ApiStorageEncryption(val config: DBEncryptionConfig): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() @@ -1982,7 +1984,10 @@ sealed class CC { is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") - is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + is ApiSendMessage -> { + val ttlStr = if (ttl != null) "$ttl" else "default" + "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" + } is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" @@ -2555,52 +2560,103 @@ data class TimedMessagesPreference( ): ChatPreference { companion object { val ttlValues: List - get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, null) + get() = listOf(3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, null) + } +} - fun ttlText(ttl: Int?): String { - ttl ?: return generalGetString(R.string.feature_off) - if (ttl == 0) return String.format(generalGetString(R.string.ttl_sec), 0) - val (m_, s) = divMod(ttl, 60) - val (h_, m) = divMod(m_, 60) - val (d_, h) = divMod(h_, 24) - val (mm, d) = divMod(d_, 30) - return maybe(mm, if (mm == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), mm)) + - maybe(d, if (d == 1) String.format(generalGetString(R.string.ttl_day), 1) else if (d == 7) String.format(generalGetString(R.string.ttl_week), 1) else if (d == 14) String.format(generalGetString(R.string.ttl_weeks), 2) else String.format(generalGetString(R.string.ttl_days), d)) + - maybe(h, if (h == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), h)) + - maybe(m, String.format(generalGetString(R.string.ttl_min), m)) + - maybe(s, String.format(generalGetString(R.string.ttl_sec), s)) +sealed class CustomTimeUnit { + object Second: CustomTimeUnit() + object Minute: CustomTimeUnit() + object Hour: CustomTimeUnit() + object Day: CustomTimeUnit() + object Week: CustomTimeUnit() + object Month: CustomTimeUnit() + + val toSeconds: Int + get() = + when (this) { + Second -> 1 + Minute -> 60 + Hour -> 3600 + Day -> 86400 + Week -> 7 * 86400 + Month -> 30 * 86400 + } + + val text: String + get() = + when (this) { + Second -> generalGetString(R.string.custom_time_unit_seconds) + Minute -> generalGetString(R.string.custom_time_unit_minutes) + Hour -> generalGetString(R.string.custom_time_unit_hours) + Day -> generalGetString(R.string.custom_time_unit_days) + Week -> generalGetString(R.string.custom_time_unit_weeks) + Month -> generalGetString(R.string.custom_time_unit_months) + } + + companion object { + fun toTimeUnit(seconds: Int): Pair { + val tryUnits = listOf(Month, Week, Day, Hour, Minute) + var selectedUnit: Pair? = null + for (unit in tryUnits) { + val (v, r) = divMod(seconds, unit.toSeconds) + if (r == 0) { + selectedUnit = Pair(unit, v) + break + } + } + return selectedUnit ?: Pair(Second, seconds) } - fun shortTtlText(ttl: Int?): String { - ttl ?: return generalGetString(R.string.feature_off) - val m = ttl / 60 - if (m == 0) { - return String.format(generalGetString(R.string.ttl_s), ttl) - } - val h = m / 60 - if (h == 0) { - return String.format(generalGetString(R.string.ttl_m), m) - } - val d = h / 24 - if (d == 0) { - return String.format(generalGetString(R.string.ttl_h), h) - } - val mm = d / 30 - if (mm > 0) { - return String.format(generalGetString(R.string.ttl_mth), mm) - } - val w = d / 7 - return if (w == 0 || d % 7 != 0) String.format(generalGetString(R.string.ttl_d), d) else String.format(generalGetString(R.string.ttl_w), w) - } - - fun divMod(n: Int, d: Int): Pair = + private fun divMod(n: Int, d: Int): Pair = n / d to n % d - fun maybe(n: Int, s: String): String = - if (n == 0) "" else s + fun toText(seconds: Int): String { + val (unit, value) = toTimeUnit(seconds) + return when (unit) { + Second -> String.format(generalGetString(R.string.ttl_sec), value) + Minute -> String.format(generalGetString(R.string.ttl_min), value) + Hour -> if (value == 1) String.format(generalGetString(R.string.ttl_hour), 1) else String.format(generalGetString(R.string.ttl_hours), value) + Day -> if (value == 1) String.format(generalGetString(R.string.ttl_day), 1) else String.format(generalGetString(R.string.ttl_days), value) + Week -> if (value == 1) String.format(generalGetString(R.string.ttl_week), 1) else String.format(generalGetString(R.string.ttl_weeks), value) + Month -> if (value == 1) String.format(generalGetString(R.string.ttl_month), 1) else String.format(generalGetString(R.string.ttl_months), value) + } + } + + fun toShortText(seconds: Int): String { + val (unit, value) = toTimeUnit(seconds) + return when (unit) { + Second -> String.format(generalGetString(R.string.ttl_s), value) + Minute -> String.format(generalGetString(R.string.ttl_m), value) + Hour -> String.format(generalGetString(R.string.ttl_h), value) + Day -> String.format(generalGetString(R.string.ttl_d), value) + Week -> String.format(generalGetString(R.string.ttl_w), value) + Month -> String.format(generalGetString(R.string.ttl_mth), value) + } + } } } +fun timeText(seconds: Int?): String { + if (seconds == null) { + return generalGetString(R.string.feature_off) + } + if (seconds == 0) { + String.format(generalGetString(R.string.ttl_sec), 0) + } + return CustomTimeUnit.toText(seconds) +} + +fun shortTimeText(seconds: Int?): String { + if (seconds == null) { + return generalGetString(R.string.feature_off) + } + if (seconds == 0) { + String.format(generalGetString(R.string.ttl_s), 0) + } + return CustomTimeUnit.toShortText(seconds) +} + @Serializable data class ContactUserPreferences( val timedMessages: ContactUserPreferenceTimed, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 15688db8be..cb00859314 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -86,7 +86,7 @@ fun TerminalLayout( userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, - sendMessage = sendCommand, + sendMessage = { sendCommand() }, sendLiveMessage = null, updateLiveMessage = null, onMessageChange = ::onMessageChange, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 8cc8c96772..3b770e55f6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -366,14 +366,15 @@ fun ComposeView( chatModel.filesToDelete.clear() } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, file = file, quotedItemId = quoted, mc = mc, - live = live + live = live, + ttl = ttl ) if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) return aChatItem?.chatItem @@ -381,7 +382,7 @@ fun ComposeView( - suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? { + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { val cInfo = chat.chatInfo val cs = composeState.value var sent: ChatItem? @@ -495,7 +496,8 @@ fun ComposeView( msgs.forEachIndexed { index, content -> if (index > 0) delay(100) sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), - if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false + live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, + ttl = ttl ) } if (sent == null && @@ -503,16 +505,16 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live) + sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) return sent } - fun sendMessage() { + fun sendMessage(ttl: Int?) { withBGApi { - sendMessageAsync(null, false) + sendMessageAsync(null, false, ttl) } } @@ -588,7 +590,7 @@ fun ComposeView( val cs = composeState.value val typedMsg = cs.message if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) { - val ci = sendMessageAsync(typedMsg, live = true) + val ci = sendMessageAsync(typedMsg, live = true, ttl = null) if (ci != null) { composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) } @@ -609,7 +611,7 @@ fun ComposeView( if (liveMessage != null) { val sentMsg = liveMessageToSend(liveMessage, typedMsg) if (sentMsg != null) { - val ci = sendMessageAsync(sentMsg, live = true) + val ci = sendMessageAsync(sentMsg, live = true, ttl = null) if (ci != null) { composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } @@ -763,7 +765,7 @@ fun ComposeView( if (orientation == activity.resources.configuration.orientation) { val cs = composeState.value if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage() + sendMessage(null) resetLinkPreview() clearCurrentDraft() deleteUnusedFiles() @@ -784,6 +786,9 @@ fun ComposeView( } } + // TODO in 5.2 - allow if ttl is not configured + // val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) && chat.chatInfo.timedMessagesTTL != null } SendMsgView( composeState, showVoiceRecordIcon = true, @@ -795,8 +800,10 @@ fun ComposeView( allowVoiceToContact = ::allowVoiceToContact, userIsObserver = userIsObserver.value, userCanSend = userCanSend.value, - sendMessage = { - sendMessage() + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + sendMessage = { ttl -> + sendMessage(ttl) resetLinkPreview() }, sendLiveMessage = ::sendLiveMessage, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt index 0bd06c20d8..a7799d262d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt @@ -188,7 +188,7 @@ private fun TimedMessagesFeatureSection( val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) } TimedMessagesTTLPicker(ttl, onTTLUpdated) } else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) { - InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl)) + InfoRow(generalGetString(R.string.delete_after), timeText(pref.contactPreference.ttl)) } } SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled)) @@ -212,7 +212,7 @@ fun TimedMessagesTTLPicker(selection: MutableState, onSelected: (Int?) -> val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value) ExposedDropDownSettingRow( generalGetString(R.string.delete_after), - values.map { it to TimedMessagesPreference.ttlText(it) }, + values.map { it to timeText(it) }, selection, onSelected = onSelected ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 831badf7fa..94087754f1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -18,7 +18,7 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* @@ -29,14 +29,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.* import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat @@ -44,8 +44,7 @@ import androidx.core.widget.* import chat.simplex.app.R import chat.simplex.app.SimplexApp import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -64,13 +63,25 @@ fun SendMsgView( userIsObserver: Boolean, userCanSend: Boolean, allowVoiceToContact: () -> Unit, - sendMessage: () -> Unit, + timedMessageAllowed: Boolean = false, + customDisappearingMessageTimePref: SharedPreference? = null, + sendMessage: (Int?) -> Unit, sendLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, onMessageChange: (String) -> Unit, textStyle: MutableState ) { + val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } + + if (showCustomDisappearingMessageDialog.value) { + CustomDisappearingMessageDialog( + sendMessage = sendMessage, + setShowDialog = { showCustomDisappearingMessageDialog.value = it }, + customDisappearingMessageTimePref = customDisappearingMessageTimePref + ) + } + Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview) @@ -80,14 +91,15 @@ fun SendMsgView( NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) { - Box(Modifier - .matchParentSize() - .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.observer_cant_send_message_title), - text = generalGetString(R.string.observer_cant_send_message_desc) - ) - }) + Box( + Modifier + .matchParentSize() + .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.observer_cant_send_message_title), + text = generalGetString(R.string.observer_cant_send_message_desc) + ) + }) ) } if (showDeleteTextButton.value) { @@ -124,10 +136,11 @@ fun SendMsgView( else -> RecordVoiceView(recState, stopRecOnNextClick) } - if (sendLiveMessage != null - && updateLiveMessage != null - && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) - && cs.contextItem is ComposeContextItem.NoContextItem) { + if (sendLiveMessage != null + && updateLiveMessage != null + && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) + && cs.contextItem is ComposeContextItem.NoContextItem + ) { Spacer(Modifier.width(10.dp)) StartLiveMessageButton(userCanSend) { if (composeState.value.preview is ComposePreview.NoPreview) { @@ -146,27 +159,43 @@ fun SendMsgView( val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward) val disabled = !cs.sendEnabled() || - (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled - if (cs.liveMessage == null && - cs.preview !is ComposePreview.VoicePreview && !cs.editing && - cs.contextItem is ComposeContextItem.NoContextItem && - sendLiveMessage != null && updateLiveMessage != null - ) { + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + val showSendLiveMessageMenuButton = + cs.liveMessage == null && !cs.editing && + cs.preview !is ComposePreview.VoicePreview && + cs.contextItem is ComposeContextItem.NoContextItem && + sendLiveMessage != null && updateLiveMessage != null + val showSendDisappearingMessageMenuButton = + cs.liveMessage == null && !cs.editing && + timedMessageAllowed + + if (showSendLiveMessageMenuButton || showSendDisappearingMessageMenuButton) { val showDropdown = rememberSaveable { mutableStateOf(false) } SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true } - DefaultDropdownMenu( showDropdown, ) { - ItemAction( - generalGetString(R.string.send_live_message), - BoltFilled, - onClick = { - startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) - showDropdown.value = false - } - ) + if (showSendLiveMessageMenuButton && sendLiveMessage != null && updateLiveMessage != null) { + ItemAction( + generalGetString(R.string.send_live_message), + BoltFilled, + onClick = { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + showDropdown.value = false + } + ) + } + if (showSendDisappearingMessageMenuButton) { + ItemAction( + generalGetString(R.string.disappearing_message), + painterResource(R.drawable.ic_timer), + onClick = { + showCustomDisappearingMessageDialog.value = true + showDropdown.value = false + } + ) + } } } else { SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) @@ -177,6 +206,99 @@ fun SendMsgView( } } +@Composable +private fun CustomDisappearingMessageDialog( + sendMessage: (Int?) -> Unit, + setShowDialog: (Boolean) -> Unit, + customDisappearingMessageTimePref: SharedPreference? +) { + val showCustomTimePicker = remember { mutableStateOf(false) } + + if (showCustomTimePicker.value) { + val selectedDisappearingMessageTime = remember { + mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) + } + CustomTimePickerDialog( + selectedDisappearingMessageTime, + title = generalGetString(R.string.delete_after), + confirmButtonText = generalGetString(R.string.send_disappearing_message_send), + confirmButtonAction = { ttl -> + sendMessage(ttl) + customDisappearingMessageTimePref?.set?.invoke(ttl) + setShowDialog(false) + }, + cancel = { setShowDialog(false) } + ) + } else { + @Composable + fun ChoiceButton( + text: String, + onClick: () -> Unit + ) { + TextButton(onClick) { + Text( + text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + } + + Dialog(onDismissRequest = { setShowDialog(false) }) { + Surface( + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(" ") // centers title + Text( + generalGetString(R.string.send_disappearing_message), + fontSize = 16.sp, + color = MaterialTheme.colors.secondary + ) + Icon( + painterResource(R.drawable.ic_close), + generalGetString(R.string.icon_descr_close_button), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(25.dp) + .clickable { setShowDialog(false) } + ) + } + + ChoiceButton(generalGetString(R.string.send_disappearing_message_30_seconds)) { + sendMessage(30) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_1_minute)) { + sendMessage(60) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_5_minutes)) { + sendMessage(300) + setShowDialog(false) + } + ChoiceButton(generalGetString(R.string.send_disappearing_message_custom_time)) { + showCustomTimePicker.value = true + } + } + } + } + } + } +} + @Composable private fun NativeKeyboard( composeState: MutableState, @@ -455,14 +577,14 @@ private fun SendMsgButton( sizeDp: Animatable, alpha: Animatable, enabled: Boolean, - sendMessage: () -> Unit, + sendMessage: (Int?) -> Unit, onLongClick: (() -> Unit)? = null ) { val interactionSource = remember { MutableInteractionSource() } Box( modifier = Modifier.requiredSize(36.dp) .combinedClickable( - onClick = sendMessage, + onClick = { sendMessage(null) }, onLongClick = onLongClick, enabled = enabled, role = Role.Button, @@ -607,6 +729,7 @@ fun PreviewSendMsgView() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle @@ -637,6 +760,7 @@ fun PreviewSendMsgViewEditing() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle @@ -667,6 +791,7 @@ fun PreviewSendMsgViewInProgress() { userIsObserver = false, userCanSend = true, allowVoiceToContact = {}, + timedMessageAllowed = false, sendMessage = {}, onMessageChange = { _ -> }, textStyle = textStyle diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt index 76a7978180..e99a0883ad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt @@ -151,7 +151,7 @@ private fun FeatureSection( iconTint = iconTint, ) if (timedOn) { - InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl)) + InfoRow(generalGetString(R.string.delete_after), timeText(preferences.timedMessages.ttl)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 8f7e464e8d..c9e460d03d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -44,7 +44,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { StatusIconText(painterResource(R.drawable.ic_timer), color) val ttl = meta.itemTimed?.ttl if (ttl != chatTTL) { - Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp) + Text(shortTimeText(ttl), color = color, fontSize = 13.sp) } Spacer(Modifier.width(4.dp)) } @@ -69,7 +69,7 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String { res += iconSpace val ttl = meta.itemTimed.ttl if (ttl != chatTTL) { - res += TimedMessagesPreference.shortTtlText(ttl) + res += shortTimeText(ttl) } } if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt new file mode 100644 index 0000000000..7fa045dd15 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt @@ -0,0 +1,193 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import chat.simplex.app.R +import chat.simplex.app.model.CustomTimeUnit +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import com.sd.lib.compose.wheel_picker.* + +@Composable +fun CustomTimePicker( + selection: MutableState, + timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits +) { + fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { + val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit) + val unitValues = (unitLimits.minValue..unitLimits.maxValue).toList() + return unitValues + if (unitValues.contains(selectedValue)) emptyList() else listOf(selectedValue) + } + + val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value) + val selectedUnit: MutableState = remember { mutableStateOf(unit) } + val selectedDuration = remember { mutableStateOf(duration) } + val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) } + + LaunchedEffect(selectedUnit.value) { + val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue + if (maxValue != null && selectedDuration.value > maxValue) { + selectedDuration.value = maxValue + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + } else { + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + } + + LaunchedEffect(selectedDuration.value) { + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + Column(Modifier.weight(1f)) { + val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value)) + FVerticalWheelPicker( + count = selectedUnitValues.value.count(), + state = durationPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + selectedUnitValues.value[index].toString(), + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(durationPickerState) { + snapshotFlow { durationPickerState.currentIndex } + .collect { + selectedDuration.value = selectedUnitValues.value[it] + } + } + } + Column(Modifier.weight(1f)) { + val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value }) + FVerticalWheelPicker( + count = timeUnitsLimits.count(), + state = unitPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + timeUnitsLimits[index].timeUnit.text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(unitPickerState) { + snapshotFlow { unitPickerState.currentIndex } + .collect { + selectedUnit.value = timeUnitsLimits[it].timeUnit + } + } + } + } +} + +data class TimeUnitLimits( + val timeUnit: CustomTimeUnit, + val minValue: Int = 1, + val maxValue: Int +) { + companion object { + fun defaultUnitLimits(unit: CustomTimeUnit): TimeUnitLimits { + return when (unit) { + CustomTimeUnit.Second -> TimeUnitLimits(CustomTimeUnit.Second, maxValue = 120) + CustomTimeUnit.Minute -> TimeUnitLimits(CustomTimeUnit.Minute, maxValue = 120) + CustomTimeUnit.Hour -> TimeUnitLimits(CustomTimeUnit.Hour, maxValue = 72) + CustomTimeUnit.Day -> TimeUnitLimits(CustomTimeUnit.Day, maxValue = 60) + CustomTimeUnit.Week -> TimeUnitLimits(CustomTimeUnit.Week, maxValue = 12) // TODO in 5.2 - 54 + CustomTimeUnit.Month -> TimeUnitLimits(CustomTimeUnit.Month, maxValue = 3) // TODO in 5.2 - 12 + } + } + + val defaultUnitsLimits: List + get() = listOf( + defaultUnitLimits(CustomTimeUnit.Second), + defaultUnitLimits(CustomTimeUnit.Minute), + defaultUnitLimits(CustomTimeUnit.Hour), + defaultUnitLimits(CustomTimeUnit.Day), + defaultUnitLimits(CustomTimeUnit.Week), + defaultUnitLimits(CustomTimeUnit.Month) + ) + } +} + +@Composable +fun CustomTimePickerDialog( + selection: MutableState, + timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits, + title: String, + confirmButtonText: String, + confirmButtonAction: (Int) -> Unit, + cancel: () -> Unit +) { + Dialog(onDismissRequest = cancel) { + Surface( + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(" ") // centers title + Text( + title, + fontSize = 16.sp, + color = MaterialTheme.colors.secondary + ) + Icon( + painterResource(R.drawable.ic_close), + generalGetString(R.string.icon_descr_close_button), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(25.dp) + .clickable { cancel() } + ) + } + + CustomTimePicker( + selection, + timeUnitsLimits + ) + + TextButton(onClick = { confirmButtonAction(selection.value) }) { + Text( + confirmButtonText, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/res/drawable/ic_close.xml b/apps/android/app/src/main/res/drawable/ic_close.xml index fc0f4d3fd6..5acbc7cfcc 100644 --- a/apps/android/app/src/main/res/drawable/ic_close.xml +++ b/apps/android/app/src/main/res/drawable/ic_close.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M480,520.5L271,729.5Q262,738.5 250.75,738.5Q239.5,738.5 230.5,729.5Q221.5,720.5 221.5,709.25Q221.5,698 230.5,689L440,479.5L230.5,270Q221.5,261.5 221.5,250.25Q221.5,239 230.5,230Q239.5,221 250.75,221Q262,221 271,230L480,439.5L689,230.5Q698,221.5 709.25,221.5Q720.5,221.5 729.5,230.5Q738.5,239.5 738.5,250.75Q738.5,262 729.5,271L520.5,480L730,689.5Q738.5,698.5 738.5,709.75Q738.5,721 730,729.5Q721,738.5 709.75,738.5Q698.5,738.5 689.5,729.5L480,520.5Z"/> diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 67734ef17d..11df772d51 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -338,6 +338,13 @@ Please ask your contact to enable sending voice messages. Only group owners can enable voice messages. Send live message + Disappearing message + Send disappearing message + 30 seconds + 1 minute + 5 minutes + Custom time + Send Live message! Send a live message - it will update for the recipient(s) as you type it Send @@ -1392,4 +1399,12 @@ Set it instead of system authentication. Polish interface Thanks to the users – contribute via Weblate! + + + seconds + minutes + hours + days + weeks + months diff --git a/apps/android/settings.gradle b/apps/android/settings.gradle index 64c6620acf..3621283ff6 100644 --- a/apps/android/settings.gradle +++ b/apps/android/settings.gradle @@ -10,6 +10,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } rootProject.name = "SimpleX"