mobile: group invitations ui (#816)

* ios: group invitations ui

* fix

* memberActive (crashes)

* adjustments

* android ui

* android - memberActive

* update group invitation item layout

* update texts

* typo

* update layout

* do not add contacts added via groups

* filter contacts by conn_level

* turn off address sanitizer

* fix layout

* android: filter on update chat

* android adjustments

* divider fix

* android chat previews

* ios previews

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-07-18 21:58:32 +04:00
committed by GitHub
parent 54f8dd8a2e
commit 53f3ee1f50
21 changed files with 766 additions and 116 deletions

View File

@@ -73,12 +73,14 @@ class ChatModel(val controller: ChatController) {
fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection))
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact))
fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = !contact.isIndirectContact)
private fun updateChat(cInfo: ChatInfo) {
fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo))
private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) {
if (hasChat(cInfo.id)) {
updateChatInfo(cInfo)
} else {
} else if (addMissing) {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
}
}
@@ -428,6 +430,9 @@ class Contact(
override val fullName get() = profile.fullName
override val image get() = profile.image
val isIndirectContact: Boolean get() =
activeConn.connLevel > 0 || viaGroup != null
companion object {
val sampleData = Contact(
contactId = 1,
@@ -455,10 +460,10 @@ class ContactSubStatus(
)
@Serializable
class Connection(val connId: Long, val connStatus: ConnStatus) {
class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int) {
val id: ChatId get() = ":$connId"
companion object {
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready)
val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0)
}
}
@@ -487,6 +492,7 @@ class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
val membership: GroupMember,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -503,6 +509,7 @@ class GroupInfo (
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
membership = GroupMember.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -537,6 +544,20 @@ class GroupMember (
val memberContactId: Long? = null,
var activeConn: Connection? = null
) {
val memberActive: Boolean get() = when (this.memberStatus) {
GroupMemberStatus.MemRemoved -> false
GroupMemberStatus.MemLeft -> false
GroupMemberStatus.MemGroupDeleted -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemIntroduced -> false
GroupMemberStatus.MemIntroInvited -> false
GroupMemberStatus.MemAccepted -> false
GroupMemberStatus.MemAnnounced -> false
GroupMemberStatus.MemConnected -> true
GroupMemberStatus.MemComplete -> true
GroupMemberStatus.MemCreator -> true
}
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
@@ -811,6 +832,15 @@ data class ChatItem (
quotedItem = null,
file = null
)
fun getGroupInvitationSample(status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending) =
ChatItem(
chatDir = CIDirection.DirectRcv(),
meta = CIMeta.getSample(1, Clock.System.now(), "received invitation to join group team as admin", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
content = CIContent.RcvGroupInvitation(groupInvitation = CIGroupInvitation.getSample(status = status), memberRole = GroupMemberRole.Admin),
quotedItem = null,
file = null
)
}
}
@@ -901,6 +931,8 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
override val text: String get() = when(this) {
is SndMsgContent -> msgContent.text
@@ -910,6 +942,8 @@ sealed class CIContent: ItemContent {
is SndCall -> status.text(duration)
is RcvCall -> status.text(duration)
is RcvIntegrityError -> msgError.text
is RcvGroupInvitation -> groupInvitation.text()
is SndGroupInvitation -> groupInvitation.text()
}
}
@@ -1003,6 +1037,36 @@ sealed class MsgContent {
}
}
@Serializable
class CIGroupInvitation (
val groupId: Long,
val groupMemberId: Long,
val localDisplayName: String,
val groupProfile: GroupProfile,
val status: CIGroupInvitationStatus
) {
fun text(): String = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName)
companion object {
fun getSample(
groupId: Long = 1,
groupMemberId: Long = 1,
localDisplayName: String = "team",
groupProfile: GroupProfile = GroupProfile.sampleData,
status: CIGroupInvitationStatus = CIGroupInvitationStatus.Pending
): CIGroupInvitation =
CIGroupInvitation(groupId = groupId, groupMemberId = groupMemberId, localDisplayName = localDisplayName, groupProfile = groupProfile, status = status)
}
}
@Serializable
enum class CIGroupInvitationStatus {
@SerialName("pending") Pending,
@SerialName("accepted") Accepted,
@SerialName("rejected") Rejected,
@SerialName("expired") Expired;
}
object MsgContentSerializer : KSerializer<MsgContent> {
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {

View File

@@ -509,7 +509,14 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
suspend fun apiReceiveFile(fileId: Long): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId))
if (r is CR.RcvFileAccepted) return r.chatItem
Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiReceiveFile bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiJoinGroup(groupId: Long): GroupInfo? {
val r = sendCmd(CC.ApiJoinGroup(groupId))
if (r is CR.UserAcceptedGroupSent) return r.groupInfo
Log.e(TAG, "apiJoinGroup bad response: ${r.responseType} ${r.details}")
return null
}
@@ -599,6 +606,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
chatModel.upsertChatItem(cInfo, cItem)
}
}
is CR.ReceivedGroupInvitation -> {
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf()))
// TODO NtfManager.shared.notifyGroupInvitation
}
is CR.UserJoinedGroup ->
chatModel.updateGroup(r.groupInfo)
is CR.RcvFileStart ->
chatItemSimpleUpdate(r.chatItem)
is CR.RcvFileComplete ->
@@ -674,6 +687,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
suspend fun joinGroup(groupId: Long) {
val groupInfo = apiJoinGroup(groupId)
if (groupInfo != null) {
chatModel.updateGroup(groupInfo)
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem

View File

@@ -108,6 +108,9 @@ fun ChatView(chatModel: ChatModel) {
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.joinGroup(groupId) }
},
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
@@ -144,6 +147,7 @@ fun ChatLayout(
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
acceptCall: (Contact) -> Unit
) {
@@ -171,7 +175,7 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, acceptCall)
ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, joinGroup, acceptCall)
}
}
}
@@ -261,6 +265,7 @@ fun ChatItemsList(
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew })
@@ -299,11 +304,11 @@ fun ChatItemsList(
} else {
Spacer(Modifier.size(42.dp))
}
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
} else {
Box(Modifier.padding(start = 86.dp, end = 12.dp)) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall)
}
}
} else { // direct message
@@ -314,7 +319,7 @@ fun ChatItemsList(
end = if (sent) 12.dp else 76.dp,
)
) {
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, acceptCall = acceptCall)
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall)
}
}
}
@@ -384,8 +389,9 @@ fun PreviewChatLayout() {
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> }
acceptCall = { _ -> }
)
}
}
@@ -431,8 +437,9 @@ fun PreviewGroupChatLayout() {
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
startCall = {},
acceptCall = { _ -> }
acceptCall = { _ -> }
)
}
}

