mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-01 17:58:37 +00:00
Merge branch 'master' into av/ios-infinity-scroll
This commit is contained in:
+29
-8
@@ -1484,6 +1484,7 @@ data class Contact(
|
||||
val contactGroupMemberId: Long? = null,
|
||||
val contactGrpInvSent: Boolean,
|
||||
val chatTags: List<Long>,
|
||||
val chatItemTTL: Long?,
|
||||
override val chatDeleted: Boolean,
|
||||
val uiThemes: ThemeModeOverrides? = null,
|
||||
): SomeChat, NamedChat {
|
||||
@@ -1567,7 +1568,8 @@ data class Contact(
|
||||
contactGrpInvSent = false,
|
||||
chatDeleted = false,
|
||||
uiThemes = null,
|
||||
chatTags = emptyList()
|
||||
chatTags = emptyList(),
|
||||
chatItemTTL = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1726,6 +1728,7 @@ data class GroupInfo (
|
||||
val chatTs: Instant?,
|
||||
val uiThemes: ThemeModeOverrides? = null,
|
||||
val chatTags: List<Long>,
|
||||
val chatItemTTL: Long?,
|
||||
override val localAlias: String,
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
@@ -1774,7 +1777,8 @@ data class GroupInfo (
|
||||
chatTs = Clock.System.now(),
|
||||
uiThemes = null,
|
||||
chatTags = emptyList(),
|
||||
localAlias = ""
|
||||
localAlias = "",
|
||||
chatItemTTL = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4186,32 +4190,49 @@ enum class SwitchPhase {
|
||||
@SerialName("completed") Completed
|
||||
}
|
||||
|
||||
sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
|
||||
sealed class ChatItemTTL: Comparable<ChatItemTTL> {
|
||||
object Day: ChatItemTTL()
|
||||
object Week: ChatItemTTL()
|
||||
object Month: ChatItemTTL()
|
||||
object Year: ChatItemTTL()
|
||||
data class Seconds(val secs: Long): ChatItemTTL()
|
||||
object None: ChatItemTTL()
|
||||
|
||||
override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE)
|
||||
override fun compareTo(other: ChatItemTTL): Int =
|
||||
(seconds.takeIf { it != 0L } ?: Long.MAX_VALUE)
|
||||
.compareTo(other.seconds.takeIf { it != 0L } ?: Long.MAX_VALUE)
|
||||
|
||||
val seconds: Long?
|
||||
val seconds: Long
|
||||
get() =
|
||||
when (this) {
|
||||
is None -> null
|
||||
is None -> 0
|
||||
is Day -> 86400L
|
||||
is Week -> 7 * 86400L
|
||||
is Month -> 30 * 86400L
|
||||
is Year -> 365 * 86400L
|
||||
is Seconds -> secs
|
||||
}
|
||||
|
||||
val text: String
|
||||
get() = when(this) {
|
||||
is None -> generalGetString(MR.strings.chat_item_ttl_none)
|
||||
is Day -> generalGetString(MR.strings.chat_item_ttl_day)
|
||||
is Week -> generalGetString(MR.strings.chat_item_ttl_week)
|
||||
is Month -> generalGetString(MR.strings.chat_item_ttl_month)
|
||||
is Year -> generalGetString(MR.strings.chat_item_ttl_year)
|
||||
is Seconds -> String.format(generalGetString(MR.strings.chat_item_ttl_seconds), secs)
|
||||
}
|
||||
|
||||
val neverExpires: Boolean get() = this is None
|
||||
|
||||
companion object {
|
||||
fun fromSeconds(seconds: Long?): ChatItemTTL =
|
||||
fun fromSeconds(seconds: Long): ChatItemTTL =
|
||||
when (seconds) {
|
||||
null -> None
|
||||
0L -> None
|
||||
86400L -> Day
|
||||
7 * 86400L -> Week
|
||||
30 * 86400L -> Month
|
||||
365 * 86400L -> Year
|
||||
else -> Seconds(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -1182,7 +1182,13 @@ object ChatController {
|
||||
suspend fun getChatItemTTL(rh: Long?): ChatItemTTL {
|
||||
val userId = currentUserId("getChatItemTTL")
|
||||
val r = sendCmd(rh, CC.APIGetChatItemTTL(userId))
|
||||
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
|
||||
if (r is CR.ChatItemTTL) {
|
||||
return if (r.chatItemTTL != null) {
|
||||
ChatItemTTL.fromSeconds(r.chatItemTTL)
|
||||
} else {
|
||||
ChatItemTTL.None
|
||||
}
|
||||
}
|
||||
throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
@@ -1193,6 +1199,13 @@ object ChatController {
|
||||
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun setChatTTL(rh: Long?, chatType: ChatType, id: Long, chatItemTTL: ChatItemTTL?) {
|
||||
val userId = currentUserId("setChatTTL")
|
||||
val r = sendCmd(rh, CC.APISetChatTTL(userId, chatType, id, chatItemTTL?.seconds))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to set chat TTL: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetNetworkConfig(cfg: NetCfg, showAlertOnError: Boolean = true, ctrl: ChatCtrl? = null): Boolean {
|
||||
val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl)
|
||||
return when (r) {
|
||||
@@ -3383,8 +3396,9 @@ sealed class CC {
|
||||
class ApiGetUsageConditions(): CC()
|
||||
class ApiSetConditionsNotified(val conditionsId: Long): CC()
|
||||
class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List<Long>): CC()
|
||||
class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC()
|
||||
class APISetChatItemTTL(val userId: Long, val seconds: Long): CC()
|
||||
class APIGetChatItemTTL(val userId: Long): CC()
|
||||
class APISetChatTTL(val userId: Long, val chatType: ChatType, val id: Long, val seconds: Long?): CC()
|
||||
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
|
||||
class APIGetNetworkConfig: CC()
|
||||
class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC()
|
||||
@@ -3567,6 +3581,7 @@ sealed class CC {
|
||||
is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}"
|
||||
is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}"
|
||||
is APIGetChatItemTTL -> "/_ttl $userId"
|
||||
is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id)} ${chatItemTTLStr(seconds)}"
|
||||
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
|
||||
is APIGetNetworkConfig -> "/network"
|
||||
is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}"
|
||||
@@ -3727,6 +3742,7 @@ sealed class CC {
|
||||
is ApiAcceptConditions -> "apiAcceptConditions"
|
||||
is APISetChatItemTTL -> "apiSetChatItemTTL"
|
||||
is APIGetChatItemTTL -> "apiGetChatItemTTL"
|
||||
is APISetChatTTL -> "apiSetChatTTL"
|
||||
is APISetNetworkConfig -> "apiSetNetworkConfig"
|
||||
is APIGetNetworkConfig -> "apiGetNetworkConfig"
|
||||
is APISetNetworkInfo -> "apiSetNetworkInfo"
|
||||
@@ -3812,7 +3828,7 @@ sealed class CC {
|
||||
data class ItemRange(val from: Long, val to: Long)
|
||||
|
||||
fun chatItemTTLStr(seconds: Long?): String {
|
||||
if (seconds == null) return "none"
|
||||
if (seconds == null) return "default"
|
||||
return seconds.toString()
|
||||
}
|
||||
|
||||
|
||||
+105
-4
@@ -16,8 +16,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -36,12 +38,14 @@ import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.group.ChatTTLSection
|
||||
import chat.simplex.common.views.chat.group.ProgressIndicator
|
||||
import chat.simplex.common.views.chatlist.updateChatSettings
|
||||
import chat.simplex.common.views.database.*
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -74,6 +78,9 @@ fun ChatInfoView(
|
||||
}
|
||||
val chatRh = chat.remoteHostId
|
||||
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
|
||||
val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) }
|
||||
val deletingItems = rememberSaveable(contact.id) { mutableStateOf(false) }
|
||||
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
contact,
|
||||
@@ -84,6 +91,16 @@ fun ChatInfoView(
|
||||
updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel)
|
||||
sendReceipts.value = sendRcpts
|
||||
},
|
||||
chatItemTTL = chatItemTTL,
|
||||
setChatItemTTL = {
|
||||
if (it == chatItemTTL.value) {
|
||||
return@ChatInfoLayout
|
||||
}
|
||||
val previousChatTTL = chatItemTTL.value
|
||||
chatItemTTL.value = it
|
||||
|
||||
setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems)
|
||||
},
|
||||
connStats = connStats,
|
||||
contactNetworkStatus.value,
|
||||
customUserProfile,
|
||||
@@ -173,7 +190,8 @@ fun ChatInfoView(
|
||||
}
|
||||
},
|
||||
close = close,
|
||||
onSearchClicked = onSearchClicked
|
||||
onSearchClicked = onSearchClicked,
|
||||
deletingItems = deletingItems
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -504,6 +522,8 @@ fun ChatInfoLayout(
|
||||
currentUser: User,
|
||||
sendReceipts: State<SendReceipts>,
|
||||
setSendReceipts: (SendReceipts) -> Unit,
|
||||
chatItemTTL: MutableState<ChatItemTTL?>,
|
||||
setChatItemTTL: (ChatItemTTL?) -> Unit,
|
||||
connStats: MutableState<ConnectionStats?>,
|
||||
contactNetworkStatus: NetworkStatus,
|
||||
customUserProfile: Profile?,
|
||||
@@ -520,7 +540,8 @@ fun ChatInfoLayout(
|
||||
syncContactConnectionForce: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
close: () -> Unit,
|
||||
onSearchClicked: () -> Unit
|
||||
onSearchClicked: () -> Unit,
|
||||
deletingItems: State<Boolean>
|
||||
) {
|
||||
val cStats = connStats.value
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -597,6 +618,9 @@ fun ChatInfoLayout(
|
||||
}
|
||||
SectionDividerSpaced(maxBottomPadding = false)
|
||||
|
||||
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
|
||||
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||
|
||||
val conn = contact.activeConn
|
||||
if (conn != null) {
|
||||
SectionView {
|
||||
@@ -1308,6 +1332,80 @@ fun queueInfoText(info: Pair<RcvMsgInfo?, ServerQueueInfo>): String {
|
||||
return generalGetString(MR.strings.message_queue_info_server_info).format(json.encodeToString(qInfo), msgInfo)
|
||||
}
|
||||
|
||||
fun setChatTTLAlert(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
selectedChatTTL: MutableState<ChatItemTTL?>,
|
||||
previousChatTTL: ChatItemTTL?,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
) {
|
||||
val defaultTTL = chatModel.chatItemTTL.value
|
||||
val previouslyUsedTTL = previousChatTTL ?: defaultTTL
|
||||
val newTTLToUse = selectedChatTTL.value ?: defaultTTL
|
||||
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(
|
||||
if (newTTLToUse.neverExpires) {
|
||||
MR.strings.disable_automatic_deletion_question
|
||||
} else if (!previouslyUsedTTL.neverExpires || selectedChatTTL.value == null) {
|
||||
MR.strings.change_automatic_deletion_question
|
||||
} else MR.strings.enable_automatic_deletion_question),
|
||||
text = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion_message else MR.strings.change_automatic_chat_deletion_message),
|
||||
confirmText = generalGetString(if (newTTLToUse.neverExpires) MR.strings.disable_automatic_deletion else MR.strings.delete_messages),
|
||||
onConfirm = { setChatTTL(rhId, chatInfo, selectedChatTTL, progressIndicator, previousChatTTL) },
|
||||
onDismiss = { selectedChatTTL.value = previousChatTTL },
|
||||
onDismissRequest = { selectedChatTTL.value = previousChatTTL },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setChatTTL(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
chatTTL: MutableState<ChatItemTTL?>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
previousChatTTL: ChatItemTTL?
|
||||
) {
|
||||
progressIndicator.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
chatModel.controller.setChatTTL(rhId, chatInfo.chatType, chatInfo.apiId, chatTTL.value)
|
||||
afterSetChatTTL(rhId, chatInfo, progressIndicator)
|
||||
} catch (e: Exception) {
|
||||
chatTTL.value = previousChatTTL
|
||||
afterSetChatTTL(rhId, chatInfo, progressIndicator)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_message_deletion), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState<Boolean>) {
|
||||
try {
|
||||
val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
|
||||
val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return
|
||||
if (chat.chatItems.isEmpty()) {
|
||||
// replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview
|
||||
withChats {
|
||||
val oldChat = getChat(chat.id)
|
||||
if (oldChat != null) {
|
||||
replaceChat(oldChat.remoteHostId, oldChat.id, oldChat.copy(chatItems = emptyList()))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chat.remoteHostId != chatModel.remoteHostId() || chat.id != chatModel.chatId.value) return
|
||||
processLoadedChat(
|
||||
chat,
|
||||
navInfo,
|
||||
contentTag = null,
|
||||
pagination = pagination
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}")
|
||||
} finally {
|
||||
progressIndicator.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
@@ -1322,6 +1420,8 @@ fun PreviewChatInfoLayout() {
|
||||
User.sampleData,
|
||||
sendReceipts = remember { mutableStateOf(SendReceipts.Yes) },
|
||||
setSendReceipts = {},
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) },
|
||||
setChatItemTTL = {},
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
@@ -1338,7 +1438,8 @@ fun PreviewChatInfoLayout() {
|
||||
syncContactConnectionForce = {},
|
||||
verifyClicked = {},
|
||||
close = {},
|
||||
onSearchClicked = {}
|
||||
onSearchClicked = {},
|
||||
deletingItems = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -34,7 +34,16 @@ suspend fun apiLoadMessages(
|
||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|
||||
|| !isActive) return@coroutineScope
|
||||
processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed)
|
||||
}
|
||||
|
||||
suspend fun processLoadedChat(
|
||||
chat: Chat,
|
||||
navInfo: NavigationInfo,
|
||||
contentTag: MsgContentTag?,
|
||||
pagination: ChatPagination,
|
||||
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
|
||||
) {
|
||||
val chatState = chatModel.chatStateForContent(contentTag)
|
||||
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
|
||||
val oldItems = chatModel.chatItemsForContent(contentTag).value
|
||||
@@ -70,7 +79,7 @@ suspend fun apiLoadMessages(
|
||||
is ChatPagination.Before -> {
|
||||
newItems.addAll(oldItems)
|
||||
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
|
||||
if (indexInCurrentItems == -1) return@coroutineScope
|
||||
if (indexInCurrentItems == -1) return
|
||||
val (newIds, _) = mapItemsToIds(chat.chatItems)
|
||||
val wasSize = newItems.size
|
||||
val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination(
|
||||
@@ -87,7 +96,7 @@ suspend fun apiLoadMessages(
|
||||
is ChatPagination.After -> {
|
||||
newItems.addAll(oldItems)
|
||||
val indexInCurrentItems: Int = oldItems.indexOfFirst { it.id == pagination.chatItemId }
|
||||
if (indexInCurrentItems == -1) return@coroutineScope
|
||||
if (indexInCurrentItems == -1) return
|
||||
|
||||
val mappedItems = mapItemsToIds(chat.chatItems)
|
||||
val newIds = mappedItems.first
|
||||
|
||||
+48
-4
@@ -17,6 +17,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -39,6 +40,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.database.TtlOptions
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
@@ -55,7 +57,10 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
||||
if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
|
||||
val groupInfo = chat.chatInfo.groupInfo
|
||||
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) }
|
||||
val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) }
|
||||
val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
GroupChatInfoLayout(
|
||||
chat,
|
||||
groupInfo,
|
||||
@@ -66,6 +71,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
||||
updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel)
|
||||
sendReceipts.value = sendRcpts
|
||||
},
|
||||
chatItemTTL = chatItemTTL,
|
||||
setChatItemTTL = {
|
||||
if (it == chatItemTTL.value) {
|
||||
return@GroupChatInfoLayout
|
||||
}
|
||||
val previousChatTTL = chatItemTTL.value
|
||||
chatItemTTL.value = it
|
||||
|
||||
setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems)
|
||||
},
|
||||
members = remember { chatModel.groupMembers }.value
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedByDescending { it.memberRole },
|
||||
@@ -125,7 +140,8 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
||||
manageGroupLink = {
|
||||
ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||
},
|
||||
onSearchClicked = onSearchClicked
|
||||
onSearchClicked = onSearchClicked,
|
||||
deletingItems = deletingItems
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +301,8 @@ fun ModalData.GroupChatInfoLayout(
|
||||
currentUser: User,
|
||||
sendReceipts: State<SendReceipts>,
|
||||
setSendReceipts: (SendReceipts) -> Unit,
|
||||
chatItemTTL: MutableState<ChatItemTTL?>,
|
||||
setChatItemTTL: (ChatItemTTL?) -> Unit,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
@@ -300,7 +318,8 @@ fun ModalData.GroupChatInfoLayout(
|
||||
leaveGroup: () -> Unit,
|
||||
manageGroupLink: () -> Unit,
|
||||
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
|
||||
onSearchClicked: () -> Unit
|
||||
onSearchClicked: () -> Unit,
|
||||
deletingItems: State<Boolean>
|
||||
) {
|
||||
val listState = remember { appBarHandler.listState }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -394,7 +413,10 @@ fun ModalData.GroupChatInfoLayout(
|
||||
}
|
||||
val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs
|
||||
SectionTextFooter(stringResource(footerId))
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||
|
||||
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
|
||||
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true)
|
||||
|
||||
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
|
||||
if (groupInfo.canAddMembers) {
|
||||
@@ -463,6 +485,26 @@ fun ModalData.GroupChatInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTTLSection(chatItemTTL: State<ChatItemTTL?>, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State<Boolean>) {
|
||||
Box {
|
||||
SectionView {
|
||||
TtlOptions(
|
||||
chatItemTTL,
|
||||
enabled = remember { derivedStateOf { !deletingItems.value } },
|
||||
onSelected = setChatItemTTL,
|
||||
default = chatModel.chatItemTTL
|
||||
)
|
||||
SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer))
|
||||
}
|
||||
if (deletingItems.value) {
|
||||
Box(Modifier.matchParentSize()) {
|
||||
ProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
|
||||
Column(
|
||||
@@ -770,12 +812,14 @@ fun PreviewGroupChatInfoLayout() {
|
||||
User.sampleData,
|
||||
sendReceipts = remember { mutableStateOf(SendReceipts.Yes) },
|
||||
setSendReceipts = {},
|
||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) },
|
||||
setChatItemTTL = {},
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
onLocalAliasChanged = {},
|
||||
groupLink = null,
|
||||
scrollToItemId = remember { mutableStateOf(null) },
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
package chat.simplex.common.views.chat.group
|
||||
|
||||
import SectionBottomSpacer
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
||||
+21
-13
@@ -12,6 +12,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
@@ -108,6 +109,9 @@ fun DatabaseView() {
|
||||
}
|
||||
},
|
||||
onChatItemTTLSelected = {
|
||||
if (it == null) {
|
||||
return@DatabaseLayout
|
||||
}
|
||||
val oldValue = chatItemTTL.value
|
||||
chatItemTTL.value = it
|
||||
if (it < oldValue) {
|
||||
@@ -158,7 +162,7 @@ fun DatabaseLayout(
|
||||
exportArchive: () -> Unit,
|
||||
deleteChatAlert: () -> Unit,
|
||||
deleteAppFilesAndMedia: () -> Unit,
|
||||
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
|
||||
onChatItemTTLSelected: (ChatItemTTL?) -> Unit,
|
||||
disconnectAllHosts: () -> Unit,
|
||||
) {
|
||||
val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote
|
||||
@@ -300,21 +304,25 @@ private fun setChatItemTTLAlert(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onSelected: (ChatItemTTL) -> Unit) {
|
||||
fun TtlOptions(
|
||||
current: State<ChatItemTTL?>,
|
||||
enabled: State<Boolean>,
|
||||
onSelected: (ChatItemTTL?) -> Unit,
|
||||
default: State<ChatItemTTL>? = null
|
||||
) {
|
||||
val values = remember {
|
||||
val all: ArrayList<ChatItemTTL> = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day)
|
||||
if (current.value is ChatItemTTL.Seconds) {
|
||||
all.add(current.value)
|
||||
val all: ArrayList<ChatItemTTL> = arrayListOf(ChatItemTTL.None, ChatItemTTL.Year, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day)
|
||||
val currentValue = current.value
|
||||
if (currentValue is ChatItemTTL.Seconds) {
|
||||
all.add(currentValue)
|
||||
}
|
||||
all.map {
|
||||
when (it) {
|
||||
is ChatItemTTL.None -> it to generalGetString(MR.strings.chat_item_ttl_none)
|
||||
is ChatItemTTL.Day -> it to generalGetString(MR.strings.chat_item_ttl_day)
|
||||
is ChatItemTTL.Week -> it to generalGetString(MR.strings.chat_item_ttl_week)
|
||||
is ChatItemTTL.Month -> it to generalGetString(MR.strings.chat_item_ttl_month)
|
||||
is ChatItemTTL.Seconds -> it to String.format(generalGetString(MR.strings.chat_item_ttl_seconds), it.secs)
|
||||
}
|
||||
val options: MutableList<Pair<ChatItemTTL?, String>> = all.map { it to it.text }.toMutableList()
|
||||
|
||||
if (default != null) {
|
||||
options.add(null to String.format(generalGetString(MR.strings.chat_item_ttl_default), default.value.text))
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.delete_messages_after),
|
||||
|
||||
@@ -540,6 +540,12 @@
|
||||
|
||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||
<string name="notifications">Notifications</string>
|
||||
<string name="disable_automatic_deletion_question">Disable automatic message deletion?</string>
|
||||
<string name="change_automatic_deletion_question">Change automatic message deletion?</string>
|
||||
<string name="disable_automatic_deletion_message">Messages in this chat will never be deleted.</string>
|
||||
<string name="change_automatic_chat_deletion_message">This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.</string>
|
||||
<string name="disable_automatic_deletion">Disable delete messages</string>
|
||||
<string name="chat_ttl_options_footer">Delete chat messages from your device.</string>
|
||||
|
||||
<!-- Chat Info Actions - ChatInfoView.kt -->
|
||||
<string name="info_view_connect_button">connect</string>
|
||||
@@ -1396,7 +1402,9 @@
|
||||
<string name="chat_item_ttl_day">1 day</string>
|
||||
<string name="chat_item_ttl_week">1 week</string>
|
||||
<string name="chat_item_ttl_month">1 month</string>
|
||||
<string name="chat_item_ttl_year">1 year</string>
|
||||
<string name="chat_item_ttl_seconds">%s second(s)</string>
|
||||
<string name="chat_item_ttl_default">default (%s)</string>
|
||||
<string name="messages_section_title">Messages</string>
|
||||
<string name="messages_section_description">This setting applies to messages in your current chat profile</string>
|
||||
<string name="delete_messages_after">Delete messages after</string>
|
||||
|
||||
@@ -220,6 +220,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20241230_reports
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -741,7 +741,7 @@ data ChatResponse
|
||||
| CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]}
|
||||
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
|
||||
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
|
||||
| CRGroupInvitation {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo}
|
||||
| CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole}
|
||||
| CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember}
|
||||
| CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
@@ -757,8 +757,7 @@ data ChatResponse
|
||||
| CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember}
|
||||
| CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember}
|
||||
| CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember}
|
||||
| CRGroupEmpty {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupRemoved {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo}
|
||||
| CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember}
|
||||
| CRGroupProfile {user :: User, groupInfo :: GroupInfo}
|
||||
@@ -773,9 +772,9 @@ data ChatResponse
|
||||
| CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember}
|
||||
| CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact}
|
||||
| CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError}
|
||||
| CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError}
|
||||
| CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]}
|
||||
| CRGroupSubscribed {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo}
|
||||
| CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]}
|
||||
| CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError}
|
||||
| CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError}
|
||||
@@ -1051,7 +1050,7 @@ data ContactSubStatus = ContactSubStatus
|
||||
deriving (Show)
|
||||
|
||||
data MemberSubStatus = MemberSubStatus
|
||||
{ member :: GroupMember,
|
||||
{ member :: ShortGroupMember,
|
||||
memberError :: Maybe ChatError
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
@@ -3347,17 +3347,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
|
||||
rs <- withAgent $ \a -> agentBatchSubscribe a conns
|
||||
-- send connection events to view
|
||||
contactSubsToView rs cts ce
|
||||
-- TODO possibly, we could either disable these events or replace with less noisy for API
|
||||
contactLinkSubsToView rs ucs
|
||||
groupSubsToView rs gs ms ce
|
||||
sndFileSubsToView rs sfts
|
||||
rcvFileSubsToView rs rfts
|
||||
pendingConnSubsToView rs pcs
|
||||
unlessM (asks $ coreApi . config) $ do
|
||||
contactLinkSubsToView rs ucs
|
||||
groupSubsToView rs gs ms ce
|
||||
sndFileSubsToView rs sfts
|
||||
rcvFileSubsToView rs rfts
|
||||
pendingConnSubsToView rs pcs
|
||||
where
|
||||
addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case
|
||||
RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs)
|
||||
RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs')
|
||||
RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs)
|
||||
RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs)
|
||||
SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs)
|
||||
RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs)
|
||||
UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs)
|
||||
@@ -3377,6 +3377,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
|
||||
createdAt,
|
||||
updatedAt = createdAt
|
||||
}
|
||||
toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} =
|
||||
ShortGroupMember
|
||||
{ groupMemberId,
|
||||
groupId,
|
||||
memberName = localDisplayName,
|
||||
connId = agentConnId
|
||||
}
|
||||
getContactConns :: CM ([ConnId], Map ConnId Contact)
|
||||
getContactConns = do
|
||||
cts <- withStore_ (`getUserContacts` vr)
|
||||
@@ -3387,11 +3394,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
|
||||
(cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr)
|
||||
let connIds = map aConnId cs
|
||||
pure (connIds, M.fromList $ zip connIds ucs)
|
||||
getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember)
|
||||
getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember)
|
||||
getGroupMemberConns = do
|
||||
gs <- withStore_ (`getUserGroups` vr)
|
||||
let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs
|
||||
gs <- withStore_ getUserGroupsToSubscribe
|
||||
let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs
|
||||
pure (gs, map fst mPairs, M.fromList mPairs)
|
||||
where
|
||||
shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId
|
||||
getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer)
|
||||
getSndFileTransferConns = do
|
||||
sfts <- withStore_ getLiveSndFileTransfers
|
||||
@@ -3435,30 +3444,27 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
|
||||
-- TODO possibly below could be replaced with less noisy events for API
|
||||
contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM ()
|
||||
contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs
|
||||
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM ()
|
||||
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM ()
|
||||
groupSubsToView rs gs ms ce = do
|
||||
mapM_ groupSub $
|
||||
sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs
|
||||
sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs
|
||||
toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs
|
||||
where
|
||||
mRs = resultsFor rs ms
|
||||
groupSub :: Group -> CM ()
|
||||
groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do
|
||||
groupSub :: ShortGroup -> CM ()
|
||||
groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do
|
||||
when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors
|
||||
toView groupEvent
|
||||
where
|
||||
mErrors :: [(GroupMember, ChatError)]
|
||||
mErrors :: [(ShortGroupMember, ChatError)]
|
||||
mErrors =
|
||||
sortOn (\(GroupMember {localDisplayName = n}, _) -> n)
|
||||
sortOn (\(ShortGroupMember {memberName = n}, _) -> n)
|
||||
. filterErrors
|
||||
$ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs
|
||||
$ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs
|
||||
groupEvent :: ChatResponse
|
||||
groupEvent
|
||||
| memberStatus membership == GSMemInvited = CRGroupInvitation user g
|
||||
| all (\GroupMember {activeConn} -> isNothing activeConn) members =
|
||||
if memberActive membership
|
||||
then CRGroupEmpty user g
|
||||
else CRGroupRemoved user g
|
||||
| membershipStatus == GSMemInvited = CRGroupInvitation user g
|
||||
| null members = CRGroupEmpty user g
|
||||
| otherwise = CRGroupSubscribed user g
|
||||
sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM ()
|
||||
sndFileSubsToView rs sfts = do
|
||||
|
||||
@@ -57,6 +57,7 @@ module Simplex.Chat.Store.Groups
|
||||
deleteGroupItemsAndMembers,
|
||||
deleteGroup,
|
||||
getUserGroups,
|
||||
getUserGroupsToSubscribe,
|
||||
getUserGroupDetails,
|
||||
getUserGroupsWithSummary,
|
||||
getGroupSummary,
|
||||
@@ -164,9 +165,13 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..))
|
||||
import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>))
|
||||
import Simplex.Messaging.Version
|
||||
import UnliftIO.STM
|
||||
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
||||
#else
|
||||
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
#endif
|
||||
|
||||
type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences))
|
||||
|
||||
@@ -588,6 +593,51 @@ getGroup db vr user groupId = do
|
||||
members <- liftIO $ getGroupMembers db vr user gInfo
|
||||
pure $ Group gInfo members
|
||||
|
||||
getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup
|
||||
getGroupToSubscribe db User {userId, userContactId} groupId = do
|
||||
shortInfo <- getGroupInfoToSubscribe
|
||||
members <- liftIO getGroupMembersToSubscribe
|
||||
pure $ ShortGroup shortInfo members
|
||||
where
|
||||
getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo
|
||||
getGroupInfoToSubscribe = ExceptT $ do
|
||||
firstRow toInfo (SEGroupNotFound groupId) $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT g.local_display_name, mu.member_status
|
||||
FROM groups g
|
||||
JOIN group_members mu ON mu.group_id = g.group_id
|
||||
WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?
|
||||
AND mu.member_status NOT IN (?,?,?)
|
||||
|]
|
||||
(groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
|
||||
where
|
||||
toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo
|
||||
toInfo (groupName, membershipStatus) =
|
||||
ShortGroupInfo groupId groupName membershipStatus
|
||||
getGroupMembersToSubscribe :: IO [ShortGroupMember]
|
||||
getGroupMembersToSubscribe = do
|
||||
map toShortMember
|
||||
<$> DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT m.group_member_id, m.local_display_name, c.agent_conn_id
|
||||
FROM group_members m
|
||||
JOIN connections c ON c.connection_id = (
|
||||
SELECT max(cc.connection_id)
|
||||
FROM connections cc
|
||||
WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id
|
||||
)
|
||||
WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)
|
||||
AND m.member_status NOT IN (?,?,?)
|
||||
|]
|
||||
(userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
|
||||
where
|
||||
toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember
|
||||
toShortMember (groupMemberId, localDisplayName, agentConnId) =
|
||||
ShortGroupMember groupMemberId groupId localDisplayName agentConnId
|
||||
|
||||
deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO ()
|
||||
deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do
|
||||
forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m)
|
||||
@@ -642,6 +692,11 @@ getUserGroups db vr user@User {userId} = do
|
||||
groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId)
|
||||
rights <$> mapM (runExceptT . getGroup db vr user) groupIds
|
||||
|
||||
getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup]
|
||||
getUserGroupsToSubscribe db user@User {userId} = do
|
||||
groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId)
|
||||
rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds
|
||||
|
||||
getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo]
|
||||
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do
|
||||
g_ <-
|
||||
|
||||
@@ -415,20 +415,20 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q
|
||||
user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id,
|
||||
-- meta
|
||||
item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id,
|
||||
forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at,
|
||||
forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, timed_ttl, timed_delete_at,
|
||||
-- quote
|
||||
quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id,
|
||||
-- forwarded from
|
||||
fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow)
|
||||
ciId <- insertedRowId db
|
||||
forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt
|
||||
pure ciId
|
||||
where
|
||||
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime)
|
||||
itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed
|
||||
itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime)
|
||||
itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed
|
||||
quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e)
|
||||
idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64)
|
||||
idsRow = case chatDirection of
|
||||
@@ -438,6 +438,10 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q
|
||||
CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing)
|
||||
CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId)
|
||||
CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId)
|
||||
includeInHistory :: Bool
|
||||
includeInHistory =
|
||||
let (_, groupId_, _, _) = idsRow
|
||||
in isJust groupId_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_)
|
||||
forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64)
|
||||
forwardedFromRow = case itemForwarded of
|
||||
Nothing ->
|
||||
@@ -3070,12 +3074,11 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do
|
||||
SELECT i.chat_item_id
|
||||
FROM chat_items i
|
||||
LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ?
|
||||
WHERE i.user_id = ? AND i.group_id = ?
|
||||
AND i.item_content_tag IN (?,?)
|
||||
AND i.msg_content_tag NOT IN (?)
|
||||
WHERE s.group_snd_item_status_id IS NULL
|
||||
AND i.user_id = ? AND i.group_id = ?
|
||||
AND i.include_in_history = 1
|
||||
AND i.item_deleted = 0
|
||||
AND s.group_snd_item_status_id IS NULL
|
||||
ORDER BY i.item_ts DESC, i.chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count)
|
||||
(groupMemberId' m, userId, groupId, count)
|
||||
|
||||
@@ -425,7 +425,8 @@ CREATE TABLE chat_items(
|
||||
fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL,
|
||||
fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL,
|
||||
via_proxy SMALLINT,
|
||||
msg_content_tag TEXT
|
||||
msg_content_tag TEXT,
|
||||
include_in_history SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
ALTER TABLE groups
|
||||
ADD CONSTRAINT fk_groups_chat_items
|
||||
@@ -1012,4 +1013,16 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items(
|
||||
item_deleted,
|
||||
item_sent
|
||||
);
|
||||
CREATE INDEX idx_chat_items_groups_history ON chat_items(
|
||||
user_id,
|
||||
group_id,
|
||||
include_in_history,
|
||||
item_deleted,
|
||||
item_ts,
|
||||
chat_item_id
|
||||
);
|
||||
CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(
|
||||
chat_item_id,
|
||||
group_member_id
|
||||
);
|
||||
|]
|
||||
|
||||
@@ -124,6 +124,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -247,7 +248,8 @@ schemaMigrations =
|
||||
("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags),
|
||||
("20241230_reports", m20241230_reports, Just down_m20241230_reports),
|
||||
("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes),
|
||||
("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl)
|
||||
("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl),
|
||||
("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_history where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20250122_chat_items_include_in_history :: Query
|
||||
m20250122_chat_items_include_in_history =
|
||||
[sql|
|
||||
ALTER TABLE chat_items ADD COLUMN include_in_history INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX idx_chat_items_groups_history ON chat_items(
|
||||
user_id,
|
||||
group_id,
|
||||
include_in_history,
|
||||
item_deleted,
|
||||
item_ts,
|
||||
chat_item_id
|
||||
);
|
||||
|
||||
UPDATE chat_items
|
||||
SET include_in_history = 1
|
||||
WHERE group_id IS NOT NULL
|
||||
AND item_content_tag IN ('rcvMsgContent', 'sndMsgContent')
|
||||
AND msg_content_tag NOT IN ('report');
|
||||
|
||||
CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(chat_item_id, group_member_id);
|
||||
|]
|
||||
|
||||
down_m20250122_chat_items_include_in_history :: Query
|
||||
down_m20250122_chat_items_include_in_history =
|
||||
[sql|
|
||||
DROP INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id;
|
||||
|
||||
DROP INDEX idx_chat_items_groups_history;
|
||||
|
||||
ALTER TABLE chat_items DROP COLUMN include_in_history;
|
||||
|]
|
||||
@@ -406,7 +406,8 @@ CREATE TABLE chat_items(
|
||||
fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL,
|
||||
fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL,
|
||||
via_proxy INTEGER,
|
||||
msg_content_tag TEXT
|
||||
msg_content_tag TEXT,
|
||||
include_in_history INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE sqlite_sequence(name,seq);
|
||||
CREATE TABLE chat_item_messages(
|
||||
@@ -978,3 +979,15 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items(
|
||||
item_deleted,
|
||||
item_sent
|
||||
);
|
||||
CREATE INDEX idx_chat_items_groups_history ON chat_items(
|
||||
user_id,
|
||||
group_id,
|
||||
include_in_history,
|
||||
item_deleted,
|
||||
item_ts,
|
||||
chat_item_id
|
||||
);
|
||||
CREATE INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id ON group_snd_item_statuses(
|
||||
chat_item_id,
|
||||
group_member_id
|
||||
);
|
||||
|
||||
@@ -373,6 +373,26 @@ optionalFullName displayName fullName
|
||||
| T.null fullName || displayName == fullName = ""
|
||||
| otherwise = " (" <> fullName <> ")"
|
||||
|
||||
data ShortGroup = ShortGroup
|
||||
{ shortInfo :: ShortGroupInfo,
|
||||
members :: [ShortGroupMember]
|
||||
}
|
||||
|
||||
data ShortGroupInfo = ShortGroupInfo
|
||||
{ groupId :: GroupId,
|
||||
groupName :: GroupName,
|
||||
membershipStatus :: GroupMemberStatus
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data ShortGroupMember = ShortGroupMember
|
||||
{ groupMemberId :: GroupMemberId,
|
||||
groupId :: GroupId,
|
||||
memberName :: ContactName,
|
||||
connId :: AgentConnId
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]}
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -1812,3 +1832,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef)
|
||||
$(JQ.deriveJSON defaultJSON ''NoteFolder)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''ChatTag)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''ShortGroupInfo)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''ShortGroupMember)
|
||||
|
||||
@@ -292,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
(groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks
|
||||
CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else []
|
||||
CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else []
|
||||
CRGroupInvitation u g -> ttyUser u [groupInvitation' g]
|
||||
CRGroupInvitation u g -> ttyUser u [groupInvitationSub g]
|
||||
CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r
|
||||
CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g
|
||||
CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m
|
||||
@@ -307,8 +307,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g
|
||||
CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"]
|
||||
CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"]
|
||||
CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"]
|
||||
CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"]
|
||||
CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"]
|
||||
CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"]
|
||||
CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m
|
||||
CRGroupProfile u g -> ttyUser u $ viewGroupProfile g
|
||||
@@ -323,9 +322,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m]
|
||||
CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"]
|
||||
CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct'
|
||||
CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
|
||||
CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e]
|
||||
CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors"
|
||||
CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g
|
||||
CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g
|
||||
CRPendingSubSummary u _ -> ttyUser u []
|
||||
CRSndFileSubError u SndFileTransfer {fileId, fileName} e ->
|
||||
ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
|
||||
@@ -571,8 +570,8 @@ viewUsersList us =
|
||||
<> ["muted" | not showNtfs]
|
||||
<> [plain ("unread: " <> show count) | count /= 0]
|
||||
|
||||
viewGroupSubscribed :: GroupInfo -> [StyledString]
|
||||
viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"]
|
||||
viewGroupSubscribed :: GroupName -> [StyledString]
|
||||
viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"]
|
||||
|
||||
showSMPServer :: SMPServer -> String
|
||||
showSMPServer ProtocolServer {host} = B.unpack $ strEncode host
|
||||
@@ -1216,6 +1215,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil
|
||||
Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", "
|
||||
Nothing -> " to join, "
|
||||
|
||||
groupInvitationSub :: ShortGroupInfo -> StyledString
|
||||
groupInvitationSub ShortGroupInfo {groupName = ldn} =
|
||||
highlight ("#" <> viewName ldn)
|
||||
<> " - you are invited ("
|
||||
<> highlight ("/j " <> viewName ldn)
|
||||
<> " to join, "
|
||||
<> highlight ("/d #" <> viewName ldn)
|
||||
<> " to delete invitation)"
|
||||
|
||||
viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString]
|
||||
viewContactsMerged c1 c2 ct' =
|
||||
[ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1,
|
||||
|
||||
@@ -923,13 +923,13 @@ testRestoreDirectory tmp = do
|
||||
withTestChat tmp "cath" $ \cath -> do
|
||||
bob <## "2 contacts connected (use /cs for the list)"
|
||||
bob
|
||||
<### [ "#privacy (Privacy): connected to server(s)",
|
||||
"#security (Security): connected to server(s)"
|
||||
<### [ "#privacy: connected to server(s)",
|
||||
"#security: connected to server(s)"
|
||||
]
|
||||
cath <## "2 contacts connected (use /cs for the list)"
|
||||
cath
|
||||
<### [ "#privacy (Privacy): connected to server(s)",
|
||||
"#anonymity (Anonymity): connected to server(s)"
|
||||
<### [ "#privacy: connected to server(s)",
|
||||
"#anonymity: connected to server(s)"
|
||||
]
|
||||
listGroups superUser bob cath
|
||||
groupFoundN 3 bob "privacy"
|
||||
|
||||
@@ -169,7 +169,6 @@ testChatApi tmp = do
|
||||
chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists
|
||||
chatSendCmd cc "/_start" `shouldReturn` chatStarted
|
||||
chatRecvMsg cc `shouldReturn` networkStatuses
|
||||
chatRecvMsg cc `shouldReturn` userContactSubSummary
|
||||
chatRecvMsgWait cc 10000 `shouldReturn` ""
|
||||
chatParseMarkdown "hello" `shouldBe` "{}"
|
||||
chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown
|
||||
|
||||
Reference in New Issue
Block a user