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 10132572b0..514e04ce3c 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 @@ -2,8 +2,13 @@ package chat.simplex.app.model import android.net.Uri import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration @@ -21,8 +26,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File -import kotlin.time.DurationUnit -import kotlin.time.toDuration +import kotlin.time.* /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it @@ -278,7 +282,13 @@ class ChatModel(val controller: ChatController) { while (i < chatItems.count()) { val item = chatItems[i] if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { - chatItems[i] = item.withStatus(CIStatus.RcvRead()) + val newItem = item.withStatus(CIStatus.RcvRead()) + chatItems[i] = newItem + if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { + chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( + deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) + ) + } markedRead++ } i += 1 @@ -400,6 +410,7 @@ interface SomeChat { val incognito: Boolean val voiceMessageAllowed: Boolean val fullDeletionAllowed: Boolean + val timedMessagesTTL: Int? val createdAt: Instant val updatedAt: Instant } @@ -463,6 +474,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val incognito get() = contact.incognito override val voiceMessageAllowed get() = contact.voiceMessageAllowed override val fullDeletionAllowed get() = contact.fullDeletionAllowed + override val timedMessagesTTL: Int? get() = contact.timedMessagesTTL override val createdAt get() = contact.createdAt override val updatedAt get() = contact.updatedAt override val displayName get() = contact.displayName @@ -487,6 +499,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val incognito get() = groupInfo.incognito override val voiceMessageAllowed get() = groupInfo.voiceMessageAllowed override val fullDeletionAllowed get() = groupInfo.fullDeletionAllowed + override val timedMessagesTTL: Int? get() = groupInfo.timedMessagesTTL override val createdAt get() = groupInfo.createdAt override val updatedAt get() = groupInfo.updatedAt override val displayName get() = groupInfo.displayName @@ -511,6 +524,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val incognito get() = contactRequest.incognito override val voiceMessageAllowed get() = contactRequest.voiceMessageAllowed override val fullDeletionAllowed get() = contactRequest.fullDeletionAllowed + override val timedMessagesTTL: Int? get() = contactRequest.timedMessagesTTL override val createdAt get() = contactRequest.createdAt override val updatedAt get() = contactRequest.updatedAt override val displayName get() = contactRequest.displayName @@ -535,6 +549,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val incognito get() = contactConnection.incognito override val voiceMessageAllowed get() = contactConnection.voiceMessageAllowed override val fullDeletionAllowed get() = contactConnection.fullDeletionAllowed + override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL override val createdAt get() = contactConnection.createdAt override val updatedAt get() = contactConnection.updatedAt override val displayName get() = contactConnection.displayName @@ -572,6 +587,7 @@ data class Contact( override val incognito get() = contactConnIncognito override val voiceMessageAllowed get() = mergedPreferences.voice.enabled.forUser override val fullDeletionAllowed get() = mergedPreferences.fullDelete.enabled.forUser + override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null } override val displayName get() = localAlias.ifEmpty { profile.displayName } override val fullName get() = profile.fullName override val image get() = profile.image @@ -706,6 +722,7 @@ data class GroupInfo ( override val incognito get() = membership.memberIncognito override val voiceMessageAllowed get() = fullGroupPreferences.voice.on override val fullDeletionAllowed get() = fullGroupPreferences.fullDelete.on + override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image @@ -953,6 +970,7 @@ class UserContactRequest ( override val incognito get() = false override val voiceMessageAllowed get() = false override val fullDeletionAllowed get() = false + override val timedMessagesTTL: Int? get() = null override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image @@ -991,6 +1009,7 @@ class PendingContactConnection( override val incognito get() = customUserProfileId != null override val voiceMessageAllowed get() = false override val fullDeletionAllowed get() = false + override val timedMessagesTTL: Int? get() = null override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) override val displayName: String get() { if (localAlias.isNotEmpty()) return localAlias @@ -1088,7 +1107,7 @@ data class ChatItem ( } } - val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew + val isRcvNew: Boolean get() = meta.isRcvNew val memberDisplayName: String? get() = if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName @@ -1150,11 +1169,12 @@ data class ChatItem ( file: CIFile? = null, itemDeleted: Boolean = false, itemEdited: Boolean = false, + itemTimed: CITimed? = null, editable: Boolean = true ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, null, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem, file = file @@ -1209,7 +1229,7 @@ data class ChatItem ( ) fun getChatFeatureSample(feature: ChatFeature, enabled: FeatureEnabled): ChatItem { - val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled) + val content = CIContent.RcvChatFeature(feature = feature, enabled = enabled, param = null) return ChatItem( chatDir = CIDirection.DirectRcv(), meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false), @@ -1233,6 +1253,7 @@ data class ChatItem ( updatedAt = Clock.System.now(), itemDeleted = false, itemEdited = false, + itemTimed = null, itemLive = false, editable = false ), @@ -1268,17 +1289,30 @@ data class CIMeta ( val updatedAt: Instant, val itemDeleted: Boolean, val itemEdited: Boolean, + val itemTimed: CITimed?, val itemLive: Boolean?, val editable: Boolean ) { val timestampText: String get() = getTimestampText(itemTs) val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now() val isLive: Boolean get() = itemLive == true + val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null + + val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew + + fun statusIcon(primaryColor: Color, metaColor: Color = HighOrLowlight): Pair? = + when (itemStatus) { + is CIStatus.SndSent -> Icons.Filled.Check to metaColor + is CIStatus.SndErrorAuth -> Icons.Filled.Close to Color.Red + is CIStatus.SndError -> Icons.Filled.WarningAmber to WarningYellow + is CIStatus.RcvNew -> Icons.Filled.Circle to primaryColor + else -> null + } companion object { fun getSample( id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), - itemDeleted: Boolean = false, itemEdited: Boolean = false, itemLive: Boolean = false, editable: Boolean = true + itemDeleted: Boolean = false, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true ): CIMeta = CIMeta( itemId = id, @@ -1289,12 +1323,19 @@ data class CIMeta ( updatedAt = ts, itemDeleted = itemDeleted, itemEdited = itemEdited, + itemTimed = itemTimed, itemLive = itemLive, editable = editable ) } } +@Serializable +data class CITimed( + val ttl: Int, + val deleteAt: Instant? +) + fun getTimestampText(t: Instant): String { val tz = TimeZone.currentSystemDefault() val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz) @@ -1342,10 +1383,10 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndConnEvent") class SndConnEventContent(val sndConnEvent: SndConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvChatFeature") class RcvChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null } @@ -1363,13 +1404,22 @@ sealed class CIContent: ItemContent { is SndGroupEventContent -> sndGroupEvent.text is RcvConnEventContent -> rcvConnEvent.text is SndConnEventContent -> sndConnEvent.text - is RcvChatFeature -> "${feature.text}: ${enabled.text}" - is SndChatFeature -> "${feature.text}: ${enabled.text}" - is RcvGroupFeature -> "${groupFeature.text}: ${preference.enable.text}" - is SndGroupFeature -> "${groupFeature.text}: ${preference.enable.text}" + is RcvChatFeature -> featureText(feature, enabled.text, param) + is SndChatFeature -> featureText(feature, enabled.text, param) + is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param) + is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param) is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(R.string.feature_received_prohibited)}" is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(R.string.feature_received_prohibited)}" } + + companion object { + fun featureText(feature: Feature, value: String, param: Int?): String = + if (feature.hasParam && param != null) { + "${feature.text}: ${TimedMessagesPreference.ttlText(param)}" + } else { + "${feature.text}: $value" + } + } } @Serializable 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 8d1ef98ff1..602c869bd5 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 @@ -2016,39 +2016,101 @@ data class ChatSettings( @Serializable data class FullChatPreferences( - val fullDelete: ChatPreference, - val voice: ChatPreference, + val timedMessages: TimedMessagesPreference, + val fullDelete: SimpleChatPreference, + val voice: SimpleChatPreference, ) { fun toPreferences(): ChatPreferences = ChatPreferences(fullDelete = fullDelete, voice = voice) companion object { - val sampleData = FullChatPreferences(fullDelete = ChatPreference(allow = FeatureAllowed.NO), voice = ChatPreference(allow = FeatureAllowed.YES)) - } -} - -@Serializable -data class ChatPreferences( - val timedMessages: ChatPreference? = null, - val fullDelete: ChatPreference? = null, - val voice: ChatPreference? = null, -) { - companion object { - val sampleData = ChatPreferences( - timedMessages = ChatPreference(allow = FeatureAllowed.NO), - fullDelete = ChatPreference(allow = FeatureAllowed.NO), - voice = ChatPreference(allow = FeatureAllowed.YES) + val sampleData = FullChatPreferences( + timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO), + fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), + voice = SimpleChatPreference(allow = FeatureAllowed.YES) ) } } @Serializable -data class ChatPreference( +data class ChatPreferences( + val timedMessages: TimedMessagesPreference? = null, + val fullDelete: SimpleChatPreference? = null, + val voice: SimpleChatPreference? = null, +) { + companion object { + val sampleData = ChatPreferences( + timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO), + fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), + voice = SimpleChatPreference(allow = FeatureAllowed.YES) + ) + } +} + +interface ChatPreference { val allow: FeatureAllowed -) +} + +@Serializable +data class SimpleChatPreference( + override val allow: FeatureAllowed +): ChatPreference + +@Serializable +class TimedMessagesPreference( + override val allow: FeatureAllowed, + val ttl: Int? = null +): ChatPreference { + companion object { + val ttlValues: List + get() = listOf(30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400) + + 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)) + } + + 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 = + n / d to n % d + + fun maybe(n: Int, s: String): String = + if (n == 0) "" else s + } +} @Serializable data class ContactUserPreferences( - val timedMessages: ContactUserPreference, + val timedMessages: ContactUserPreferenceTimed, val fullDelete: ContactUserPreference, val voice: ContactUserPreference, ) { @@ -2060,30 +2122,37 @@ data class ContactUserPreferences( companion object { val sampleData = ContactUserPreferences( - timedMessages = ContactUserPreference( + timedMessages = ContactUserPreferenceTimed( enabled = FeatureEnabled(forUser = false, forContact = false), - userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)), - contactPreference = ChatPreference(allow = FeatureAllowed.NO) + userPreference = ContactUserPrefTimed.User(preference = TimedMessagesPreference(allow = FeatureAllowed.NO)), + contactPreference = TimedMessagesPreference(allow = FeatureAllowed.NO) ), fullDelete = ContactUserPreference( enabled = FeatureEnabled(forUser = false, forContact = false), - userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.NO)), - contactPreference = ChatPreference(allow = FeatureAllowed.NO) + userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)), + contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO) ), voice = ContactUserPreference( enabled = FeatureEnabled(forUser = true, forContact = true), - userPreference = ContactUserPref.User(preference = ChatPreference(allow = FeatureAllowed.YES)), - contactPreference = ChatPreference(allow = FeatureAllowed.YES) + userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), + contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES) ) ) } } @Serializable -data class ContactUserPreference( +data class ContactUserPreference ( val enabled: FeatureEnabled, val userPreference: ContactUserPref, - val contactPreference: ChatPreference, + val contactPreference: SimpleChatPreference, +) + +@Serializable +data class ContactUserPreferenceTimed ( + val enabled: FeatureEnabled, + val userPreference: ContactUserPrefTimed, + val contactPreference: TimedMessagesPreference, ) @Serializable @@ -2116,22 +2185,49 @@ data class FeatureEnabled( @Serializable sealed class ContactUserPref { - abstract val pref: ChatPreference + abstract val pref: SimpleChatPreference // contact override is set - @Serializable @SerialName("contact") data class Contact(val preference: ChatPreference): ContactUserPref() { + @Serializable @SerialName("contact") data class Contact(val preference: SimpleChatPreference): ContactUserPref() { override val pref get() = preference } // global user default is used - @Serializable @SerialName("user") data class User(val preference: ChatPreference): ContactUserPref() { + @Serializable @SerialName("user") data class User(val preference: SimpleChatPreference): ContactUserPref() { override val pref get() = preference } + + val contactOverride: SimpleChatPreference? + get() = when(this) { + is Contact -> pref + is User -> null + } +} + +@Serializable +sealed class ContactUserPrefTimed { + abstract val pref: TimedMessagesPreference + + // contact override is set + @Serializable @SerialName("contact") data class Contact(val preference: TimedMessagesPreference): ContactUserPrefTimed() { + override val pref get() = preference + } + // global user default is used + @Serializable @SerialName("user") data class User(val preference: TimedMessagesPreference): ContactUserPrefTimed() { + override val pref get() = preference + } + + val contactOverride: TimedMessagesPreference? + get() = when(this) { + is Contact -> pref + is User -> null + } } interface Feature { // val icon: ImageVector val text: String val iconFilled: ImageVector + val hasParam: Boolean } @Serializable @@ -2145,6 +2241,11 @@ enum class ChatFeature: Feature { else -> true } + override val hasParam: Boolean get() = when(this) { + TimedMessages -> true + else -> false + } + override val text: String get() = when(this) { TimedMessages -> generalGetString(R.string.timed_messages) @@ -2215,6 +2316,11 @@ enum class GroupFeature: Feature { @SerialName("fullDelete") FullDelete, @SerialName("voice") Voice; + override val hasParam: Boolean get() = when(this) { + TimedMessages -> true + else -> false + } + override val text: String get() = when(this) { TimedMessages -> generalGetString(R.string.timed_messages) @@ -2310,25 +2416,31 @@ sealed class ContactFeatureAllowed { @Serializable data class ContactFeaturesAllowed( - val timedMessages: ContactFeatureAllowed, + val timedMessagesAllowed: Boolean, + val timedMessagesTTL: Int?, val fullDelete: ContactFeatureAllowed, val voice: ContactFeatureAllowed ) { companion object { val sampleData = ContactFeaturesAllowed( - timedMessages = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO), + timedMessagesAllowed = false, + timedMessagesTTL = null, fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO), voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES) ) } } -fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed = - ContactFeaturesAllowed( - timedMessages = contactUserPrefToFeatureAllowed(contactUserPreferences.timedMessages), +fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPreferences): ContactFeaturesAllowed { + val pref = contactUserPreferences.timedMessages.userPreference + val allow = pref.contactOverride?.allow + return ContactFeaturesAllowed( + timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS, + timedMessagesTTL = pref.pref.ttl, fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice) ) +} fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference): ContactFeatureAllowed = when (val pref = contactUserPreference.userPreference) { @@ -2342,17 +2454,17 @@ fun contactUserPrefToFeatureAllowed(contactUserPreference: ContactUserPreference fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed): ChatPreferences = ChatPreferences( - timedMessages = contactFeatureAllowedToPref(contactFeaturesAllowed.timedMessages), + timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL), fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice) ) -fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): ChatPreference? = +fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? = when(contactFeatureAllowed) { is ContactFeatureAllowed.UserDefault -> null - is ContactFeatureAllowed.Always -> ChatPreference(allow = FeatureAllowed.ALWAYS) - is ContactFeatureAllowed.Yes -> ChatPreference(allow = FeatureAllowed.YES) - is ContactFeatureAllowed.No -> ChatPreference(allow = FeatureAllowed.NO) + is ContactFeatureAllowed.Always -> SimpleChatPreference(allow = FeatureAllowed.ALWAYS) + is ContactFeatureAllowed.Yes -> SimpleChatPreference(allow = FeatureAllowed.YES) + is ContactFeatureAllowed.No -> SimpleChatPreference(allow = FeatureAllowed.NO) } @Serializable @@ -2371,7 +2483,7 @@ enum class FeatureAllowed { @Serializable data class FullGroupPreferences( - val timedMessages: GroupPreference, + val timedMessages: TimedMessagesGroupPreference, val directMessages: GroupPreference, val fullDelete: GroupPreference, val voice: GroupPreference @@ -2381,7 +2493,7 @@ data class FullGroupPreferences( companion object { val sampleData = FullGroupPreferences( - timedMessages = GroupPreference(GroupFeatureEnabled.OFF), + timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON) @@ -2391,14 +2503,14 @@ data class FullGroupPreferences( @Serializable data class GroupPreferences( - val timedMessages: GroupPreference?, + val timedMessages: TimedMessagesGroupPreference?, val directMessages: GroupPreference?, val fullDelete: GroupPreference?, val voice: GroupPreference? ) { companion object { val sampleData = GroupPreferences( - timedMessages = GroupPreference(GroupFeatureEnabled.OFF), + timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), directMessages = GroupPreference(GroupFeatureEnabled.OFF), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), voice = GroupPreference(GroupFeatureEnabled.ON) @@ -2413,6 +2525,14 @@ data class GroupPreference( val on: Boolean get() = enable == GroupFeatureEnabled.ON } +@Serializable +data class TimedMessagesGroupPreference( + val enable: GroupFeatureEnabled, + val ttl: Int? = null +) { + val on: Boolean get() = enable == GroupFeatureEnabled.ON +} + @Serializable enum class GroupFeatureEnabled { @SerialName("on") ON, 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 5c2de2337b..a7744ea9f0 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 @@ -41,7 +41,6 @@ import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable @@ -517,7 +516,7 @@ fun ComposeView( fun allowVoiceToContact() { val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return - val prefs = contact.mergedPreferences.toPreferences().copy(voice = ChatPreference(allow = FeatureAllowed.YES)) + val prefs = contact.mergedPreferences.toPreferences().copy(voice = SimpleChatPreference(allow = FeatureAllowed.YES)) withApi { val toContact = chatModel.controller.apiSetContactPrefs(contact.contactId, prefs) if (toContact != null) { 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 3e628a71b5..773ecc0c1d 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 @@ -20,6 +20,8 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.PreferenceToggle +import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon @Composable fun ContactPreferencesView( @@ -85,6 +87,14 @@ private fun ContactPreferencesLayout( horizontalAlignment = Alignment.Start, ) { AppBarTitle(stringResource(R.string.contact_preferences)) + val timedMessages: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) } + val onTTLUpdated = { ttl: Int? -> + applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl ?: 86400)) + } + TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl -> + applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL)) + } + SectionSpacer() val allowFullDeletion: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) } FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) { applyPrefs(featuresAllowed.copy(fullDelete = it)) @@ -113,7 +123,7 @@ private fun FeatureSection( ) { val enabled = FeatureEnabled.enabled( feature.asymmetric, - user = ChatPreference(allow = allowFeature.value.allowed), + user = SimpleChatPreference(allow = allowFeature.value.allowed), contact = pref.contactPreference ) @@ -141,6 +151,50 @@ private fun FeatureSection( SectionTextFooter(feature.enabledDescription(enabled)) } +@Composable +private fun TimedMessagesFeatureSection( + featuresAllowed: ContactFeaturesAllowed, + pref: ContactUserPreferenceTimed, + allowFeature: State, + onTTLUpdated: (Int?) -> Unit, + onSelected: (Boolean, Int?) -> Unit +) { + val enabled = FeatureEnabled.enabled( + ChatFeature.TimedMessages.asymmetric, + user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO), + contact = pref.contactPreference + ) + + SectionView( + ChatFeature.TimedMessages.text.uppercase(), + icon = ChatFeature.TimedMessages.iconFilled, + iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, + leadingIcon = true, + ) { + SectionItemView { + PreferenceToggle( + generalGetString(R.string.chat_preferences_you_allow), + checked = allowFeature.value, + ) { allow -> + onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null) + } + } + SectionDivider() + InfoRow( + generalGetString(R.string.chat_preferences_contact_allows), + pref.contactPreference.allow.text + ) + SectionDivider() + if (featuresAllowed.timedMessagesAllowed) { + 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)) + } + } + SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled)) +} + @Composable private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { SectionView { @@ -154,6 +208,20 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool } } +@Composable +fun TimedMessagesTTLPicker(selection: MutableState, onSelected: (Int?) -> Unit) { + val ttlValues = TimedMessagesPreference.ttlValues + val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value) + SectionItemView { + ExposedDropDownSettingRow( + generalGetString(R.string.delete_after), + values.map { it to TimedMessagesPreference.ttlText(it) }, + selection, + onSelected = onSelected + ) + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(R.string.save_preferences_question), 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 a753df056e..48a5cd1117 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 @@ -18,7 +18,9 @@ import androidx.compose.ui.res.stringResource import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.TimedMessagesTTLPicker import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon @Composable fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { @@ -74,18 +76,30 @@ private fun GroupPreferencesLayout( horizontalAlignment = Alignment.Start, ) { AppBarTitle(stringResource(R.string.group_preferences)) + val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) } + val onTTLUpdated = { ttl: Int? -> + applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400))) + } + FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable -> + if (enable == GroupFeatureEnabled.ON) { + applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400))) + } else { + applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl))) + } + } + SectionSpacer() val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) } - FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo) { + FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it))) } SectionSpacer() val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) } - FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo) { + FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it))) } SectionSpacer() val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } - FeatureSection(GroupFeature.Voice, allowVoice, groupInfo) { + FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) } if (groupInfo.canEdit) { @@ -100,21 +114,34 @@ private fun GroupPreferencesLayout( } @Composable -private fun FeatureSection(feature: GroupFeature, enableFeature: State, groupInfo: GroupInfo, onSelected: (GroupFeatureEnabled) -> Unit) { +private fun FeatureSection( + feature: GroupFeature, + enableFeature: State, + groupInfo: GroupInfo, + preferences: FullGroupPreferences, + onTTLUpdated: (Int?) -> Unit, + onSelected: (GroupFeatureEnabled) -> Unit +) { SectionView { val on = enableFeature.value == GroupFeatureEnabled.ON val icon = if (on) feature.iconFilled else feature.icon val iconTint = if (on) SimplexGreen else HighOrLowlight + val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON if (groupInfo.canEdit) { SectionItemView { - ExposedDropDownSettingRow( + PreferenceToggleWithIcon( feature.text, - GroupFeatureEnabled.values().map { it to it.text }, - enableFeature, - icon = icon, - iconTint = iconTint, - onSelected = onSelected - ) + icon, + iconTint, + enableFeature.value == GroupFeatureEnabled.ON, + ) { checked -> + onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF) + } + } + if (timedOn) { + SectionDivider() + val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) } + TimedMessagesTTLPicker(ttl, onTTLUpdated) } } else { InfoRow( @@ -123,6 +150,10 @@ private fun FeatureSection(feature: GroupFeature, enableFeature: State { - Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) - } - is CIStatus.SndErrorAuth -> { - Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) - } - is CIStatus.SndError -> { - Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = WarningYellow) - } - is CIStatus.RcvNew -> { - Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary) - } - else -> {} - } +private fun StatusIconText(icon: ImageVector, color: Color) { + Icon(icon, null, Modifier.height(12.dp), tint = color) } @Preview @@ -67,7 +89,8 @@ fun PreviewCIMetaView() { CIMetaView( chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" - ) + ), + null ) } @@ -78,7 +101,8 @@ fun PreviewCIMetaViewUnread() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.RcvNew() - ) + ), + null ) } @@ -89,7 +113,8 @@ fun PreviewCIMetaViewSendFailed() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndError("CMD SYNTAX") - ) + ), + null ) } @@ -99,7 +124,8 @@ fun PreviewCIMetaViewSendNoAuth() { CIMetaView( chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth() - ) + ), + null ) } @@ -109,7 +135,8 @@ fun PreviewCIMetaViewSendSent() { CIMetaView( chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent() - ) + ), + null ) } @@ -120,7 +147,8 @@ fun PreviewCIMetaViewEdited() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = true - ) + ), + null ) } @@ -132,7 +160,8 @@ fun PreviewCIMetaViewEditedUnread() { 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = true, status=CIStatus.RcvNew() - ) + ), + null ) } @@ -144,7 +173,8 @@ fun PreviewCIMetaViewEditedSent() { 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = true, status=CIStatus.SndSent() - ) + ), + null ) } @@ -152,6 +182,7 @@ fun PreviewCIMetaViewEditedSent() { @Composable fun PreviewCIMetaViewDeletedContent() { CIMetaView( - chatItem = ChatItem.getDeletedContentSampleData() + chatItem = ChatItem.getDeletedContentSampleData(), + null ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index 5071b16dd4..a42acadea0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -34,6 +34,7 @@ fun CIVoiceView( sent: Boolean, hasText: Boolean, ci: ChatItem, + timedMessagesTTL: Int?, longClick: () -> Unit, ) { Row( @@ -63,7 +64,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick) + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick) val metaReserve = if (edited) @@ -86,6 +87,7 @@ private fun VoiceLayout( brokenAudio: Boolean, sent: Boolean, hasText: Boolean, + timedMessagesTTL: Int?, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit @@ -105,7 +107,7 @@ private fun VoiceLayout( Column { VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci) + CIMetaView(ci, timedMessagesTTL) } } } @@ -115,7 +117,7 @@ private fun VoiceLayout( Column { VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci) + CIMetaView(ci, timedMessagesTTL) } } Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 1d18c9e5c3..c6cad053f0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -174,14 +174,14 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted && !revealed.value) { - MarkedDeletedItemView(cItem, showMember = showMember) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) MarkedDeletedItemDropdownMenu() } else if (cItem.quotedItem == null && !cItem.meta.itemDeleted && !cItem.meta.isLive) { if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem) + EmojiItemView(cItem, cInfo.timedMessagesTTL) MsgContentItemDropdownMenu() } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, longClick = { onLinkLongClick("") }) + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }) MsgContentItemDropdownMenu() } else { framedItemView() @@ -194,7 +194,7 @@ fun ChatItemView( } @Composable fun DeletedItem() { - DeletedItemView(cItem, showMember = showMember) + DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) DropdownMenu( expanded = showMenu.value, onDismissRequest = { showMenu.value = false }, @@ -215,7 +215,7 @@ fun ChatItemView( is CIContent.RcvDeleted -> DeletedItem() is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) + is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.RcvGroupEventContent -> CIEventView(cItem) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt index 5ed2f39f2b..1128978583 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt @@ -18,7 +18,7 @@ import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme @Composable -fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) { +fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) { Surface( shape = RoundedCornerShape(18.dp), color = ReceivedColorLight, @@ -35,7 +35,7 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) { style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), modifier = Modifier.padding(end = 8.dp) ) - CIMetaView(ci) + CIMetaView(ci, timedMessagesTTL) } } } @@ -49,7 +49,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) { fun PreviewDeletedItemView() { SimpleXTheme { DeletedItemView( - ChatItem.getDeletedContentSampleData() + ChatItem.getDeletedContentSampleData(), + null ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt index 7ac5e0ca7e..86fffc6ae0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt @@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp) val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp) @Composable -fun EmojiItemView(chatItem: ChatItem) { +fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) { Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { EmojiText(chatItem.content.text) - CIMetaView(chatItem) + CIMetaView(chatItem, timedMessagesTTL) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 4f8233ddf5..1234799172 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -48,6 +48,7 @@ fun FramedItemView( scrollToItem: (Long) -> Unit = {}, ) { val sent = ci.chatDir.sent + val chatTTL = chatInfo.timedMessagesTTL fun membership(): GroupMember? { return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null @@ -144,7 +145,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, showMember, linkMode = linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler) } } @@ -188,33 +189,33 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, showMember, linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler) } } is MsgContent.MCVoice -> { - CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, longClick = { onLinkLongClick("") }) + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }) if (mc.text != "") { - CIMarkdownText(ci, showMember, linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick) + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview) - CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick) + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) } - else -> CIMarkdownText(ci, showMember, linkMode, uriHandler, onLinkLongClick) + else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) } } } } Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { - CIMetaView(ci, metaColor) + CIMetaView(ci, chatTTL, metaColor) } } } @@ -223,6 +224,7 @@ fun FramedItemView( @Composable fun CIMarkdownText( ci: ChatItem, + chatTTL: Int?, showMember: Boolean, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -232,7 +234,7 @@ fun CIMarkdownText( val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null, - meta = ci.meta, linkMode = linkMode, + meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt index ccafc6f2aa..976e34f6cc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt @@ -22,7 +22,7 @@ import chat.simplex.app.views.helpers.AlertManager import chat.simplex.app.views.helpers.generalGetString @Composable -fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) { +fun IntegrityErrorItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) { Surface( Modifier.clickable(onClick = { AlertManager.shared.showAlertMsg( @@ -45,7 +45,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) { style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), modifier = Modifier.padding(end = 8.dp) ) - CIMetaView(ci) + CIMetaView(ci, timedMessagesTTL) } } } @@ -59,7 +59,8 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) { fun IntegrityErrorItemViewPreview() { SimpleXTheme { IntegrityErrorItemView( - ChatItem.getDeletedContentSampleData() + ChatItem.getDeletedContentSampleData(), + null ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt index 677c269e35..8692c1b1de 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt @@ -20,7 +20,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.generalGetString @Composable -fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) { Surface( shape = RoundedCornerShape(18.dp), color = if (ci.chatDir.sent) SentColorLight else ReceivedColorLight, @@ -37,7 +37,7 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) { style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), modifier = Modifier.padding(end = 8.dp) ) - CIMetaView(ci) + CIMetaView(ci, timedMessagesTTL) } } } @@ -51,7 +51,8 @@ fun MarkedDeletedItemView(ci: ChatItem, showMember: Boolean = false) { fun PreviewMarkedDeletedItemView() { SimpleXTheme { DeletedItemView( - ChatItem.getSampleData(itemDeleted = true) + ChatItem.getSampleData(itemDeleted = true), + null ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index f8c9947d5c..f9007acdf5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -70,6 +70,7 @@ fun MarkdownText ( formattedText: List? = null, sender: String? = null, meta: CIMeta? = null, + chatTTL: Int? = null, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, @@ -82,10 +83,12 @@ fun MarkdownText ( val textLayoutDirection = remember (text) { if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = when { - textLayoutDirection != LocalLayoutDirection.current && meta != null -> "\n" - meta?.itemEdited == true -> " " - else -> " " + val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + "\n" + } else if (meta != null) { + reserveSpaceForMeta(meta, chatTTL) + } else { + " " } val scope = rememberCoroutineScope() CompositionLocalProvider( @@ -133,7 +136,7 @@ fun MarkdownText ( if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } - if (meta != null) withStyle(reserveTimestampStyle) { append(reserve + meta.timestampText) } + if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) } else { @@ -165,7 +168,7 @@ fun MarkdownText ( // With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good /*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl) withStyle(reserveTimestampStyle) { append("\n" + metaText) } - else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve + meta.timestampText) } + else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } if (hasLinks && uriHandler != null) { ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt index 9ac0b43ea3..b1de4d8d5f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -69,14 +69,19 @@ private fun PreferencesLayout( horizontalAlignment = Alignment.Start, ) { AppBarTitle(stringResource(R.string.your_preferences)) + val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.allow) } + TimedMessagesFeatureSection(timedMessages) { + applyPrefs(preferences.copy(timedMessages = TimedMessagesPreference(allow = if (it) FeatureAllowed.YES else FeatureAllowed.NO))) + } + SectionSpacer() val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) } FeatureSection(ChatFeature.FullDelete, allowFullDeletion) { - applyPrefs(preferences.copy(fullDelete = ChatPreference(allow = it))) + applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it))) } SectionSpacer() val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) } FeatureSection(ChatFeature.Voice, allowVoice) { - applyPrefs(preferences.copy(voice = ChatPreference(allow = it))) + applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it))) } SectionSpacer() ResetSaveButtons( @@ -103,6 +108,22 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State, onSelected: (Boolean) -> Unit) { + SectionView { + SectionItemView { + PreferenceToggleWithIcon( + ChatFeature.TimedMessages.text, + ChatFeature.TimedMessages.icon, + HighOrLowlight, + allowFeature.value == FeatureAllowed.ALWAYS || allowFeature.value == FeatureAllowed.YES, + onSelected + ) + } + } + SectionTextFooter(ChatFeature.TimedMessages.allowDescription(allowFeature.value)) +} + @Composable private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { SectionView { diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e96855a759..273e0ab761 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1038,5 +1038,21 @@ Irreversible message deletion is prohibited in this group. Group members can send voice messages. Voice messages are prohibited in this group. - + Delete after + %d sec + %ds + %d min + %d month + %d months + %dm + %dmth + %d hour + %d hours + %dh + %d day + %d days + %dd + %d week + %d weeks + %dw