diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index a72ac162da..d49030aab7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBack @@ -15,6 +16,7 @@ import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -25,6 +27,7 @@ import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.ChatItemView +import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.ModalManager import com.google.accompanist.insets.ProvideWindowInsets @@ -62,6 +65,12 @@ fun ChatView(chatModel: ChatModel) { ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, + openDirectChat = { contactId -> + val c = chatModel.chats.firstOrNull { + it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId + } + if (c != null) withApi { openChat(chatModel, c.chatInfo) } + }, sendMessage = { msg -> withApi { // show "in progress" @@ -104,6 +113,7 @@ fun ChatLayout( editingItem: MutableState, back: () -> Unit, info: () -> Unit, + openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit ) { @@ -119,7 +129,7 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chatItems, msg, quotedItem, editingItem) + ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat) } } } @@ -186,10 +196,12 @@ val CIListStateSaver = run { @Composable fun ChatItemsList( user: User, + chat: Chat, chatItems: List, msg: MutableState, quotedItem: MutableState, - editingItem: MutableState + editingItem: MutableState, + openDirectChat: (Long) -> Unit ) { val listState = rememberLazyListState() val keyboardState by getKeyboardState() @@ -200,8 +212,42 @@ fun ChatItemsList( val uriHandler = LocalUriHandler.current val cxt = LocalContext.current LazyColumn(state = listState) { - items(chatItems) { cItem -> - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) + itemsIndexed(chatItems) { i, cItem -> + if (chat.chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + val prevItem = if (i > 0) chatItems[i - 1] else null + val member = cItem.chatDir.groupMember + val showMember = showMemberImage(member, prevItem) + Row(Modifier.padding(start = 8.dp, end = 66.dp)) { + if (showMember) { + val contactId = member.memberContactId + if (contactId == null) { + MemberImage(member) + } else { + Box(Modifier.clip(CircleShape).clickable { openDirectChat(contactId) }) { + MemberImage(member) + } + } + Spacer(Modifier.size(4.dp)) + } else { + Spacer(Modifier.size(42.dp)) + } + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember) + } + } else { + Box(Modifier.padding(start = 86.dp, end = 12.dp)) { + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) + } + } + } else { // direct message + val sent = cItem.chatDir.sent + Box(Modifier.padding( + start = if (sent) 76.dp else 12.dp, + end = if (sent) 12.dp else 76.dp, + )) { + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) + } + } } val len = chatItems.count() if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { @@ -213,6 +259,18 @@ fun ChatItemsList( } } +fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { + return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd || + ( prevItem.chatDir is CIDirection.GroupRcv && + prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId + ) +} + +@Composable +fun MemberImage(member: GroupMember) { + ProfileImage(38.dp, member.memberProfile.image) +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -252,6 +310,48 @@ fun PreviewChatLayout() { editingItem = remember { mutableStateOf(null) }, back = {}, info = {}, + openDirectChat = {}, + sendMessage = {}, + resetMessage = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewGroupChatLayout() { + SimpleXTheme { + val chatItems = listOf( + ChatItem.getSampleData( + 1, CIDirection.GroupSnd(), Clock.System.now(), "hello" + ), + ChatItem.getSampleData( + 2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + ), + ChatItem.getSampleData( + 3, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + ), + ChatItem.getSampleData( + 4, CIDirection.GroupSnd(), Clock.System.now(), "hello" + ), + ChatItem.getSampleData( + 5, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + ) + ) + ChatLayout( + user = User.sampleData, + chat = Chat( + chatInfo = ChatInfo.Group.sampleData, + chatItems = chatItems, + chatStats = Chat.ChatStats() + ), + chatItems = chatItems, + msg = remember { mutableStateOf("") }, + quotedItem = remember { mutableStateOf(null) }, + editingItem = remember { mutableStateOf(null) }, + back = {}, + info = {}, + openDirectChat = {}, sendMessage = {}, resetMessage = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index e5cfad3a34..dd61b3515f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -30,7 +30,8 @@ fun ChatItemView( quotedItem: MutableState, editingItem: MutableState, cxt: Context, - uriHandler: UriHandler? = null + uriHandler: UriHandler? = null, + showMember: Boolean = false, ) { val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart @@ -38,18 +39,14 @@ fun ChatItemView( Box( modifier = Modifier .padding(bottom = 4.dp) - .fillMaxWidth() - .padding( - start = if (sent) 86.dp else 16.dp, - end = if (sent) 16.dp else 86.dp, - ), + .fillMaxWidth(), contentAlignment = alignment, ) { Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) { if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) } else { - FramedItemView(user, cItem, uriHandler) + FramedItemView(user, cItem, uriHandler, showMember = showMember) } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { ItemAction("Reply", Icons.Outlined.Reply, onClick = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index d5b2d9c24b..302f3ee1dc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -23,7 +23,7 @@ val SentQuoteColorLight = Color(0x2545B8FF) val ReceivedQuoteColorLight = Color(0x25B1B0B5) @Composable -fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { +fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false) { val sent = ci.chatDir.sent Surface( shape = RoundedCornerShape(18.dp), @@ -58,7 +58,7 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { } } else { MarkdownText( - ci.content, ci.formattedText, ci.memberDisplayName, + ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true ) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c0713686c3..550dff5398 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -164,6 +164,14 @@ final class ChatModel: ObservableObject { } } + func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { + if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 { + return chatItems[i - 1] + } else { + return nil + } + } + func popChat(_ id: String) { if let i = getChatIndex(id) { popChat_(i) @@ -519,6 +527,16 @@ struct GroupMember: Decodable { var memberContactId: Int64? // var activeConn: Connection? + var directChatId: ChatId? { + get { + if let chatId = memberContactId { + return "@\(chatId)" + } else { + return nil + } + } + } + static let sampleData = GroupMember( groupMemberId: 1, memberId: "abcd", diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index b50abd392c..d011edc787 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -16,6 +16,7 @@ private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, o struct FramedItemView: View { @Environment(\.colorScheme) var colorScheme var chatItem: ChatItem + var showMember = false @State var msgWidth: CGFloat = 0 var body: some View { @@ -53,7 +54,7 @@ struct FramedItemView: View { MsgContentView( content: chatItem.content, formattedText: chatItem.formattedText, - sender: chatItem.memberDisplayName, + sender: showMember ? chatItem.memberDisplayName : nil, metaText: chatItem.timestampText, edited: chatItem.meta.itemEdited ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 9ffea020a3..73a5b9f855 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -16,7 +16,7 @@ struct MsgContentView: View { var formattedText: [FormattedText]? = nil var sender: String? = nil var metaText: Text? = nil - var edited: Bool = false + var edited = false var body: some View { let v = messageText(content, formattedText, sender) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index c778a6b08b..037366156f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -10,12 +10,13 @@ import SwiftUI struct ChatItemView: View { var chatItem: ChatItem + var showMember = false var body: some View { if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) { EmojiItemView(chatItem: chatItem) } else { - FramedItemView(chatItem: chatItem) + FramedItemView(chatItem: chatItem, showMember: showMember) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index c52a2e2b04..17c8b7a555 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -8,6 +8,8 @@ import SwiftUI +private let memberImageSize: CGFloat = 34 + struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @@ -24,39 +26,32 @@ struct ChatView: View { return VStack { GeometryReader { g in - let maxWidth = g.size.width * 0.78 + let maxWidth = + cInfo.chatType == .group + ? (g.size.width - 28) * 0.84 - 42 + : (g.size.width - 32) * 0.84 ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 5) { ForEach(chatModel.chatItems) { ci in - let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - ChatItemView(chatItem: ci) - .contextMenu { - Button { - withAnimation { - editingItem = nil - quotedItem = ci - } - } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } - Button { - showShareSheet(items: [ci.content.text]) - } label: { Label("Share", systemImage: "square.and.arrow.up") } - Button { - UIPasteboard.general.string = ci.content.text - } label: { Label("Copy", systemImage: "doc.on.doc") } -// if (ci.chatDir.sent && ci.meta.editable) { -// Button { -// withAnimation { -// quotedItem = nil -// editingItem = ci -// message = ci.content.text -// } -// } label: { Label("Edit", systemImage: "square.and.pencil") } -// } + if case let .groupRcv(member) = ci.chatDir { + let prevItem = chatModel.getPrevChatItem(ci) + HStack(alignment: .top, spacing: 0) { + let showMember = showMemberImage(member, prevItem) + if showMember { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + } else { + Rectangle().fill(.clear) + .frame(width: memberImageSize, height: memberImageSize) + } + chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8) } - .padding(.horizontal) - .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) - .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, maxWidth).padding(.horizontal) + } } .onAppear { DispatchQueue.main.async { @@ -119,6 +114,44 @@ struct ChatView: View { .navigationBarBackButtonHidden(true) } + private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { + let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading + return ChatItemView(chatItem: ci, showMember: showMember) + .contextMenu { + Button { + withAnimation { + editingItem = nil + quotedItem = ci + } + } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } + Button { + showShareSheet(items: [ci.content.text]) + } label: { Label("Share", systemImage: "square.and.arrow.up") } + Button { + UIPasteboard.general.string = ci.content.text + } label: { Label("Copy", systemImage: "doc.on.doc") } +// if (ci.chatDir.sent && ci.meta.editable) { +// Button { +// withAnimation { +// quotedItem = nil +// editingItem = ci +// message = ci.content.text +// } +// } label: { Label("Edit", systemImage: "square.and.pencil") } +// } + } + .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) + .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) + } + + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { + switch (prevItem?.chatDir) { + case .groupSnd: return true + case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + default: return false + } + } + func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { withAnimation(animation) { scrollToBottom_(proxy) } } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 3c11927c4f..f415fc216e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -253,8 +253,8 @@ msgPreview :: MsgContent -> [StyledString] msgPreview = msgPlain . preview . msgContentText where preview t - | T.length t <= 60 = t - | otherwise = t <> "..." + | T.length t <= 120 = t + | otherwise = T.take 120 t <> "..." viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = msgError $ case err of