View File

@@ -0,0 +1,162 @@
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
@Composable
fun CIGroupInvitationView(
ci: ChatItem,
groupInvitation: CIGroupInvitation,
memberRole: GroupMemberRole,
joinGroup: (Long) -> Unit
) {
val sent = ci.chatDir.sent
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
@Composable
fun groupInfoView() {
val p = groupInvitation.groupProfile
val iconColor =
if (action) MaterialTheme.colors.primary
else if (isSystemInDarkTheme()) FileDark else FileLight
Row(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 4.dp, bottom = 12.dp)
.padding(horizontal = 2.dp)
) {
ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(p.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium, maxLines = 2)
if (p.fullName != "" && p.displayName != p.fullName) {
Text(p.fullName, maxLines = 2)
}
}
}
}
@Composable
fun groupInvitationText() {
when {
sent -> Text(stringResource(R.string.you_sent_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Pending -> Text(stringResource(R.string.you_are_invited_to_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Accepted -> Text(stringResource(R.string.you_joined_this_group))
!sent && groupInvitation.status == CIGroupInvitationStatus.Rejected -> Text(stringResource(R.string.you_rejected_group_invitation))
!sent && groupInvitation.status == CIGroupInvitationStatus.Expired -> Text(stringResource(R.string.group_invitation_expired))
}
}
fun acceptInvitation() {
Log.d(TAG, "CIGroupInvitationView acceptInvitation")
joinGroup(groupInvitation.groupId)
}
Surface(
modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier,
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight,
) {
Box(
Modifier
.width(IntrinsicSize.Min)
.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.BottomEnd
) {
Column(
Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 4.dp),
horizontalAlignment = Alignment.Start
) {
groupInfoView()
Divider(Modifier.fillMaxWidth().padding(bottom = 8.dp))
if (action) {
groupInvitationText()
Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary)
} else {
Box(Modifier.padding(end = 60.dp)) {
groupInvitationText()
}
}
}
Text(
ci.timestampText,
color = HighOrLowlight,
fontSize = 14.sp,
modifier = Modifier.padding(start = 3.dp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun PendingCIGroupInvitationViewPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)
@Composable
fun CIGroupInvitationViewAcceptedPreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CIGroupInvitationViewLongNamePreview() {
SimpleXTheme {
CIGroupInvitationView(
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(
groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name"),
status = CIGroupInvitationStatus.Accepted
),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
)
}
}

View File

@@ -39,6 +39,7 @@ fun ChatItemView(
useLinkPreviews: Boolean,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit
) {
val context = LocalContext.current
@@ -146,6 +147,8 @@ fun ChatItemView(
is CIContent.SndCall -> CallItem(c.status, c.duration)
is CIContent.RcvCall -> CallItem(c.status, c.duration)
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup)
}
}
}
@@ -209,6 +212,7 @@ fun PreviewChatItemView() {
cxt = LocalContext.current,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}
@@ -227,6 +231,7 @@ fun PreviewChatItemViewDeletedContent() {
cxt = LocalContext.current,
deleteMessage = { _, _ -> },
receiveFile = {},
joinGroup = {},
acceptCall = { _ -> }
)
}

View File

@@ -56,7 +56,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
name = "Dark Mode"
)
@Composable
fun IntegrityErrorItemViewView() {
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {
IntegrityErrorItemView(
ChatItem.getDeletedContentSampleData()

View File

@@ -1,7 +1,6 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -39,7 +38,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.Direct ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
@@ -47,8 +46,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, stopped) },
click = { openOrPendingChat(chat.chatInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu,
stopped
)
@@ -71,7 +70,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
}
fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
if (chatInfo.ready) {
withApi { openChat(chatInfo, chatModel) }
} else {
@@ -79,6 +78,14 @@ fun openOrPendingChat(chatInfo: ChatInfo, chatModel: ChatModel) {
}
}
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
}
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
@@ -122,27 +129,40 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
}
@Composable
fun GroupMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited ->
ItemAction(
stringResource(R.string.join_button),
Icons.Outlined.Login,
onClick = {
withApi { chatModel.controller.joinGroup(groupInfo.groupId) }
showMenu.value = false
}
)
else -> {
if (showMarkRead) {
ItemAction(
stringResource(R.string.mark_read),
Icons.Outlined.Check,
onClick = {
markChatRead(chat, chatModel)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
)
}
)
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
}
}
ItemAction(
stringResource(R.string.clear_verb),
Icons.Outlined.Restore,
onClick = {
clearChatDialog(chat.chatInfo, chatModel)
showMenu.value = false
},
color = WarningOrange
)
}
@Composable
@@ -287,6 +307,22 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
)
}
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.join_group_question),
text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members),
confirmText = generalGetString(R.string.join_button),
onConfirm = { withApi { chatModel.controller.joinGroup(groupInfo.groupId) } }
)
}
fun groupInvitationAcceptedAlert() {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.joining_group),
generalGetString(R.string.youve_accepted_group_invitation_connecting_to_inviting_group_member)
)
}
@Composable
fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,

