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:
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = { _ -> }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun IntegrityErrorItemViewView() {
|
||||
fun IntegrityErrorItemViewPreview() {
|
||||
SimpleXTheme {
|
||||
IntegrityErrorItemView(
|
||||
ChatItem.getDeletedContentSampleData()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
113
apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
Normal file
113
apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user