2022-01-29 11:10:04 +00:00
//
// C h a t V i e w . s w i f t
// S i m p l e X
//
// C r e a t e d b y E v g e n y P o b e r e z k i n o n 2 7 / 0 1 / 2 0 2 2 .
// C o p y r i g h t © 2 0 2 2 S i m p l e X C h a t . A l l r i g h t s r e s e r v e d .
//
import SwiftUI
2022-05-31 07:55:13 +01:00
import SimpleXChat
2022-08-15 21:07:11 +01:00
import Introspect
2022-01-29 11:10:04 +00:00
2022-03-30 08:57:42 +01:00
private let memberImageSize : CGFloat = 34
2022-01-29 11:10:04 +00:00
struct ChatView : View {
@ EnvironmentObject var chatModel : ChatModel
2022-02-08 09:19:25 +00:00
@ Environment ( \ . colorScheme ) var colorScheme
2022-02-05 20:10:47 +00:00
@ ObservedObject var chat : Chat
2022-07-27 11:16:07 +04:00
@ State private var showChatInfoSheet : Bool = false
@ State private var showAddMembersSheet : Bool = false
2022-05-07 06:40:46 +01:00
@ State private var composeState = ComposeState ( )
@ State private var deletingItem : ChatItem ? = nil
2022-02-11 07:42:00 +00:00
@ FocusState private var keyboardVisible : Bool
2022-03-30 20:37:47 +04:00
@ State private var showDeleteMessage = false
2022-08-02 14:48:31 +04:00
@ State private var connectionStats : ConnectionStats ?
2022-08-15 21:07:11 +01:00
@ State private var tableView : UITableView ?
@ State private var loadingItems = false
@ State private var firstPage = false
2022-08-16 13:13:29 +01:00
@ State private var itemsInView : Set < String > = [ ]
@ State private var scrollProxy : ScrollViewProxy ?
2022-02-01 17:34:06 +00:00
2022-01-29 11:10:04 +00:00
var body : some View {
2022-02-08 09:19:25 +00:00
let cInfo = chat . chatInfo
return VStack {
2022-08-16 13:13:29 +01:00
ZStack ( alignment : . trailing ) {
chatItemsList ( cInfo )
if let proxy = scrollProxy {
floatingButtons ( proxy )
2022-01-29 11:10:04 +00:00
}
}
2022-01-31 21:28:07 +00:00
Spacer ( minLength : 0 )
2022-01-29 23:37:02 +00:00
2022-03-17 09:42:59 +00:00
ComposeView (
2022-04-25 12:44:24 +04:00
chat : chat ,
composeState : $ composeState ,
keyboardVisible : $ keyboardVisible
2022-02-11 07:42:00 +00:00
)
2022-07-19 18:21:15 +04:00
. disabled ( ! chat . chatInfo . sendMsgEnabled )
2022-01-29 11:10:04 +00:00
}
2022-08-15 21:07:11 +01:00
. padding ( . top , 1 )
2022-02-08 09:19:25 +00:00
. navigationTitle ( cInfo . chatViewName )
2022-02-05 20:10:47 +00:00
. navigationBarTitleDisplayMode ( . inline )
2022-02-01 17:34:06 +00:00
. toolbar {
2022-02-02 16:46:05 +00:00
ToolbarItem ( placement : . navigationBarLeading ) {
2022-08-15 21:07:11 +01:00
Button {
chatModel . chatId = nil
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.35 ) {
if chatModel . chatId = = nil {
chatModel . reversedChatItems = [ ]
}
}
} label : {
2022-02-04 16:31:08 +00:00
HStack ( spacing : 4 ) {
Image ( systemName : " chevron.backward " )
2022-04-16 09:37:01 +01:00
Text ( " Chats " , comment : " back button to return to chats list " )
2022-02-04 16:31:08 +00:00
}
2022-02-02 12:51:39 +00:00
}
2022-02-01 17:34:06 +00:00
}
2022-02-05 20:10:47 +00:00
ToolbarItem ( placement : . principal ) {
Button {
2022-08-02 14:48:31 +04:00
if case . direct = cInfo {
Task {
do {
let stats = try await apiContactInfo ( contactId : chat . chatInfo . apiId )
await MainActor . run { connectionStats = stats }
} catch let error {
logger . error ( " apiContactInfo error: \( responseError ( error ) ) " )
}
await MainActor . run { showChatInfoSheet = true }
}
2022-08-09 13:43:19 +04:00
} else if case let . group ( groupInfo ) = cInfo {
Task {
let groupMembers = await apiListMembers ( groupInfo . groupId )
await MainActor . run {
ChatModel . shared . groupMembers = groupMembers
showChatInfoSheet = true
}
}
2022-08-02 14:48:31 +04:00
}
2022-02-05 20:10:47 +00:00
} label : {
2022-02-12 15:59:43 +00:00
ChatInfoToolbar ( chat : chat )
2022-02-05 20:10:47 +00:00
}
2022-07-27 11:16:07 +04:00
. sheet ( isPresented : $ showChatInfoSheet ) {
2022-07-30 13:03:44 +01:00
switch cInfo {
case . direct :
2022-08-02 14:48:31 +04:00
ChatInfoView ( chat : chat , connectionStats : connectionStats )
2022-07-30 13:03:44 +01:00
case let . group ( groupInfo ) :
GroupChatInfoView ( chat : chat , groupInfo : groupInfo )
default :
EmptyView ( )
2022-07-14 16:40:32 +04:00
}
2022-02-05 20:10:47 +00:00
}
}
2022-05-24 19:34:27 +01:00
ToolbarItem ( placement : . navigationBarTrailing ) {
2022-07-30 13:03:44 +01:00
switch cInfo {
case let . direct ( contact ) :
2022-05-24 19:34:27 +01:00
HStack {
callButton ( contact , . audio , imageName : " phone " )
callButton ( contact , . video , imageName : " video " )
}
2022-07-30 13:03:44 +01:00
case let . group ( groupInfo ) :
if groupInfo . canAddMembers {
addMembersButton ( )
. sheet ( isPresented : $ showAddMembersSheet ) {
2022-08-09 13:43:19 +04:00
AddGroupMembersView ( chat : chat , groupInfo : groupInfo )
2022-07-30 13:03:44 +01:00
}
}
default :
EmptyView ( )
2022-05-24 19:34:27 +01:00
}
}
2022-02-01 17:34:06 +00:00
}
. navigationBarBackButtonHidden ( true )
2022-01-29 11:10:04 +00:00
}
2022-01-29 23:37:02 +00:00
2022-08-16 13:13:29 +01:00
private func chatItemsList ( _ cInfo : ChatInfo ) -> some View {
GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
let maxWidth =
cInfo . chatType = = . group
? ( g . size . width - 28 ) * 0.84 - 42
: ( g . size . width - 32 ) * 0.84
LazyVStack ( spacing : 5 ) {
ForEach ( chatModel . reversedChatItems , id : \ . viewId ) { ci in
chatItemView ( ci , maxWidth )
. scaleEffect ( x : 1 , y : - 1 , anchor : . center )
. onAppear {
itemsInView . insert ( ci . viewId )
loadChatItems ( cInfo , ci , proxy )
if ci . isRcvNew ( ) {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.75 ) {
if chatModel . chatId = = cInfo . id && itemsInView . contains ( ci . viewId ) {
Task {
await apiMarkChatItemRead ( cInfo , ci )
NtfManager . shared . decNtfBadgeCount ( )
}
}
}
}
}
. onDisappear {
itemsInView . remove ( ci . viewId )
}
}
}
}
. onAppear {
scrollProxy = proxy
}
. onTapGesture { hideKeyboard ( ) }
}
}
. scaleEffect ( x : 1 , y : - 1 , anchor : . center )
}
private func floatingButtons ( _ proxy : ScrollViewProxy ) -> some View {
let counts = chatModel . unreadChatItemCounts ( itemsInView : itemsInView )
return VStack {
let unreadAbove = chat . chatStats . unreadCount - counts . unreadBelow
if unreadAbove > 0 {
circleButton {
unreadCountText ( unreadAbove )
. font ( . callout )
. foregroundColor ( . accentColor )
}
. onTapGesture { scrollUp ( proxy ) }
. contextMenu {
Button {
if let ci = chatModel . topItemInView ( itemsInView : itemsInView ) {
Task {
await markChatRead ( chat , aboveItem : ci )
}
}
} label : {
Label ( " Mark read " , systemImage : " checkmark " )
}
}
}
Spacer ( )
if counts . unreadBelow > 0 {
circleButton {
unreadCountText ( counts . unreadBelow )
. font ( . callout )
. foregroundColor ( . accentColor )
}
. onTapGesture { scrollToBottom ( proxy ) }
} else if counts . totalBelow > 16 {
circleButton {
Image ( systemName : " chevron.down " )
. foregroundColor ( . accentColor )
}
. onTapGesture { scrollToBottom ( proxy ) }
}
}
. padding ( )
}
private func circleButton < Content : View > ( _ content : @ escaping ( ) -> Content ) -> some View {
ZStack {
Circle ( )
. foregroundColor ( Color ( uiColor : . tertiarySystemGroupedBackground ) )
. frame ( width : 44 , height : 44 )
content ( )
}
}
2022-05-07 06:40:46 +01:00
private func callButton ( _ contact : Contact , _ media : CallMediaType , imageName : String ) -> some View {
Button {
2022-05-24 19:34:27 +01:00
CallController . shared . startCall ( contact , media )
2022-05-07 06:40:46 +01:00
} label : {
Image ( systemName : imageName )
}
}
2022-07-26 12:33:10 +04:00
private func addMembersButton ( ) -> some View {
2022-07-26 10:55:58 +04:00
Button {
2022-07-30 13:03:44 +01:00
if case let . group ( gInfo ) = chat . chatInfo {
Task {
2022-08-09 13:43:19 +04:00
let groupMembers = await apiListMembers ( gInfo . groupId )
2022-07-30 13:03:44 +01:00
await MainActor . run {
2022-08-09 13:43:19 +04:00
ChatModel . shared . groupMembers = groupMembers
2022-07-30 13:03:44 +01:00
showAddMembersSheet = true
}
}
}
2022-07-26 10:55:58 +04:00
} label : {
Image ( systemName : " person.crop.circle.badge.plus " )
}
}
2022-08-15 21:07:11 +01:00
private func loadChatItems ( _ cInfo : ChatInfo , _ ci : ChatItem , _ proxy : ScrollViewProxy ) {
if let firstItem = chatModel . reversedChatItems . last , firstItem . id = = ci . id {
2022-08-16 13:13:29 +01:00
if loadingItems || firstPage { return }
2022-08-15 21:07:11 +01:00
loadingItems = true
Task {
do {
let items = try await apiGetChatItems (
type : cInfo . chatType ,
id : cInfo . apiId ,
pagination : . before ( chatItemId : firstItem . id , count : 50 )
)
await MainActor . run {
if items . count = = 0 {
firstPage = true
} else {
chatModel . reversedChatItems . append ( contentsOf : items . reversed ( ) )
}
loadingItems = false
}
} catch let error {
logger . error ( " apiGetChat error: \( responseError ( error ) ) " )
await MainActor . run { loadingItems = false }
}
}
}
}
@ ViewBuilder private func chatItemView ( _ ci : ChatItem , _ maxWidth : CGFloat ) -> some View {
if case let . groupRcv ( member ) = ci . chatDir {
let prevItem = chatModel . getPrevChatItem ( ci )
HStack ( alignment : . top , spacing : 0 ) {
let showMember = prevItem = = nil || 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 ( . trailing )
. padding ( . leading , 12 )
} else {
chatItemWithMenu ( ci , maxWidth ) . padding ( . horizontal )
}
}
2022-03-30 08:57:42 +01:00
private func chatItemWithMenu ( _ ci : ChatItem , _ maxWidth : CGFloat , showMember : Bool = false ) -> some View {
let alignment : Alignment = ci . chatDir . sent ? . trailing : . leading
2022-08-15 21:07:11 +01:00
var menu : [ UIAction ] = [ ]
if ci . isMsgContent ( ) {
menu . append ( contentsOf : [
UIAction (
title : NSLocalizedString ( " Reply " , comment : " chat item action " ) ,
image : UIImage ( systemName : " arrowshape.turn.up.left " )
) { _ in
withAnimation {
if composeState . editing ( ) {
composeState = ComposeState ( contextItem : . quotedItem ( chatItem : ci ) )
2022-04-19 12:29:03 +04:00
} else {
2022-08-15 21:07:11 +01:00
composeState = composeState . copy ( contextItem : . quotedItem ( chatItem : ci ) )
2022-04-19 12:29:03 +04:00
}
2022-05-04 09:10:36 +04:00
}
2022-08-15 21:07:11 +01:00
} ,
UIAction (
title : NSLocalizedString ( " Share " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.arrow.up " )
) { _ in
var shareItems : [ Any ] = [ ci . content . text ]
if case . image = ci . content . msgContent , let image = getLoadedImage ( ci . file ) {
shareItems . append ( image )
}
showShareSheet ( items : shareItems )
} ,
UIAction (
title : NSLocalizedString ( " Copy " , comment : " chat item action " ) ,
image : UIImage ( systemName : " doc.on.doc " )
) { _ in
if case let . image ( text , _ ) = ci . content . msgContent ,
text = = " " ,
let image = getLoadedImage ( ci . file ) {
UIPasteboard . general . image = image
} else {
UIPasteboard . general . string = ci . content . text
2022-03-30 08:57:42 +01:00
}
2022-03-30 20:37:47 +04:00
}
2022-08-15 21:07:11 +01:00
] )
if case . image = ci . content . msgContent ,
let image = getLoadedImage ( ci . file ) {
menu . append (
UIAction (
title : NSLocalizedString ( " Save " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.arrow.down " )
) { _ in
UIImageWriteToSavedPhotosAlbum ( image , nil , nil , nil )
}
)
}
if ci . meta . editable {
menu . append (
UIAction (
title : NSLocalizedString ( " Edit " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.pencil " )
) { _ in
withAnimation {
composeState = ComposeState ( editingItem : ci )
}
}
)
2022-03-30 20:37:47 +04:00
}
2022-08-15 21:07:11 +01:00
menu . append (
UIAction (
title : NSLocalizedString ( " Delete " , comment : " chat item action " ) ,
image : UIImage ( systemName : " trash " ) ,
attributes : [ . destructive ]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
)
} else if ci . isDeletedContent ( ) {
menu . append (
UIAction (
title : NSLocalizedString ( " Delete " , comment : " chat item action " ) ,
image : UIImage ( systemName : " trash " ) ,
attributes : [ . destructive ]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
)
}
return ChatItemView ( chatInfo : chat . chatInfo , chatItem : ci , showMember : showMember , maxWidth : maxWidth )
. uiKitContextMenu ( actions : menu )
2022-03-30 20:37:47 +04:00
. confirmationDialog ( " Delete message? " , isPresented : $ showDeleteMessage , titleVisibility : . visible ) {
Button ( " Delete for me " , role : . destructive ) {
deleteMessage ( . cidmInternal )
}
2022-08-15 21:07:11 +01:00
if let di = deletingItem , di . meta . editable {
Button ( " Delete for everyone " , role : . destructive ) {
deleteMessage ( . cidmBroadcast )
2022-04-19 13:24:26 +04:00
}
}
2022-03-30 08:57:42 +01:00
}
. frame ( maxWidth : maxWidth , maxHeight : . infinity , alignment : alignment )
. frame ( minWidth : 0 , maxWidth : . infinity , alignment : alignment )
}
2022-08-15 21:07:11 +01:00
2022-03-30 08:57:42 +01:00
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
}
}
2022-08-16 13:13:29 +01:00
private func scrollToBottom ( _ proxy : ScrollViewProxy ) {
if let ci = chatModel . reversedChatItems . first {
withAnimation { proxy . scrollTo ( ci . viewId , anchor : . top ) }
2022-02-12 15:59:43 +00:00
}
}
2022-08-16 13:13:29 +01:00
private func scrollUp ( _ proxy : ScrollViewProxy ) {
if let ci = chatModel . topItemInView ( itemsInView : itemsInView ) {
withAnimation { proxy . scrollTo ( ci . viewId , anchor : . top ) }
2022-02-12 15:59:43 +00:00
}
}
2022-08-16 13:13:29 +01:00
private func deleteMessage ( _ mode : CIDeleteMode ) {
2022-03-30 20:37:47 +04:00
logger . debug ( " ChatView deleteMessage " )
Task {
logger . debug ( " ChatView deleteMessage: in Task " )
do {
if let di = deletingItem {
let toItem = try await apiDeleteChatItem (
type : chat . chatInfo . chatType ,
id : chat . chatInfo . apiId ,
itemId : di . id ,
mode : mode
)
DispatchQueue . main . async {
deletingItem = nil
let _ = chatModel . removeChatItem ( chat . chatInfo , toItem )
}
}
} catch {
logger . error ( " ChatView.deleteMessage error: \( error . localizedDescription ) " )
}
}
}
2022-01-29 11:10:04 +00:00
}
2022-01-29 23:37:02 +00:00
struct ChatView_Previews : PreviewProvider {
static var previews : some View {
let chatModel = ChatModel ( )
2022-02-02 12:51:39 +00:00
chatModel . chatId = " @1 "
2022-08-15 21:07:11 +01:00
chatModel . reversedChatItems = [
2022-02-08 09:19:25 +00:00
ChatItem . getSample ( 1 , . directSnd , . now , " hello " ) ,
ChatItem . getSample ( 2 , . directRcv , . now , " hi " ) ,
ChatItem . getSample ( 3 , . directRcv , . now , " hi there " ) ,
2022-03-30 20:37:47 +04:00
ChatItem . getDeletedContentSample ( 4 ) ,
ChatItem . getSample ( 5 , . directRcv , . now , " hello again " ) ,
ChatItem . getSample ( 6 , . directSnd , . now , " hi there!!! " ) ,
ChatItem . getSample ( 7 , . directSnd , . now , " how are you? " ) ,
ChatItem . getSample ( 8 , . directSnd , . now , " 👍👍👍👍 " ) ,
ChatItem . getSample ( 9 , . directSnd , . now , " Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. " )
2022-01-29 23:37:02 +00:00
]
2022-06-03 13:19:41 +04:00
@ State var showChatInfo = false
2022-07-26 10:55:58 +04:00
return ChatView ( chat : Chat ( chatInfo : ChatInfo . sampleData . direct , chatItems : [ ] ) )
2022-01-29 23:37:02 +00:00
. environmentObject ( chatModel )
}
}