View File

@@ -27,8 +27,50 @@ import chat.simplex.app.views.helpers.badgeLayout
@Composable
fun ChatPreviewView(chat: Chat, stopped: Boolean) {
val cInfo = chat.chatInfo
@Composable
fun chatPreviewTitleColor(): Color {
return when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.ready) Color.Unspecified else HighOrLowlight
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> MaterialTheme.colors.primary
GroupMemberStatus.MemAccepted -> HighOrLowlight
else -> Color.Unspecified
}
else -> Color.Unspecified
}
}
@Composable
fun chatPreviewText() {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
)
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.ready) {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
is ChatInfo.Group ->
if (cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemAccepted) {
Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
}
else -> {}
}
}
}
Row {
val cInfo = chat.chatInfo
ChatInfoImage(cInfo, size = 72.dp)
Column(
modifier = Modifier
@@ -41,22 +83,9 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = if (cInfo.ready) Color.Unspecified else HighOrLowlight
color = chatPreviewTitleColor()
)
if (cInfo.ready) {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.text, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
)
}
} else {
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
}
chatPreviewText()
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)

View File

@@ -110,6 +110,7 @@
<string name="this_text_is_available_in_settings">Этот текст можно найти в Настройках</string>
<string name="your_chats">Ваши чаты</string>
<string name="contact_connection_pending">соединяется…</string>
<string name="group_connection_pending">соединяется…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Прикрепить</string>
@@ -484,4 +485,20 @@
<string name="delete_archive">Удалить архив</string>
<string name="archive_created_on_ts">Дата создания <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Удалить архив чата?</string>
<!-- Groups -->
<string name="group_invitation_item_description">приглашение в группу <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Вступить в группу?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">Вы приглашены в группу. Вступите, чтобы соединиться с членами группы.</string>
<string name="join_button">Вступить</string>
<string name="joining_group">Вступление в группу</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">Вы вступили в эту группу. Устанавливается соединение с пригласившем членом группы.</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
<string name="you_are_invited_to_group">Вы приглашены в группу</string>
<string name="group_invitation_tap_to_join">Нажмите, чтобы вступить</string>
<string name="you_joined_this_group">Вы вступили в эту группу</string>
<string name="you_rejected_group_invitation">Вы отклонили приглашение в группу</string>
<string name="group_invitation_expired">Приглашение в группу истекло</string>
</resources>

