Merge branch 'master' into master-ios

This commit is contained in:
Evgeny Poberezkin
2023-05-18 17:13:13 +01:00
38 changed files with 1112 additions and 371 deletions
+12 -1
View File
@@ -4,7 +4,6 @@ This readme is currently a stub and as such is in development.
Ultimately, this readme will act as a guide to contributing to the develop of the SimpleX android app.
## Gotchas
#### SHA Signature for verification for app links/deep links
@@ -23,3 +22,15 @@ To find your SHA certificate fingerprint perform the following steps.
More information is available [here](https://developer.android.com/training/app-links/verify-site-associations#manual-verification). If there is no response when running the `pm get-app-links` command, the intents in `AndroidManifest.xml` are likely misspecified. A verification attempt can be triggered using `adb shell pm verify-app-links --re-verify chat.simplex.app`.
Note that this is not an issue for the app store build of the app as this is signed with our app store credentials and thus there is a stable signature over users. Developers do not have general access to these credentials for development and testing.
## Adding icons
1. Find a [Material symbol](https://fonts.google.com/icons?icon.style=Rounded) in Rounded category.
2. Set weight to 400, grade to -25 and size to 48px.
3. Click on the icon, choose Android and download XML file.
4. Update the color to black (#FF000000) and the size to "24.dp", as in other icons.
For example, this is [add reaction icon](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:add_reaction:FILL@0;wght@300;GRAD@-25;opsz@24&icon.style=Rounded).
+3
View File
@@ -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'
@@ -238,6 +238,17 @@ class ChatModel(val controller: ChatController) {
}
}
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (chatId.value == cInfo.id) {
withContext(Dispatchers.Main) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
}
}
}
}
fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(cInfo.id)
@@ -729,6 +740,7 @@ data class Contact(
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser
ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser
ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser
ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser
}
@@ -750,12 +762,14 @@ data class Contact(
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO
}
fun userAllowsFeature(feature: ChatFeature): Boolean = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO
ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO
}
@@ -888,6 +902,7 @@ data class GroupInfo (
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on
ChatFeature.Reactions -> fullGroupPreferences.reactions.on
ChatFeature.Voice -> fullGroupPreferences.voice.on
ChatFeature.Calls -> false
}
@@ -1257,6 +1272,20 @@ class AChatItem (
val chatItem: ChatItem
)
@Serializable
class ACIReaction(
val chatInfo: ChatInfo,
val chatReaction: CIReaction
)
@Serializable
class CIReaction(
val chatDir: CIDirection,
val chatItem: ChatItem,
val sentAt: Instant,
val reaction: MsgReaction
)
@Serializable @Stable
data class ChatItem (
val chatDir: CIDirection,
@@ -1264,6 +1293,7 @@ data class ChatItem (
val content: CIContent,
val formattedText: List<FormattedText>? = null,
val quotedItem: CIQuote? = null,
val reactions: List<CIReactionCount>,
val file: CIFile? = null
) {
val id: Long get() = meta.itemId
@@ -1280,6 +1310,11 @@ data class ChatItem (
val isRcvNew: Boolean get() = meta.isRcvNew
val allowAddReaction: Boolean get() =
meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3)
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
else null
@@ -1369,6 +1404,7 @@ data class ChatItem (
meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem,
reactions = listOf(),
file = file
)
@@ -1384,6 +1420,7 @@ data class ChatItem (
meta = CIMeta.getSample(id, Clock.System.now(), text, CIStatus.RcvRead()),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile(text)),
quotedItem = null,
reactions = listOf(),
file = CIFile.getSample(fileName = fileName, fileSize = fileSize, fileStatus = fileStatus)
)
@@ -1399,6 +1436,7 @@ data class ChatItem (
meta = CIMeta.getSample(id, ts, text, status),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1408,6 +1446,7 @@ data class ChatItem (
meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead()),
content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1417,6 +1456,7 @@ data class ChatItem (
meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead()),
content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1427,6 +1467,7 @@ data class ChatItem (
meta = CIMeta.getSample(1, Clock.System.now(), content.text, CIStatus.RcvRead()),
content = content,
quotedItem = null,
reactions = listOf(),
file = null
)
}
@@ -1452,6 +1493,7 @@ data class ChatItem (
),
content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1472,6 +1514,7 @@ data class ChatItem (
),
content = CIContent.SndMsgContent(MsgContent.MCText("")),
quotedItem = null,
reactions = listOf(),
file = null
)
@@ -1481,6 +1524,7 @@ data class ChatItem (
meta = meta ?: CIMeta.invalidJSON(),
content = CIContent.InvalidJSON(json),
quotedItem = null,
reactions = listOf(),
file = null
)
}
@@ -1671,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))
}
}
}
@@ -1729,6 +1773,93 @@ class CIQuote (
}
}
@Serializable
class CIReactionCount(val reaction: MsgReaction, val userReacted: Boolean, val totalReacted: Int)
@Serializable(with = MsgReactionSerializer::class)
sealed class MsgReaction {
@Serializable(with = MsgReactionSerializer::class) class Emoji(val emoji: MREmojiChar): MsgReaction()
@Serializable(with = MsgReactionSerializer::class) class Unknown(val type: String? = null, val json: JsonElement): MsgReaction()
val text: String get() = when (this) {
is Emoji -> when (emoji) {
MREmojiChar.Heart -> "❤️"
else -> emoji.value
}
is Unknown -> ""
}
val cmdString: String get() = when(this) {
is Emoji -> emoji.cmdString
is Unknown -> ""
}
companion object {
val values: List<MsgReaction> get() = MREmojiChar.values().map(::Emoji)
}
}
object MsgReactionSerializer : KSerializer<MsgReaction> {
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgReaction", PolymorphicKind.SEALED) {
element("Emoji", buildClassSerialDescriptor("Emoji") {
element<String>("emoji")
})
element("Unknown", buildClassSerialDescriptor("Unknown"))
}
override fun deserialize(decoder: Decoder): MsgReaction {
require(decoder is JsonDecoder)
val json = decoder.decodeJsonElement()
return if (json is JsonObject && "type" in json) {
when(val t = json["type"]?.jsonPrimitive?.content ?: "") {
"emoji" -> {
val emoji = Json.decodeFromString<MREmojiChar>(json["emoji"].toString())
if (emoji == null) MsgReaction.Unknown(t, json) else MsgReaction.Emoji(emoji)
}
else -> MsgReaction.Unknown(t, json)
}
} else {
MsgReaction.Unknown("", json)
}
}
override fun serialize(encoder: Encoder, value: MsgReaction) {
require(encoder is JsonEncoder)
val json = when (value) {
is MsgReaction.Emoji ->
buildJsonObject {
put("type", "emoji")
put("emoji", json.encodeToJsonElement(value.emoji))
}
is MsgReaction.Unknown -> value.json
}
encoder.encodeJsonElement(json)
}
}
@Serializable
enum class MREmojiChar(val value: String) {
@SerialName("👍") ThumbsUp("👍"),
@SerialName("👎") ThumbsDown("👎"),
@SerialName("😀") Smile("😀"),
@SerialName("🎉") Celebration("🎉"),
@SerialName("😕") Confused("😕"),
@SerialName("") Heart(""),
@SerialName("🚀") Launch("🚀"),
@SerialName("👀") Looking("👀");
val cmdString: String get() = when(this) {
ThumbsUp -> "+"
ThumbsDown -> "-"
Smile -> ")"
Celebration -> "!"
Confused -> "?"
Heart -> "*"
Launch -> "^"
Looking -> "%"
}
}
@Serializable
class CIFile(
val fileId: Long,
@@ -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
@@ -600,6 +602,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
return null
}
suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? {
val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction))
if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem
Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? {
val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode))
if (r is CR.ChatItemDeleted) return r
@@ -1378,6 +1387,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.user, r.chatItem)
is CR.ChatItemReaction -> {
if (active(r.user)) {
chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
is CR.ChatItemDeleted -> {
if (!active(r.user)) {
if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) {
@@ -1881,10 +1895,11 @@ 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()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
@@ -1969,10 +1984,14 @@ 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"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
@@ -2059,6 +2078,7 @@ sealed class CC {
is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction"
is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
@@ -2473,15 +2493,23 @@ data class ChatSettings(
data class FullChatPreferences(
val timedMessages: TimedMessagesPreference,
val fullDelete: SimpleChatPreference,
val reactions: SimpleChatPreference,
val voice: SimpleChatPreference,
val calls: SimpleChatPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(timedMessages = timedMessages, fullDelete = fullDelete, voice = voice, calls = calls)
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages,
fullDelete = fullDelete,
reactions = reactions,
voice = voice,
calls = calls
)
companion object {
val sampleData = FullChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
)
@@ -2492,6 +2520,7 @@ data class FullChatPreferences(
data class ChatPreferences(
val timedMessages: TimedMessagesPreference?,
val fullDelete: SimpleChatPreference?,
val reactions: SimpleChatPreference?,
val voice: SimpleChatPreference?,
val calls: SimpleChatPreference?,
) {
@@ -2499,6 +2528,7 @@ data class ChatPreferences(
when (feature) {
ChatFeature.TimedMessages -> this.copy(timedMessages = TimedMessagesPreference(allow = allowed, ttl = param ?: this.timedMessages?.ttl))
ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed))
ChatFeature.Reactions -> this.copy(reactions = SimpleChatPreference(allow = allowed))
ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed))
ChatFeature.Calls -> this.copy(calls = SimpleChatPreference(allow = allowed))
}
@@ -2507,6 +2537,7 @@ data class ChatPreferences(
val sampleData = ChatPreferences(
timedMessages = TimedMessagesPreference(allow = FeatureAllowed.NO),
fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO),
reactions = SimpleChatPreference(allow = FeatureAllowed.YES),
voice = SimpleChatPreference(allow = FeatureAllowed.YES),
calls = SimpleChatPreference(allow = FeatureAllowed.YES),
)
@@ -2529,62 +2560,115 @@ data class TimedMessagesPreference(
): ChatPreference {
companion object {
val ttlValues: List<Int?>
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<CustomTimeUnit, Int> {
val tryUnits = listOf(Month, Week, Day, Hour, Minute)
var selectedUnit: Pair<CustomTimeUnit, Int>? = 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<Int, Int> =
private fun divMod(n: Int, d: Int): Pair<Int, Int> =
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,
val fullDelete: ContactUserPreference,
val reactions: ContactUserPreference,
val voice: ContactUserPreference,
val calls: ContactUserPreference,
) {
fun toPreferences(): ChatPreferences = ChatPreferences(
timedMessages = timedMessages.userPreference.pref,
fullDelete = fullDelete.userPreference.pref,
reactions = reactions.userPreference.pref,
voice = voice.userPreference.pref,
calls = calls.userPreference.pref
)
@@ -2601,6 +2685,11 @@ data class ContactUserPreferences(
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.NO)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.NO)
),
reactions = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES)
),
voice = ContactUserPreference(
enabled = FeatureEnabled(forUser = true, forContact = true),
userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)),
@@ -2697,6 +2786,7 @@ interface Feature {
enum class ChatFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("reactions") Reactions,
@SerialName("voice") Voice,
@SerialName("calls") Calls;
@@ -2714,6 +2804,7 @@ enum class ChatFeature: Feature {
get() = when(this) {
TimedMessages -> generalGetString(R.string.timed_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Reactions -> generalGetString(R.string.message_reactions)
Voice -> generalGetString(R.string.voice_messages)
Calls -> generalGetString(R.string.audio_video_calls)
}
@@ -2722,6 +2813,7 @@ enum class ChatFeature: Feature {
@Composable get() = when(this) {
TimedMessages -> painterResource(R.drawable.ic_timer)
FullDelete -> painterResource(R.drawable.ic_delete_forever)
Reactions -> painterResource(R.drawable.ic_add_reaction)
Voice -> painterResource(R.drawable.ic_keyboard_voice)
Calls -> painterResource(R.drawable.ic_call)
}
@@ -2730,6 +2822,7 @@ enum class ChatFeature: Feature {
override fun iconFilled(): Painter = when(this) {
TimedMessages -> painterResource(R.drawable.ic_timer_filled)
FullDelete -> painterResource(R.drawable.ic_delete_forever_filled)
Reactions -> painterResource(R.drawable.ic_add_reaction_filled)
Voice -> painterResource(R.drawable.ic_keyboard_voice_filled)
Calls -> painterResource(R.drawable.ic_call_filled)
}
@@ -2746,7 +2839,12 @@ enum class ChatFeature: Feature {
FeatureAllowed.YES -> generalGetString(R.string.allow_irreversible_message_deletion_only_if)
FeatureAllowed.NO -> generalGetString(R.string.contacts_can_mark_messages_for_deletion)
}
Voice -> when (allowed) {
Reactions -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_adding_message_reactions)
FeatureAllowed.YES -> generalGetString(R.string.allow_message_reactions_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_message_reactions)
}
Voice -> when (allowed) {
FeatureAllowed.ALWAYS -> generalGetString(R.string.allow_your_contacts_to_send_voice_messages)
FeatureAllowed.YES -> generalGetString(R.string.allow_voice_messages_only_if)
FeatureAllowed.NO -> generalGetString(R.string.prohibit_sending_voice_messages)
@@ -2772,6 +2870,12 @@ enum class ChatFeature: Feature {
enabled.forContact -> generalGetString(R.string.only_your_contact_can_delete)
else -> generalGetString(R.string.message_deletion_prohibited)
}
Reactions -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_add_message_reactions)
enabled.forUser -> generalGetString(R.string.only_you_can_add_message_reactions)
enabled.forContact -> generalGetString(R.string.only_your_contact_can_add_message_reactions)
else -> generalGetString(R.string.message_reactions_prohibited_in_this_chat)
}
Voice -> when {
enabled.forUser && enabled.forContact -> generalGetString(R.string.both_you_and_your_contact_can_send_voice)
enabled.forUser -> generalGetString(R.string.only_you_can_send_voice)
@@ -2792,6 +2896,7 @@ enum class GroupFeature: Feature {
@SerialName("timedMessages") TimedMessages,
@SerialName("directMessages") DirectMessages,
@SerialName("fullDelete") FullDelete,
@SerialName("reactions") Reactions,
@SerialName("voice") Voice;
override val hasParam: Boolean get() = when(this) {
@@ -2804,6 +2909,7 @@ enum class GroupFeature: Feature {
TimedMessages -> generalGetString(R.string.timed_messages)
DirectMessages -> generalGetString(R.string.direct_messages)
FullDelete -> generalGetString(R.string.full_deletion)
Reactions -> generalGetString(R.string.message_reactions)
Voice -> generalGetString(R.string.voice_messages)
}
@@ -2812,6 +2918,7 @@ enum class GroupFeature: Feature {
TimedMessages -> painterResource(R.drawable.ic_timer)
DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle)
FullDelete -> painterResource(R.drawable.ic_delete_forever)
Reactions -> painterResource(R.drawable.ic_add_reaction)
Voice -> painterResource(R.drawable.ic_keyboard_voice)
}
@@ -2820,6 +2927,7 @@ enum class GroupFeature: Feature {
TimedMessages -> painterResource(R.drawable.ic_timer_filled)
DirectMessages -> painterResource(R.drawable.ic_swap_horizontal_circle_filled)
FullDelete -> painterResource(R.drawable.ic_delete_forever_filled)
Reactions -> painterResource(R.drawable.ic_add_reaction_filled)
Voice -> painterResource(R.drawable.ic_keyboard_voice_filled)
}
@@ -2838,6 +2946,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_delete_messages)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_deletion)
}
Reactions -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_message_reactions)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_message_reactions_group)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.allow_to_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.prohibit_sending_voice)
@@ -2857,6 +2969,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_delete)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_deletion_prohibited_in_chat)
}
Reactions -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_add_message_reactions)
GroupFeatureEnabled.OFF -> generalGetString(R.string.message_reactions_are_prohibited)
}
Voice -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(R.string.group_members_can_send_voice)
GroupFeatureEnabled.OFF -> generalGetString(R.string.voice_messages_are_prohibited)
@@ -2897,6 +3013,7 @@ data class ContactFeaturesAllowed(
val timedMessagesAllowed: Boolean,
val timedMessagesTTL: Int?,
val fullDelete: ContactFeatureAllowed,
val reactions: ContactFeatureAllowed,
val voice: ContactFeatureAllowed,
val calls: ContactFeatureAllowed,
) {
@@ -2905,6 +3022,7 @@ data class ContactFeaturesAllowed(
timedMessagesAllowed = false,
timedMessagesTTL = null,
fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO),
reactions = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
calls = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES),
)
@@ -2918,6 +3036,7 @@ fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPrefere
timedMessagesAllowed = allow == FeatureAllowed.YES || allow == FeatureAllowed.ALWAYS,
timedMessagesTTL = pref.pref.ttl,
fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete),
reactions = contactUserPrefToFeatureAllowed(contactUserPreferences.reactions),
voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice),
calls = contactUserPrefToFeatureAllowed(contactUserPreferences.calls),
)
@@ -2937,6 +3056,7 @@ fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed
ChatPreferences(
timedMessages = TimedMessagesPreference(if (contactFeaturesAllowed.timedMessagesAllowed) FeatureAllowed.YES else FeatureAllowed.NO, contactFeaturesAllowed.timedMessagesTTL),
fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete),
reactions = contactFeatureAllowedToPref(contactFeaturesAllowed.reactions),
voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice),
calls = contactFeatureAllowedToPref(contactFeaturesAllowed.calls),
)
@@ -2968,16 +3088,24 @@ data class FullGroupPreferences(
val timedMessages: TimedMessagesGroupPreference,
val directMessages: GroupPreference,
val fullDelete: GroupPreference,
val reactions: GroupPreference,
val voice: GroupPreference
) {
fun toGroupPreferences(): GroupPreferences =
GroupPreferences(timedMessages = timedMessages, directMessages = directMessages, fullDelete = fullDelete, voice = voice)
GroupPreferences(
timedMessages = timedMessages,
directMessages = directMessages,
fullDelete = fullDelete,
reactions = reactions,
voice = voice
)
companion object {
val sampleData = FullGroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
@@ -2988,6 +3116,7 @@ data class GroupPreferences(
val timedMessages: TimedMessagesGroupPreference?,
val directMessages: GroupPreference?,
val fullDelete: GroupPreference?,
val reactions: GroupPreference?,
val voice: GroupPreference?
) {
companion object {
@@ -2995,6 +3124,7 @@ data class GroupPreferences(
timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF),
directMessages = GroupPreference(GroupFeatureEnabled.OFF),
fullDelete = GroupPreference(GroupFeatureEnabled.OFF),
reactions = GroupPreference(GroupFeatureEnabled.ON),
voice = GroupPreference(GroupFeatureEnabled.ON)
)
}
@@ -3166,6 +3296,7 @@ sealed class CR {
@Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: User, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
@Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List<Contact>): CR()
// group events
@@ -3277,6 +3408,7 @@ sealed class CR {
is NewChatItem -> "newChatItem"
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated"
is ChatItemReaction -> "chatItemReaction"
is ChatItemDeleted -> "chatItemDeleted"
is ContactsList -> "contactsList"
is GroupCreated -> "groupCreated"
@@ -3386,6 +3518,7 @@ sealed class CR {
is NewChatItem -> withUser(user, json.encodeToString(chatItem))
is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser")
is ContactsList -> withUser(user, json.encodeToString(contacts))
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
@@ -86,7 +86,7 @@ fun TerminalLayout(
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
sendMessage = sendCommand,
sendMessage = { sendCommand() },
sendLiveMessage = null,
updateLiveMessage = null,
onMessageChange = ::onMessageChange,
@@ -258,6 +258,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.controller.allowFeatureToContact(contact, feature, param)
}
},
setReaction = { cInfo, cItem, add, reaction ->
withApi {
val updatedCI = chatModel.controller.apiChatItemReaction(
type = cInfo.chatType,
id = cInfo.apiId,
itemId = cItem.id,
add = add,
reaction = reaction
)
if (updatedCI != null) {
chatModel.updateChatItem(cInfo, updatedCI)
}
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withApi {
@@ -316,6 +330,7 @@ fun ChatLayout(
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
addMembers: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
@@ -358,7 +373,7 @@ fun ChatLayout(
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, setReaction, markRead, setFloatingButton, onComposed,
)
}
}
@@ -531,6 +546,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
@@ -644,11 +660,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction)
}
} else {
Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction)
}
}
} else { // direct message
@@ -659,7 +675,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction)
}
}
@@ -1082,6 +1098,7 @@ fun PreviewChatLayout() {
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -1142,6 +1159,7 @@ fun PreviewGroupChatLayout() {
startCall = {},
acceptCall = { _ -> },
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
addMembers = { _ -> },
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
@@ -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,
@@ -40,10 +40,10 @@ fun ComposeVoiceView(
val audioPlaying = rememberSaveable { mutableStateOf(false) }
Row(
Modifier
.height(60.dp)
.height(57.dp)
.fillMaxWidth()
.background(sentColor)
.padding(top = 8.dp),
.padding(top = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
@@ -95,6 +95,11 @@ private fun ContactPreferencesLayout(
applyPrefs(featuresAllowed.copy(fullDelete = it))
}
SectionDividerSpaced(true, maxBottomPadding = false)
// val allowReactions: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) }
// FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) {
// applyPrefs(featuresAllowed.copy(reactions = it))
// }
// SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
applyPrefs(featuresAllowed.copy(voice = it))
@@ -183,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))
@@ -207,7 +212,7 @@ fun TimedMessagesTTLPicker(selection: MutableState<Int?>, 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
)
@@ -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<Int>? = null,
sendMessage: (Int?) -> Unit,
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
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<Int>?
) {
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<ComposeState>,
@@ -455,14 +577,14 @@ private fun SendMsgButton(
sizeDp: Animatable<Float, AnimationVector1D>,
alpha: Animatable<Float, AnimationVector1D>,
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
@@ -95,6 +95,11 @@ private fun GroupPreferencesLayout(
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
// val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) }
// FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) {
// applyPrefs(preferences.copy(reactions = GroupPreference(enable = it)))
// }
// SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
@@ -146,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))
}
}
}
@@ -211,6 +211,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
reactions = listOf(),
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
)
private val fileChatItemWtFile = ChatItem(
@@ -218,6 +219,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
quotedItem = null,
reactions = listOf(),
file = null
)
override val values = listOf(
@@ -24,7 +24,7 @@ fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = Ma
Text(
chatItem.timestampText,
color = metaColor,
fontSize = 14.sp,
fontSize = 12.sp,
modifier = Modifier.padding(start = 3.dp)
)
} else {
@@ -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) {
@@ -150,32 +150,32 @@ private fun VoiceLayout(
}
}
sent -> {
Row {
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) {
Spacer(Modifier.height(56.dp))
Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
Column(horizontalAlignment = Alignment.End) {
Row {
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) {
Spacer(Modifier.height(56.dp))
Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp))
DurationText(text, PaddingValues(end = 12.dp))
}
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
}
Box(Modifier.padding(top = 6.dp, end = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
}
else -> {
Row {
Column {
Column(horizontalAlignment = Alignment.Start) {
Row {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DurationText(text, PaddingValues(start = 12.dp))
Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp))
Spacer(Modifier.height(56.dp))
}
}
Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DurationText(text, PaddingValues(start = 12.dp))
Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp))
Spacer(Modifier.height(56.dp))
Box(Modifier.padding(top = 6.dp)) {
CIMetaView(ci, timedMessagesTTL)
}
}
}
@@ -2,7 +2,8 @@ package chat.simplex.app.views.chat.item
import android.Manifest
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -16,8 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
@@ -45,7 +48,8 @@ fun ChatItemView(
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
@@ -75,186 +79,246 @@ fun ChatItemView(
else -> {}
}
}
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
fun moderateMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.moderate_message_will_be_deleted_warning)
} else {
generalGetString(R.string.moderate_message_will_be_marked_warning)
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
@Composable
fun ChatItemReactions() {
Row {
cItem.reactions.forEach { r ->
var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp))
if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) {
modifier = modifier.clickable {
setReaction(cInfo, cItem, !r.userReacted, r.reaction)
}
showMenu.value = false
})
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
copyText(context, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
saveImage(context, cItem.file)
} else {
writePermissionState.launchPermissionRequest()
}
}
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
Row(modifier.padding(2.dp)) {
Text(r.reaction.text, fontSize = 12.sp)
if (r.totalReacted > 1) {
Spacer(Modifier.width(4.dp))
Text("${r.totalReacted}",
fontSize = 11.5.sp,
fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal,
color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
)
}
}
}
}
}
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
}
fun deleteMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.delete_message_cannot_be_undone_warning)
} else {
generalGetString(R.string.delete_message_mark_deleted_warning)
}
}
fun moderateMessageQuestionText(): String {
return if (fullDeleteAllowed) {
generalGetString(R.string.moderate_message_will_be_deleted_warning)
} else {
generalGetString(R.string.moderate_message_will_be_marked_warning)
}
}
@Composable
fun MsgReactionsMenu() {
val rs = MsgReaction.values.mapNotNull { r ->
if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) {
r
} else {
null
}
}
if (rs.isNotEmpty()) {
Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) {
rs.forEach() { r ->
Box(
Modifier.size(36.dp).clickable {
setReaction(cInfo, cItem, true, r)
showMenu.value = false
},
contentAlignment = Alignment.Center
) {
Text(r.text)
}
}
}
}
}
@Composable
fun MsgContentItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
when {
filePath != null -> shareFile(context, cItem.text, filePath)
else -> shareText(context, cItem.content.text)
}
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
painterResource(R.drawable.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
copyText(context, cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
saveImage(context, cItem.file)
} else {
writePermissionState.launchPermissionRequest()
}
}
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
showMenu.value = false
})
}
)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(R.string.hide_verb),
painterResource(R.drawable.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
}
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(R.string.reveal_verb),
painterResource(R.drawable.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
}
}
}
@Composable
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(R.string.reveal_verb),
painterResource(R.drawable.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
}
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
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, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile)
MsgContentItemDropdownMenu()
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
MarkedDeletedItemDropdownMenu()
} else {
framedItemView()
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem, cInfo.timedMessagesTTL)
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile)
} else {
framedItemView()
}
} else {
framedItemView()
}
MsgContentItemDropdownMenu()
}
} else {
framedItemView()
MsgContentItemDropdownMenu()
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, 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)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
}
}
@Composable fun DeletedItem() {
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
}
@Composable fun CallItem(status: CICallStatus, duration: Int) {
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
when (val c = cItem.content) {
is CIContent.SndMsgContent -> ContentItem()
is CIContent.RcvMsgContent -> ContentItem()
is CIContent.SndDeleted -> DeletedItem()
is CIContent.RcvDeleted -> DeletedItem()
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, 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)
is CIContent.SndGroupEventContent -> CIEventView(cItem)
is CIContent.RcvConnEventContent -> CIEventView(cItem)
is CIContent.SndConnEventContent -> CIEventView(cItem)
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
if (cItem.content.msgContent != null && cItem.meta.itemDeleted == null && cItem.reactions.isNotEmpty()) {
ChatItemReactions()
}
}
}
@@ -430,7 +494,8 @@ fun PreviewChatItemView() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
)
}
}
@@ -451,7 +516,8 @@ fun PreviewChatItemViewDeletedContent() {
joinGroup = {},
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> }
acceptFeature = { _, _, _ -> },
setReaction = { _, _, _, _ -> },
)
}
}
@@ -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<Int>,
timeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits
) {
fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List<Int> {
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<CustomTimeUnit> = 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<TimeUnitLimits>
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<Int>,
timeUnitsLimits: List<TimeUnitLimits> = 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
)
}
}
}
}
}
}
@@ -202,9 +202,9 @@ fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boole
Divider(
Modifier.padding(
start = DEFAULT_PADDING_HALF,
top = if (maxTopPadding) 40.dp else 30.dp,
top = if (maxTopPadding) 37.dp else 27.dp,
end = DEFAULT_PADDING_HALF,
bottom = if (maxBottomPadding) 40.dp else 30.dp)
bottom = if (maxBottomPadding) 37.dp else 27.dp)
)
}
@@ -72,6 +72,11 @@ private fun PreferencesLayout(
applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it)))
}
SectionDividerSpaced(true, maxBottomPadding = false)
// val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.allow) }
// FeatureSection(ChatFeature.Reactions, allowReactions) {
// applyPrefs(preferences.copy(reactions = SimpleChatPreference(allow = it)))
// }
// SectionDividerSpaced(true, maxBottomPadding = false)
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) }
FeatureSection(ChatFeature.Voice, allowVoice) {
applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it)))
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,160Q648,167.9 648.75,174.95Q649.5,182 652,189Q614,166.5 570.93,154.5Q527.86,142.5 479.9,142.5Q339.69,142.5 241.09,240.75Q142.5,339 142.5,479.51Q142.5,620.03 241.24,718.76Q339.97,817.5 480.49,817.5Q621,817.5 719.25,718.91Q817.5,620.31 817.5,480Q817.5,441.47 809.25,404.99Q801,368.5 786,336Q796.96,343.76 810.93,347.88Q824.9,352 840,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM811.5,188.5L760,188.5Q747.75,188.5 739.63,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,252.25 860.58,260.38Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.dp"
android:height="24.dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M811.5,188.5L760,188.5Q748.5,188.5 740,180.33Q731.5,172.15 731.5,159.82Q731.5,147.5 739.63,139.25Q747.75,131 760,131L811.5,131L811.5,80Q811.5,67.75 819.67,59.38Q827.85,51 840.17,51Q852.5,51 860.75,59.38Q869,67.75 869,80L869,131L920,131Q932.25,131 940.63,139.43Q949,147.85 949,160.17Q949,172.5 940.63,180.5Q932.25,188.5 920,188.5L869,188.5L869,240Q869,251.5 860.58,260Q852.15,268.5 839.83,268.5Q827.5,268.5 819.5,260.38Q811.5,252.25 811.5,240L811.5,188.5ZM480.33,875Q398.63,875 326.7,843.74Q254.78,812.47 201.14,758.74Q147.5,705 116.25,633.21Q85,561.42 85,479.8Q85,398.09 116.36,325.93Q147.73,253.78 201.13,200.39Q254.54,147 326.79,116Q399.04,85 479.92,85Q526.76,85 570.69,95.51Q614.62,106.03 654,125Q650.5,133.5 649.25,142.08Q648,150.67 648,159.57Q648,198 671.44,228.46Q694.88,258.93 732.5,267.5Q741.57,305.17 772.03,328.58Q802.48,352 840.28,352Q843.36,352 846.75,352Q850.14,352 853.5,351.5Q864,382 869.5,413.93Q875,445.87 875,479.82Q875,560.91 843.99,633.3Q812.97,705.68 759.49,758.99Q706,812.3 633.98,843.65Q561.95,875 480.33,875ZM624.45,426.5Q647.4,426.5 662.45,411.5Q677.5,396.49 677.5,373.55Q677.5,350.6 662.5,335.55Q647.49,320.5 624.55,320.5Q601.6,320.5 586.55,335.5Q571.5,350.51 571.5,373.45Q571.5,396.4 586.5,411.45Q601.51,426.5 624.45,426.5ZM335.45,426.5Q358.4,426.5 373.45,411.5Q388.5,396.49 388.5,373.55Q388.5,350.6 373.5,335.55Q358.49,320.5 335.55,320.5Q312.6,320.5 297.55,335.5Q282.5,350.51 282.5,373.45Q282.5,396.4 297.5,411.45Q312.51,426.5 335.45,426.5ZM480,696Q544.5,696 599.25,661.5Q654,627 679,566.5L281,566.5Q307,627 361.25,661.5Q415.5,696 480,696Z"/>
</vector>
@@ -5,5 +5,5 @@
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="m480,520.5 l-209,209q-9,9 -20.25,9t-20.25,-9q-9,-9 -9,-20.25t9,-20.25L440,479.5 230.5,270q-9,-8.5 -9,-19.75t9,-20.25q9,-9 20.25,-9t20.25,9l209,209.5 209,-209q9,-9 20.25,-9t20.25,9q9,9 9,20.25t-9,20.25l-209,209L730,689.5q8.5,9 8.5,20.25T730,729.5q-9,9 -20.25,9t-20.25,-9L480,520.5Z"/>
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"/>
</vector>
@@ -338,6 +338,13 @@
<string name="ask_your_contact_to_enable_voice">Please ask your contact to enable sending voice messages.</string>
<string name="only_group_owners_can_enable_voice">Only group owners can enable voice messages.</string>
<string name="send_live_message">Send live message</string>
<string name="disappearing_message">Disappearing message</string>
<string name="send_disappearing_message">Send disappearing message</string>
<string name="send_disappearing_message_30_seconds">30 seconds</string>
<string name="send_disappearing_message_1_minute">1 minute</string>
<string name="send_disappearing_message_5_minutes">5 minutes</string>
<string name="send_disappearing_message_custom_time">Custom time</string>
<string name="send_disappearing_message_send">Send</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</string>
@@ -1250,6 +1257,7 @@
<string name="timed_messages">Disappearing messages</string>
<string name="direct_messages">Direct messages</string>
<string name="full_deletion">Delete for everyone</string>
<string name="message_reactions">Message reactions</string>
<string name="voice_messages">Voice messages</string>
<string name="audio_video_calls">Audio/video calls</string>
<string name="available_in_v51">\nAvailable in v5.1</string>
@@ -1269,6 +1277,9 @@
<string name="allow_your_contacts_to_send_voice_messages">Allow your contacts to send voice messages.</string>
<string name="allow_voice_messages_only_if">Allow voice messages only if your contact allows them.</string>
<string name="prohibit_sending_voice_messages">Prohibit sending voice messages.</string>
<string name="allow_your_contacts_adding_message_reactions">Allow your contacts adding message reactions.</string>
<string name="allow_message_reactions_only_if">Allow message reactions only if your contact allows them.</string>
<string name="prohibit_message_reactions">Prohibit message reactions.</string>
<string name="allow_your_contacts_to_call">Allow your contacts to call you.</string>
<string name="allow_calls_only_if">Allow calls only if your contact allows them.</string>
<string name="prohibit_calls">Prohibit audio/video calls.</string>
@@ -1284,6 +1295,10 @@
<string name="only_you_can_send_voice">Only you can send voice messages.</string>
<string name="only_your_contact_can_send_voice">Only your contact can send voice messages.</string>
<string name="voice_prohibited_in_this_chat">Voice messages are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_add_message_reactions">Both you and your contact can add message reactions.</string>
<string name="only_you_can_add_message_reactions">Only you can add message reactions.</string>
<string name="only_your_contact_can_add_message_reactions">Only your contact can add message reactions.</string>
<string name="message_reactions_prohibited_in_this_chat">Message reactions are prohibited in this chat.</string>
<string name="both_you_and_your_contact_can_make_calls">Both you and your contact can make calls.</string>
<string name="only_you_can_make_calls">Only you can make calls.</string>
<string name="only_your_contact_can_make_calls">Only your contact can make calls.</string>
@@ -1296,6 +1311,8 @@
<string name="prohibit_message_deletion">Prohibit irreversible message deletion.</string>
<string name="allow_to_send_voice">Allow to send voice messages.</string>
<string name="prohibit_sending_voice">Prohibit sending voice messages.</string>
<string name="allow_message_reactions">Allow message reactions.</string>
<string name="prohibit_message_reactions_group">Prohibit messages reactions.</string>
<string name="group_members_can_send_disappearing">Group members can send disappearing messages.</string>
<string name="disappearing_messages_are_prohibited">Disappearing messages are prohibited in this group.</string>
<string name="group_members_can_send_dms">Group members can send direct messages.</string>
@@ -1304,6 +1321,8 @@
<string name="message_deletion_prohibited_in_chat">Irreversible message deletion is prohibited in this group.</string>
<string name="group_members_can_send_voice">Group members can send voice messages.</string>
<string name="voice_messages_are_prohibited">Voice messages are prohibited in this group.</string>
<string name="group_members_can_add_message_reactions">Group members can add message reactions.</string>
<string name="message_reactions_are_prohibited">Message reactions are prohibited in this group.</string>
<string name="delete_after">Delete after</string>
<string name="ttl_sec">%d sec</string>
<string name="ttl_s">%ds</string>
@@ -1380,4 +1399,12 @@
<string name="v5_0_app_passcode_descr">Set it instead of system authentication.</string>
<string name="v5_0_polish_interface">Polish interface</string>
<string name="v5_0_polish_interface_descr">Thanks to the users contribute via Weblate!</string>
<!-- CustomTimePicker -->
<string name="custom_time_unit_seconds">seconds</string>
<string name="custom_time_unit_minutes">minutes</string>
<string name="custom_time_unit_hours">hours</string>
<string name="custom_time_unit_days">days</string>
<string name="custom_time_unit_weeks">weeks</string>
<string name="custom_time_unit_months">months</string>
</resources>
+1
View File
@@ -10,6 +10,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "SimpleX"
+1 -2
View File
@@ -444,7 +444,6 @@ struct ChatView: View {
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var ci: ChatItem
var showMember: Bool = false
var maxWidth: CGFloat
@@ -538,7 +537,7 @@ struct ChatView: View {
private func menu(live: Bool) -> [UIMenuElement] {
var menu: [UIMenuElement] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction && developerTools,
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
let rm = reactionUIMenu() {
menu.append(rm)
}
@@ -12,7 +12,6 @@ import SimpleXChat
struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed
@@ -25,9 +24,7 @@ struct ContactPreferencesView: View {
List {
timedMessagesFeatureSection()
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
if developerTools {
featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions)
}
// featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions)
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls)
@@ -12,7 +12,6 @@ import SimpleXChat
struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences
@@ -26,9 +25,7 @@ struct GroupPreferencesView: View {
featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable)
if developerTools {
featureSection(.reactions, $preferences.reactions.enable)
}
// featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable)
if groupInfo.canEdit {
@@ -11,7 +11,6 @@ import SimpleXChat
struct PreferencesView: View {
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State var profile: LocalProfile
@State var preferences: FullPreferences
@State var currentPreferences: FullPreferences
@@ -21,9 +20,7 @@ struct PreferencesView: View {
List {
timedMessagesFeatureSection($preferences.timedMessages.allow)
featureSection(.fullDelete, $preferences.fullDelete.allow)
if developerTools {
featureSection(.reactions, $preferences.reactions.allow)
}
// featureSection(.reactions, $preferences.reactions.allow)
featureSection(.voice, $preferences.voice.allow)
featureSection(.calls, $preferences.calls.allow)
+5 -1
View File
@@ -2474,7 +2474,11 @@ public enum MsgReaction: Hashable {
public var text: String {
switch self {
case let .emoji(emoji): return emoji.rawValue
case let .emoji(emoji):
switch emoji {
case .heart: return "❤️"
default: return emoji.rawValue
}
case .unknown: return "?"
}
}
+2
View File
@@ -1,5 +1,7 @@
#!/bin/sh
set -e
# the binaries folders should be in ~/Downloads folder
rm -rf ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim
mkdir -p ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim
+2
View File
@@ -1,5 +1,7 @@
#!/bin/sh
set -e
# the binaries folder should be in ~/Downloads folder
rm -rf ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim
mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim
+9 -19
View File
@@ -468,22 +468,10 @@ processChatCommand = \case
APIGetChatItems pagination search -> withUser $ \user -> do
chatItems <- withStore $ \db -> getAllChatItems db user pagination search
pure $ CRChatItems user chatItems
APIGetChatItemInfo itemId -> withUser $ \user -> do
(chatItem@(AChatItem _ _ _ ChatItem {meta}), itemVersions) <- withStore $ \db -> do
ci <- getAChatItem db user itemId
versions <- liftIO $ getChatItemVersions db itemId
pure (ci, versions)
let CIMeta {itemTs, createdAt, updatedAt, itemTimed} = meta
ciInfo =
ChatItemInfo
{ chatItemId = itemId,
itemTs,
createdAt,
updatedAt,
deleteAt = itemTimed >>= timedDeleteAt',
itemVersions
}
pure $ CRChatItemInfo user chatItem ciInfo
APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do
(chatItem, itemVersions) <- withStore $ \db ->
(,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId)
pure $ CRChatItemInfo user chatItem ChatItemInfo {itemVersions}
APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of
CTDirect -> do
ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId
@@ -1490,7 +1478,9 @@ processChatCommand = \case
chatItems <- withStore $ \db -> getAllChatItems db user (CPLast $ index + 1) Nothing
pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems)
ShowChatItem (Just itemId) -> withUser $ \user -> do
chatItem <- withStore $ \db -> getAChatItem db user itemId
chatItem <- withStore $ \db -> do
chatRef <- getChatRefViaItemId db user itemId
getAChatItem db user chatRef itemId
pure $ CRChatItems user ((: []) chatItem)
ShowChatItem Nothing -> withUser $ \user -> do
chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing
@@ -1498,7 +1488,7 @@ processChatCommand = \case
ShowChatItemInfo chatName msg -> withUser $ \user -> do
chatRef <- getChatRef user chatName
itemId <- getChatItemIdByText user chatRef msg
processChatCommand $ APIGetChatItemInfo itemId
processChatCommand $ APIGetChatItemInfo chatRef itemId
ShowLiveItems on -> withUser $ \_ ->
asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_
SendFile chatName f -> withUser $ \user -> do
@@ -4756,7 +4746,7 @@ chatCommandP =
"/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get item info " *> (APIGetChatItemInfo <$> A.decimal),
"/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal),
"/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode),
+1 -1
View File
@@ -214,7 +214,7 @@ data ChatCommand
| APIGetChats {userId :: UserId, pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
| APIGetChatItemInfo ChatItemId
| APIGetChatItemInfo ChatRef ChatItemId
| APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessage :: ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef ChatItemId CIDeleteMode
+2 -6
View File
@@ -1501,12 +1501,7 @@ jsonCIDeleted = \case
CIModerated m -> JCIDModerated m
data ChatItemInfo = ChatItemInfo
{ chatItemId :: ChatItemId,
itemTs :: UTCTime,
createdAt :: UTCTime,
updatedAt :: UTCTime,
deleteAt :: Maybe UTCTime,
itemVersions :: [ChatItemVersion]
{ itemVersions :: [ChatItemVersion]
}
deriving (Eq, Show, Generic)
@@ -1515,6 +1510,7 @@ instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOpt
data ChatItemVersion = ChatItemVersion
{ chatItemVersionId :: Int64,
msgContent :: MsgContent,
formattedText :: Maybe MarkdownList,
itemVersionTs :: UTCTime,
createdAt :: UTCTime
}
+23 -19
View File
@@ -227,6 +227,7 @@ module Simplex.Chat.Store
getGroupChat,
getAllChatItems,
getAChatItem,
getChatRefViaItemId,
getChatItemVersions,
getDirectCIReactions,
getDirectReactions,
@@ -4290,11 +4291,14 @@ getAllChatItems db user@User {userId} pagination search_ = do
itemRefs <-
rights . map toChatItemRef <$> case pagination of
CPLast count -> liftIO $ getAllChatItemsLast_ count
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem db user afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem db user beforeId
mapM (uncurry (getAChatItem_ db user) >=> liftIO . getACIReactions db) itemRefs
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId
mapM (uncurry (getAChatItem db user) >=> liftIO . getACIReactions db) itemRefs
where
search = fromMaybe "" search_
getAChatItem_ itemId = do
chatRef <- getChatRefViaItemId db user itemId
getAChatItem db user chatRef itemId
getAllChatItemsLast_ count =
reverse
<$> DB.query
@@ -4771,7 +4775,7 @@ getGroupChatItemIdByText' db User {userId} groupId msg =
getChatItemByFileId :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO AChatItem
getChatItemByFileId db user@User {userId} fileId = do
(itemId, chatRef) <-
(chatRef, itemId) <-
ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $
DB.query
db
@@ -4783,11 +4787,11 @@ getChatItemByFileId db user@User {userId} fileId = do
LIMIT 1
|]
(userId, fileId)
getAChatItem_ db user itemId chatRef
getAChatItem db user chatRef itemId
getChatItemByGroupId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO AChatItem
getChatItemByGroupId db user@User {userId} groupId = do
(itemId, chatRef) <-
(chatRef, itemId) <-
ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $
DB.query
db
@@ -4799,22 +4803,20 @@ getChatItemByGroupId db user@User {userId} groupId = do
LIMIT 1
|]
(userId, groupId)
getAChatItem_ db user itemId chatRef
getAChatItem db user chatRef itemId
getAChatItem :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO AChatItem
getAChatItem db user@User {userId} itemId = do
chatRef <-
ExceptT . firstRow' toChatRef (SEChatItemNotFound itemId) $
DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId)
getAChatItem_ db user itemId chatRef
getChatRefViaItemId :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO ChatRef
getChatRefViaItemId db User {userId} itemId = do
ExceptT . firstRow' toChatRef (SEChatItemNotFound itemId) $
DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId)
where
toChatRef = \case
(Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId
(Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId
(_, _) -> Left $ SEBadChatItem itemId
getAChatItem_ :: DB.Connection -> User -> ChatItemId -> ChatRef -> ExceptT StoreError IO AChatItem
getAChatItem_ db user itemId = \case
getAChatItem :: DB.Connection -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
getAChatItem db user chatRef itemId = case chatRef of
ChatRef CTDirect contactId -> do
ct <- getContact db user contactId
(CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId
@@ -4839,7 +4841,9 @@ getChatItemVersions db itemId = do
(Only itemId)
where
toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion
toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = ChatItemVersion {chatItemVersionId, msgContent, itemVersionTs, createdAt}
toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) =
let formattedText = parseMaybeMarkdownList $ msgContentText msgContent
in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt}
getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect)
getDirectChatReactions_ db ct c@Chat {chatItems} = do
@@ -4984,10 +4988,10 @@ updateDirectCIFileStatus db user fileId fileStatus = do
pure $ AChatItem SCTDirect d cInfo $ updateFileStatus ci fileStatus
_ -> pure aci
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatItemId, ChatRef)
toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId)
toChatItemRef = \case
(itemId, Just contactId, Nothing) -> Right (itemId, ChatRef CTDirect contactId)
(itemId, Nothing, Just groupId) -> Right (itemId, ChatRef CTGroup groupId)
(itemId, Just contactId, Nothing) -> Right (ChatRef CTDirect contactId, itemId)
(itemId, Nothing, Just groupId) -> Right (ChatRef CTGroup groupId, itemId)
(itemId, _, _) -> Left $ SEBadChatItem itemId
updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> Maybe (ChatItemId, ChatItemId) -> IO ()
+2 -2
View File
@@ -424,7 +424,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta, content, quotedItem, file}
prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String)
viewChatItemInfo :: AChatItem -> ChatItemInfo -> TimeZone -> [StyledString]
viewChatItemInfo (AChatItem _ msgDir _ _) ChatItemInfo {itemTs, createdAt, deleteAt, itemVersions} tz =
viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions} tz =
["sent at: " <> ts itemTs]
<> receivedAt
<> toBeDeletedAt
@@ -434,7 +434,7 @@ viewChatItemInfo (AChatItem _ msgDir _ _) ChatItemInfo {itemTs, createdAt, delet
receivedAt = case msgDir of
SMDRcv -> ["received at: " <> ts createdAt]
SMDSnd -> []
toBeDeletedAt = case deleteAt of
toBeDeletedAt = case itemTimed >>= timedDeleteAt' of
Just d -> ["to be deleted at: " <> ts d]
Nothing -> []
versions =
+6 -6
View File
@@ -283,9 +283,9 @@ testDirectMessageEditHistory =
alice #> "@bob hello!"
bob <# "alice> hello!"
alice ##> ("/_get item info " <> itemId 1)
alice ##> ("/_get item info @2 " <> itemId 1)
alice <##. "sent at: "
bob ##> ("/_get item info " <> itemId 1)
bob ##> ("/_get item info @2 " <> itemId 1)
bob <##. "sent at: "
bob <##. "received at: "
@@ -293,12 +293,12 @@ testDirectMessageEditHistory =
alice <# "@bob [edited] hey 👋"
bob <# "alice> [edited] hey 👋"
alice ##> ("/_get item info " <> itemId 1)
alice ##> ("/_get item info @2 " <> itemId 1)
alice <##. "sent at: "
alice <## "message history:"
alice .<## ": hey 👋"
alice .<## ": hello!"
bob ##> ("/_get item info " <> itemId 1)
bob ##> ("/_get item info @2 " <> itemId 1)
bob <##. "sent at: "
bob <##. "received at: "
bob <## "message history:"
@@ -434,12 +434,12 @@ testDirectLiveMessage =
alice <# "@bob [LIVE] hello 2"
bob <# "alice> [LIVE ended] hello 2"
-- live message has edit history
alice ##> ("/_get item info " <> itemId 2)
alice ##> ("/_get item info @2 " <> itemId 2)
alice <##. "sent at: "
alice <## "message history:"
alice .<## ": hello 2"
alice .<## ":"
bob ##> ("/_get item info " <> itemId 2)
bob ##> ("/_get item info @2 " <> itemId 2)
bob <##. "sent at: "
bob <##. "received at: "
bob <## "message history:"
+6 -6
View File
@@ -892,9 +892,9 @@ testGroupMessageEditHistory =
aliceItemId <- lastItemId alice
bobItemId <- lastItemId bob
alice ##> ("/_get item info " <> aliceItemId)
alice ##> ("/_get item info #1 " <> aliceItemId)
alice <##. "sent at: "
bob ##> ("/_get item info " <> bobItemId)
bob ##> ("/_get item info #1 " <> bobItemId)
bob <##. "sent at: "
bob <##. "received at: "
@@ -902,12 +902,12 @@ testGroupMessageEditHistory =
alice <# "#team [edited] hey 👋"
bob <# "#team alice> [edited] hey 👋"
alice ##> ("/_get item info " <> aliceItemId)
alice ##> ("/_get item info #1 " <> aliceItemId)
alice <##. "sent at: "
alice <## "message history:"
alice .<## ": hey 👋"
alice .<## ": hello!"
bob ##> ("/_get item info " <> bobItemId)
bob ##> ("/_get item info #1 " <> bobItemId)
bob <##. "sent at: "
bob <##. "received at: "
bob <## "message history:"
@@ -1059,13 +1059,13 @@ testGroupLiveMessage =
bob <# "#team alice> [LIVE ended] hello 2"
cath <# "#team alice> [LIVE ended] hello 2"
-- live message has edit history
alice ##> ("/_get item info " <> msgItemId2)
alice ##> ("/_get item info #1 " <> msgItemId2)
alice <##. "sent at: "
alice <## "message history:"
alice .<## ": hello 2"
alice .<## ":"
bobItemId <- lastItemId bob
bob ##> ("/_get item info " <> bobItemId)
bob ##> ("/_get item info #1 " <> bobItemId)
bob <##. "sent at: "
bob <##. "received at: "
bob <## "message history:"
+3
View File
@@ -212,6 +212,9 @@ module.exports = function (ty) {
if (parsed.scheme || parsed.host || !parsed.path.endsWith(".md")) {
return link
}
if (parsed.path.startsWith("../../blog")) {
parsed.path = parsed.path.replace("../../blog", "/blog")
}
parsed.path = parsed.path.replace(/\.md$/, ".html")
return uri.serialize(parsed)
}