View File

@@ -110,6 +110,7 @@
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Your chats</string>
<string name="contact_connection_pending">connecting…</string>
<string name="group_connection_pending">connecting…</string>
<!-- ComposeView.kt, helpers -->
<string name="attach">Attach</string>
@@ -486,4 +487,20 @@
<string name="delete_archive">Delete archive</string>
<string name="archive_created_on_ts">Created on <xliff:g id="archive_ts">%1$s</xliff:g></string>
<string name="delete_chat_archive_question">Delete chat archive?</string>
<!-- Groups -->
<string name="group_invitation_item_description">invitation to group <xliff:g id="group_name">%1$s</xliff:g></string>
<string name="join_group_question">Join group?</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">You are invited to group. Join to connect with group members.</string>
<string name="join_button">Join</string>
<string name="joining_group">Joining group</string>
<string name="youve_accepted_group_invitation_connecting_to_inviting_group_member">You joined this group. Connecting to inviting group member.</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>
<string name="you_are_invited_to_group">You are invited to group</string>
<string name="group_invitation_tap_to_join">Tap to join</string>
<string name="you_joined_this_group">You joined this group</string>
<string name="you_rejected_group_invitation">You rejected group invitation</string>
<string name="group_invitation_expired">Group invitation expired</string>
</resources>

View File

@@ -76,13 +76,17 @@ final class ChatModel: ObservableObject {
}
func updateContact(_ contact: Contact) {
updateChat(.direct(contact: contact))
updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact())
}
private func updateChat(_ cInfo: ChatInfo) {
func updateGroup(_ groupInfo: GroupInfo) {
updateChat(.group(groupInfo: groupInfo))
}
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
if hasChat(cInfo.id) {
updateChatInfo(cInfo)
} else {
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
}
}

View File

@@ -526,6 +526,21 @@ func apiNewGroup(_ gp: GroupProfile) throws -> GroupInfo {
throw r
}
func joinGroup(groupId: Int64) async {
do {
let groupInfo = try await apiJoinGroup(groupId: groupId)
DispatchQueue.main.async { ChatModel.shared.updateGroup(groupInfo) }
} catch let error {
logger.error("joinGroup error: \(responseError(error))")
}
}
func apiJoinGroup(groupId: Int64) async throws -> GroupInfo {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
if case let .userAcceptedGroupSent(groupInfo) = r { return groupInfo }
throw r
}
func initializeChat(start: Bool) throws {
logger.debug("initializeChat")
do {
@@ -695,6 +710,14 @@ func processReceivedMsg(_ res: ChatResponse) async {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = m.upsertChatItem(cInfo, cItem)
}
case let .receivedGroupInvitation(groupInfo, _, _):
m.addChat(Chat(
chatInfo: .group(groupInfo: groupInfo),
chatItems: []
))
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
case let .userJoinedGroup(groupInfo):
m.updateGroup(groupInfo)
case let .rcvFileStart(aChatItem):
chatItemSimpleUpdate(aChatItem)
case let .rcvFileComplete(aChatItem):

View File

@@ -9,8 +9,8 @@
import SwiftUI
import SimpleXChat
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat

View File

@@ -56,13 +56,13 @@ struct CIFileView: View {
}
func fileAction() {
logger.debug("CIFileView processFile")
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation:
if fileSizeValid() {
Task {
logger.debug("CIFileView processFile - in .rcvInvitation, in Task")
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
await receiveFile(fileId: file.fileId)
}
} else {
@@ -78,7 +78,7 @@ struct CIFileView: View {
message: "File will be received when your contact is online, please wait or check later!"
)
case .rcvComplete:
logger.debug("CIFileView processFile - in .rcvComplete")
logger.debug("CIFileView fileAction - in .rcvComplete")
if let filePath = getLoadedFilePath(file){
let url = URL(fileURLWithPath: filePath)
showShareSheet(items: [url])

View File

@@ -0,0 +1,113 @@
//
// CIGroupInvitationView.swift
// SimpleX (iOS)
//
// Created by JRoberts on 15.07.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct CIGroupInvitationView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole
@State private var frameWidth: CGFloat = 0
var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading) {
groupInfoView(action)
.padding(.horizontal, 2)
.padding(.top, 8)
.padding(.bottom, 6)
.overlay(DetermineWidth())
Divider().frame(width: frameWidth)
if action {
groupInvitationText().overlay(DetermineWidth())
Text("Tap to join")
.foregroundColor(.accentColor)
.font(.callout)
} else {
groupInvitationText()
.padding(.trailing, 60)
.overlay(DetermineWidth())
}
}
.padding(.bottom, 2)
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
if action {
v.onTapGesture { acceptInvitation() }
} else {
v
}
}
private func groupInfoView(_ action: Bool) -> some View {
HStack(alignment: .top) {
ProfileImage(
iconName: "person.2.circle.fill",
color: action ? .accentColor : Color(uiColor: .tertiaryLabel)
)
.frame(width: 44, height: 44)
.padding(.trailing, 4)
VStack(alignment: .leading) {
let p = groupInvitation.groupProfile
Text(p.displayName).font(.headline).lineLimit(2)
if p.fullName != "" && p.displayName != p.fullName {
Text(p.fullName).font(.subheadline).lineLimit(2)
}
}
.frame(minHeight: 44)
}
}
private func groupInvitationText() -> some View {
Text(groupInvitationStr())
.font(.callout)
}
private func groupInvitationStr() -> LocalizedStringKey {
if chatItem.chatDir.sent {
return "You sent group invitation"
} else {
switch groupInvitation.status {
case .pending: return "You are invited to group"
case .accepted: return "You joined this group"
case .rejected: return "You rejected group invitation"
case .expired: return "Group invitation expired"
}
}
}
private func acceptInvitation() {
Task {
logger.debug("acceptInvitation")
await joinGroup(groupId: groupInvitation.groupId)
}
}
}
struct CIGroupInvitationView_Previews: PreviewProvider {
static var previews: some View {
Group {
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
}
}
}

View File

@@ -24,6 +24,8 @@ struct ChatItemView: View {
case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration)
case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
}
}
@@ -42,6 +44,10 @@ struct ChatItemView: View {
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration)
}
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole)
}
}
struct ChatItemView_Previews: PreviewProvider {

View File

@@ -71,30 +71,54 @@ struct ChatListNavLink: View {
}
}
private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
NavLinkPlain(
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
let v = NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) },
disabled: !groupInfo.ready
disabled: !groupInfo.ready // TODO group has to be accessible for member in other statuses as well, e.g. if he was removed
)
.swipeActions(edge: .leading) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: {
Label("Delete", systemImage: "trash")
}
}
.frame(height: 80)
switch (groupInfo.membership.memberStatus) {
case .memInvited:
v.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
}
// .onTapGesture {
// AlertManager.shared.showAlert(acceptGroupInvitationAlert(groupInfo))
// }
// case .memAccepted:
// v.onTapGesture {
// AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
// }
default:
v.swipeActions(edge: .leading) {
if chat.chatStats.unreadCount > 0 {
markReadButton()
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
clearChatButton()
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
private func joinGroupButton() -> some View {
Button {
Task { await joinGroup(groupId: chat.chatInfo.apiId) }
} label: {
Label("Join", systemImage: "iphone.and.arrow.forward")
}
.tint(Color.accentColor)
}
private func markReadButton() -> some View {
@@ -243,6 +267,24 @@ struct ChatListNavLink: View {
)
}
private func acceptGroupInvitationAlert(_ groupInfo: GroupInfo) -> Alert {
Alert(
title: Text("Join group?"),
message: Text("You are invited to group. Join to connect with group members."),
primaryButton: .default(Text("Join")) {
Task { await joinGroup(groupId: groupInfo.groupId) }
},
secondaryButton: .cancel()
)
}
private func groupInvitationAcceptedAlert() -> Alert {
Alert(
title: Text("Joining group"),
message: Text("You joined this group. Connecting to inviting group member.")
)
}
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),

View File

@@ -24,11 +24,7 @@ struct ChatPreviewView: View {
VStack(spacing: 0) {
HStack(alignment: .top) {
Text(chat.chatInfo.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
.frame(maxHeight: .infinity, alignment: .topLeading)
chatPreviewTitle()
Spacer()
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt))
.font(.subheadline)
@@ -41,27 +37,7 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
if let cItem = cItem {
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
.padding(.bottom, 4)
if unread > 0 {
Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)")
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(Color.accentColor)
.cornerRadius(10)
}
} else if case let .direct(contact) = chat.chatInfo, !contact.ready {
Text("Connecting...")
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
}
chatPreviewText(cItem, unread)
if case .direct = chat.chatInfo {
chatStatusImage()
.padding(.top, 24)
@@ -75,6 +51,68 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func chatPreviewTitle() -> some View {
let v = Text(chat.chatInfo.chatViewName)
.font(.title3)
.fontWeight(.bold)
.frame(maxHeight: .infinity, alignment: .topLeading)
switch (chat.chatInfo) {
case .direct:
v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case .group(groupInfo: let groupInfo):
switch (groupInfo.membership.memberStatus) {
case .memInvited:
v.foregroundColor(.accentColor)
case .memAccepted:
v.foregroundColor(.secondary)
default:
v.foregroundColor(.primary)
}
default:
v.foregroundColor(.primary)
}
}
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?, _ unread: Int) -> some View {
if let cItem = cItem {
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
.padding(.bottom, 4)
if unread > 0 {
Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)")
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(Color.accentColor)
.cornerRadius(10)
}
}
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if !contact.ready {
connectingText()
}
case let .group(groupInfo):
if groupInfo.membership.memberStatus == .memAccepted {
connectingText()
}
default: EmptyView()
}
}
}
@ViewBuilder private func connectingText() -> some View {
Text("Connecting...")
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
}
private func itemStatusMark(_ cItem: ChatItem) -> Text {
switch cItem.meta.itemStatus {
case .sndErrorAuth:

View File

@@ -121,6 +121,7 @@
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -297,6 +298,7 @@
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -589,6 +591,7 @@
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
5C029EA72837DBB3004A9677 /* CICallItemView.swift */,
5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */,
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
@@ -838,6 +841,7 @@
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */,
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */,
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */,
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */,
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,

View File

@@ -219,7 +219,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
var localDisplayName: ContactName
public var profile: Profile
public var activeConn: Connection
var viaGroup: Int64?
public var viaGroup: Int64?
var createdAt: Date
var updatedAt: Date
@@ -230,6 +230,10 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var fullName: String { get { profile.fullName } }
public var image: String? { get { profile.image } }
public func isIndirectContact() -> Bool {
return activeConn.connLevel > 0 || viaGroup != nil
}
public static let sampleData = Contact(
contactId: 1,
localDisplayName: "alice",
@@ -255,12 +259,14 @@ public struct ContactSubStatus: Decodable {
public struct Connection: Decodable {
var connId: Int64
var connStatus: ConnStatus
var connLevel: Int
public var id: ChatId { get { ":\(connId)" } }
static let sampleData = Connection(
connId: 1,
connStatus: .ready
connStatus: .ready,
connLevel: 0
)
}
@@ -376,9 +382,10 @@ public struct Group: Decodable {
}
public struct GroupInfo: Identifiable, Decodable, NamedChat {
var groupId: Int64
public var groupId: Int64
var localDisplayName: GroupName
var groupProfile: GroupProfile
public var membership: GroupMember
var createdAt: Date
var updatedAt: Date
@@ -393,6 +400,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
groupId: 1,
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
membership: GroupMember.sampleData,
createdAt: .now,
updatedAt: .now
)
@@ -409,7 +417,7 @@ public struct GroupProfile: Codable, NamedChat {
public var fullName: String
public var image: String?
static let sampleData = GroupProfile(
public static let sampleData = GroupProfile(
displayName: "team",
fullName: "My Team"
)
@@ -421,7 +429,7 @@ public struct GroupMember: Decodable {
var memberId: String
var memberRole: GroupMemberRole
var memberCategory: GroupMemberCategory
var memberStatus: GroupMemberStatus
public var memberStatus: GroupMemberStatus
var invitedBy: InvitedBy
var localDisplayName: ContactName
public var memberProfile: Profile
@@ -445,6 +453,24 @@ public struct GroupMember: Decodable {
}
}
public var memberActive: Bool {
get {
switch self.memberStatus {
case .memRemoved: return false
case .memLeft: return false
case .memGroupDeleted: return false
case .memInvited: return false
case .memIntroduced: return false
case .memIntroInvited: return false
case .memAccepted: return false
case .memAnnounced: return false
case .memConnected: return true
case .memComplete: return true
case .memCreator: return true
}
}
}
public static let sampleData = GroupMember(
groupMemberId: 1,
groupId: 1,
@@ -624,6 +650,16 @@ public struct ChatItem: Identifiable, Decodable {
file: nil
)
}
public static func getGroupInvitationSample (_ status: CIGroupInvitationStatus = .pending) -> ChatItem {
ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, false, false, false),
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: status), memberRole: .admin),
quotedItem: nil,
file: nil
)
}
}
public enum CIDirection: Decodable {
@@ -706,6 +742,8 @@ public enum CIContent: Decodable, ItemContent {
case sndCall(status: CICallStatus, duration: Int)
case rcvCall(status: CICallStatus, duration: Int)
case rcvIntegrityError(msgError: MsgErrorType)
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
public var text: String {
get {
@@ -717,6 +755,8 @@ public enum CIContent: Decodable, ItemContent {
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text()
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text()
}
}
}
@@ -1027,3 +1067,26 @@ public enum MsgErrorType: Decodable {
}
}
}
public struct CIGroupInvitation: Decodable {
public var groupId: Int64
public var groupMemberId: Int64
public var localDisplayName: GroupName
public var groupProfile: GroupProfile
public var status: CIGroupInvitationStatus
func text() -> String {
String.localizedStringWithFormat(NSLocalizedString("invitation to group %@", comment: "group name"), groupProfile.displayName)
}
public static func getSample(groupId: Int64 = 1, groupMemberId: Int64 = 1, localDisplayName: GroupName = "team", groupProfile: GroupProfile = GroupProfile.sampleData, status: CIGroupInvitationStatus = .pending) -> CIGroupInvitation {
CIGroupInvitation(groupId: groupId, groupMemberId: groupMemberId, localDisplayName: localDisplayName, groupProfile: groupProfile, status: status)
}
}
public enum CIGroupInvitationStatus: String, Decodable {
case pending
case accepted
case rejected
case expired
}

View File

@@ -700,15 +700,15 @@ processChatCommand = \case
Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName
| otherwise -> throwChatError $ CEGroupDuplicateMember cName
APIJoinGroup groupId -> withUser $ \user@User {userId} -> do
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \db -> getGroupInvitation db user groupId
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId
withChatLock . procCmd $ do
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember)
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership:: GroupMember)
withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId
updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId (membership g) GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
updateCIGroupInvitationStatus user
pure $ CRUserAcceptedGroupSent g
pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}}
where
updateCIGroupInvitationStatus user@User {userId} = do
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId
@@ -1380,11 +1380,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
sendPendingGroupMessages m conn
case memberCategory m of
GCHostMember -> do
toView $ CRUserJoinedGroup gInfo
toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}}
setActive $ ActiveG gName
showToast ("#" <> gName) "you are connected to group"
GCInviteeMember -> do
toView $ CRJoinedGroupMember gInfo m
toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected}
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore' $ \db -> createIntroductions db members m