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-12-24 00:22:12 +03:00
import SwiftyGif
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
2023-01-23 18:17:33 +00:00
@ Environment ( \ . dismiss ) var dismiss
2023-02-06 19:33:45 +03:00
@ Environment ( \ . presentationMode ) var presentationMode
2022-12-30 21:47:11 +04:00
@ State @ 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-23 18:18:12 +04:00
@ State private var customUserProfile : Profile ?
2022-12-12 08:59:35 +00:00
@ State private var connectionCode : String ?
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-08-17 11:43:18 +01:00
@ State private var searchMode = false
@ State private var searchText : String = " "
@ FocusState private var searchFocussed
2022-08-26 17:27:38 +04:00
// o p e n i n g G r o u p M e m b e r I n f o V i e w o n m e m b e r i c o n
@ State private var selectedMember : GroupMember ? = nil
2022-12-12 08:59:35 +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
2022-08-17 11:43:18 +01:00
return VStack ( spacing : 0 ) {
if searchMode {
searchToolbar ( )
Divider ( )
}
2022-08-16 13:13:29 +01:00
ZStack ( alignment : . trailing ) {
2022-08-17 11:43:18 +01:00
chatItemsList ( )
2022-08-16 13:13:29 +01:00
if let proxy = scrollProxy {
floatingButtons ( proxy )
2022-01-29 11:10:04 +00:00
}
}
2022-12-03 15:53:46 +04:00
2022-01-31 21:28:07 +00:00
Spacer ( minLength : 0 )
2023-01-27 22:09:39 +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-08-17 11:43:18 +01:00
. disabled ( ! cInfo . 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-10-21 12:32:11 +01:00
. onAppear {
2023-01-25 08:35:25 +00:00
if chatModel . draftChatId = = cInfo . id , let draft = chatModel . draft {
composeState = draft
}
2022-10-21 12:32:11 +01:00
if chat . chatStats . unreadChat {
Task {
await markChatUnread ( chat , unreadChat : false )
}
}
}
2023-01-24 19:24:46 +00:00
. onChange ( of : chatModel . chatId ) { _ in
if chatModel . chatId = = nil { dismiss ( ) }
}
. onDisappear {
2023-04-06 20:26:48 +03:00
VideoPlayerView . players . removeAll ( )
2023-02-06 19:33:45 +03:00
if chatModel . chatId = = cInfo . id && ! presentationMode . wrappedValue . isPresented {
2023-01-24 19:24:46 +00:00
chatModel . chatId = nil
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.35 ) {
if chatModel . chatId = = nil {
chatModel . reversedChatItems = [ ]
2022-08-29 14:08:46 +01:00
}
2022-02-02 12:51:39 +00:00
}
2022-02-01 17:34:06 +00:00
}
2023-01-24 19:24:46 +00:00
}
. toolbar {
2022-02-05 20:10:47 +00:00
ToolbarItem ( placement : . principal ) {
2022-08-26 17:27:38 +04:00
if case let . direct ( contact ) = cInfo {
Button {
2022-08-02 14:48:31 +04:00
Task {
do {
2022-12-12 08:59:35 +00:00
let ( stats , profile ) = try await apiContactInfo ( chat . chatInfo . apiId )
let ( ct , code ) = try await apiGetContactCode ( chat . chatInfo . apiId )
2022-08-23 18:18:12 +04:00
await MainActor . run {
connectionStats = stats
customUserProfile = profile
2022-12-12 08:59:35 +00:00
connectionCode = code
if contact . activeConn . connectionCode != ct . activeConn . connectionCode {
chat . chatInfo = . direct ( contact : ct )
}
2022-08-23 18:18:12 +04:00
}
2022-08-02 14:48:31 +04:00
} catch let error {
2022-12-12 08:59:35 +00:00
logger . error ( " apiContactInfo or apiGetContactCode error: \( responseError ( error ) ) " )
2022-08-02 14:48:31 +04:00
}
await MainActor . run { showChatInfoSheet = true }
}
2022-08-26 17:27:38 +04:00
} label : {
ChatInfoToolbar ( chat : chat )
}
2022-12-12 08:59:35 +00:00
. sheet ( isPresented : $ showChatInfoSheet , onDismiss : {
2022-08-26 17:27:38 +04:00
connectionStats = nil
customUserProfile = nil
2022-12-12 08:59:35 +00:00
connectionCode = nil
2022-08-26 17:27:38 +04:00
} ) {
2022-12-12 08:59:35 +00:00
ChatInfoView ( chat : chat , contact : contact , connectionStats : $ connectionStats , customUserProfile : $ customUserProfile , localAlias : chat . chatInfo . localAlias , connectionCode : $ connectionCode )
2022-08-26 17:27:38 +04:00
}
} else if case let . group ( groupInfo ) = cInfo {
Button {
2022-08-09 13:43:19 +04:00
Task {
let groupMembers = await apiListMembers ( groupInfo . groupId )
await MainActor . run {
ChatModel . shared . groupMembers = groupMembers
showChatInfoSheet = true
}
}
2022-08-26 17:27:38 +04:00
} label : {
ChatInfoToolbar ( chat : chat )
2022-08-02 14:48:31 +04:00
}
2022-11-25 14:31:37 +00:00
. appSheet ( isPresented : $ showChatInfoSheet ) {
2022-07-30 13:03:44 +01:00
GroupChatInfoView ( chat : chat , groupInfo : groupInfo )
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 {
2023-04-18 10:29:49 +02:00
if contact . allowsFeature ( . calls ) {
callButton ( contact , . audio , imageName : " phone " )
}
2022-08-17 11:43:18 +01:00
Menu {
2023-04-18 10:29:49 +02:00
if contact . allowsFeature ( . calls ) {
Button {
CallController . shared . startCall ( contact , . video )
} label : {
Label ( " Video call " , systemImage : " video " )
}
2022-08-17 11:43:18 +01:00
}
searchButton ( )
2022-08-20 12:47:48 +01:00
toggleNtfsButton ( chat )
2022-08-17 11:43:18 +01:00
} label : {
Image ( systemName : " ellipsis " )
}
2022-05-24 19:34:27 +01:00
}
2022-07-30 13:03:44 +01:00
case let . group ( groupInfo ) :
2022-08-17 11:43:18 +01:00
HStack {
if groupInfo . canAddMembers {
2022-08-29 14:47:29 +04:00
if ( chat . chatInfo . incognito ) {
Image ( systemName : " person.crop.circle.badge.plus " )
. foregroundColor ( Color ( uiColor : . tertiaryLabel ) )
. onTapGesture { AlertManager . shared . showAlert ( cantInviteIncognitoAlert ( ) ) }
} else {
addMembersButton ( )
2022-11-25 14:31:37 +00:00
. appSheet ( isPresented : $ showAddMembersSheet ) {
2022-08-29 14:47:29 +04:00
AddGroupMembersView ( chat : chat , groupInfo : groupInfo )
}
}
2022-08-17 11:43:18 +01:00
}
Menu {
searchButton ( )
2022-08-20 12:47:48 +01:00
toggleNtfsButton ( chat )
2022-08-17 11:43:18 +01:00
} label : {
Image ( systemName : " ellipsis " )
}
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
}
2022-01-29 11:10:04 +00:00
}
2023-01-25 08:35:25 +00:00
2022-08-17 11:43:18 +01:00
private func searchToolbar ( ) -> some View {
HStack {
HStack {
Image ( systemName : " magnifyingglass " )
TextField ( " Search " , text : $ searchText )
2022-12-03 15:53:46 +04:00
. focused ( $ searchFocussed )
. foregroundColor ( . primary )
. frame ( maxWidth : . infinity )
2022-08-17 11:43:18 +01:00
Button {
searchText = " "
} label : {
Image ( systemName : " xmark.circle.fill " ) . opacity ( searchText = = " " ? 0 : 1 )
}
}
. padding ( EdgeInsets ( top : 8 , leading : 6 , bottom : 8 , trailing : 6 ) )
. foregroundColor ( . secondary )
. background ( Color ( . secondarySystemBackground ) )
. cornerRadius ( 10.0 )
2022-12-03 15:53:46 +04:00
2022-08-17 11:43:18 +01:00
Button ( " Cancel " ) {
searchText = " "
searchMode = false
searchFocussed = false
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.35 ) {
chatModel . reversedChatItems = [ ]
loadChat ( chat : chat )
}
}
}
. padding ( . horizontal )
. padding ( . vertical , 8 )
}
2022-12-03 15:53:46 +04:00
2023-05-14 20:07:34 +03:00
private func voiceWithoutFrame ( _ ci : ChatItem ) -> Bool {
ci . content . msgContent ? . isVoice = = true && ci . content . text . count = = 0 && ci . quotedItem = = nil
}
2022-08-17 11:43:18 +01:00
private func chatItemsList ( ) -> some View {
let cInfo = chat . chatInfo
return GeometryReader { g in
2022-08-16 13:13:29 +01:00
ScrollViewReader { proxy in
ScrollView {
LazyVStack ( spacing : 5 ) {
ForEach ( chatModel . reversedChatItems , id : \ . viewId ) { ci in
2023-05-14 20:07:34 +03:00
let voiceNoFrame = voiceWithoutFrame ( ci )
let maxWidth = cInfo . chatType = = . group
? voiceNoFrame
? ( g . size . width - 28 ) - 42
: ( g . size . width - 28 ) * 0.84 - 42
: voiceNoFrame
? ( g . size . width - 32 )
: ( g . size . width - 32 ) * 0.84
2022-08-16 13:13:29 +01:00
chatItemView ( ci , maxWidth )
2022-12-03 15:53:46 +04:00
. scaleEffect ( x : 1 , y : - 1 , anchor : . center )
. onAppear {
itemsInView . insert ( ci . viewId )
loadChatItems ( cInfo , ci , proxy )
2022-12-07 20:46:38 +04:00
if ci . isRcvNew {
2022-12-03 15:53:46 +04:00
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.6 ) {
if chatModel . chatId = = cInfo . id && itemsInView . contains ( ci . viewId ) {
Task {
await apiMarkChatItemRead ( cInfo , ci )
}
2022-08-16 13:13:29 +01:00
}
}
}
}
2022-12-03 15:53:46 +04:00
. onDisappear {
itemsInView . remove ( ci . viewId )
}
2022-08-16 13:13:29 +01:00
}
}
}
. onAppear {
scrollProxy = proxy
}
. onTapGesture { hideKeyboard ( ) }
2022-08-17 11:43:18 +01:00
. onChange ( of : searchText ) { _ in
loadChat ( chat : chat , search : searchText )
}
2022-08-29 14:08:46 +01:00
. onChange ( of : chatModel . chatId ) { _ in
2022-12-30 21:47:11 +04:00
if let chatId = chatModel . chatId , let c = chatModel . getChat ( chatId ) {
chat = c
2022-08-29 14:08:46 +01:00
showChatInfoSheet = false
2022-12-30 21:47:11 +04:00
loadChat ( chat : c )
2022-08-29 14:08:46 +01:00
DispatchQueue . main . async {
scrollToBottom ( proxy )
}
}
}
2022-08-16 13:13:29 +01:00
}
}
. scaleEffect ( x : 1 , y : - 1 , anchor : . center )
}
2022-12-03 15:53:46 +04:00
2022-08-16 13:13:29 +01:00
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 ( )
}
2022-12-03 15:53:46 +04:00
2022-08-16 13:13:29 +01:00
private func circleButton < Content : View > ( _ content : @ escaping ( ) -> Content ) -> some View {
ZStack {
Circle ( )
. foregroundColor ( Color ( uiColor : . tertiarySystemGroupedBackground ) )
. frame ( width : 44 , height : 44 )
content ( )
}
}
2022-12-03 15:53:46 +04:00
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-12-03 15:53:46 +04:00
2022-08-17 11:43:18 +01:00
private func searchButton ( ) -> some View {
Button {
searchMode = true
searchFocussed = true
searchText = " "
} label : {
Label ( " Search " , systemImage : " magnifyingglass " )
}
}
2022-12-03 15:53:46 +04:00
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-12-03 15:53:46 +04:00
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 ,
2022-08-17 11:43:18 +01:00
pagination : . before ( chatItemId : firstItem . id , count : 50 ) ,
search : searchText
2022-08-15 21:07:11 +01:00
)
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 }
}
}
}
}
2022-12-03 15:53:46 +04:00
2022-08-15 21:07:11 +01:00
@ ViewBuilder private func chatItemView ( _ ci : ChatItem , _ maxWidth : CGFloat ) -> some View {
2022-08-26 17:27:38 +04:00
if case let . groupRcv ( member ) = ci . chatDir ,
case let . group ( groupInfo ) = chat . chatInfo {
2022-08-15 21:07:11 +01:00
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 )
2022-12-12 08:59:35 +00:00
. onTapGesture { selectedMember = member }
. appSheet ( item : $ selectedMember ) { member in
GroupMemberInfoView ( groupInfo : groupInfo , member : member , navigation : true )
2022-08-26 17:27:38 +04:00
}
2022-08-15 21:07:11 +01:00
} else {
Rectangle ( ) . fill ( . clear )
. frame ( width : memberImageSize , height : memberImageSize )
}
2022-12-03 15:40:31 +04:00
ChatItemWithMenu (
ci : ci ,
showMember : showMember ,
maxWidth : maxWidth ,
scrollProxy : scrollProxy ,
deleteMessage : deleteMessage ,
deletingItem : $ deletingItem ,
composeState : $ composeState ,
showDeleteMessage : $ showDeleteMessage
2022-12-21 12:59:45 +00:00
)
. padding ( . leading , 8 )
. environmentObject ( chat )
2022-08-15 21:07:11 +01:00
}
. padding ( . trailing )
. padding ( . leading , 12 )
} else {
2022-12-03 15:40:31 +04:00
ChatItemWithMenu (
ci : ci ,
maxWidth : maxWidth ,
scrollProxy : scrollProxy ,
deleteMessage : deleteMessage ,
deletingItem : $ deletingItem ,
composeState : $ composeState ,
showDeleteMessage : $ showDeleteMessage
2022-12-21 12:59:45 +00:00
)
. padding ( . horizontal )
. environmentObject ( chat )
2022-08-15 21:07:11 +01:00
}
}
2022-12-03 15:53:46 +04:00
2022-12-03 15:40:31 +04:00
private struct ChatItemWithMenu : View {
2022-12-21 12:59:45 +00:00
@ EnvironmentObject var chat : Chat
2023-05-15 12:28:53 +02:00
@ Environment ( \ . colorScheme ) var colorScheme
2022-12-03 15:40:31 +04:00
var ci : ChatItem
var showMember : Bool = false
var maxWidth : CGFloat
var scrollProxy : ScrollViewProxy ?
var deleteMessage : ( CIDeleteMode ) -> Void
@ Binding var deletingItem : ChatItem ?
@ Binding var composeState : ComposeState
@ Binding var showDeleteMessage : Bool
2022-12-03 15:53:46 +04:00
2022-12-03 15:40:31 +04:00
@ State private var revealed = false
2023-05-09 20:43:21 +04:00
@ State private var showChatItemInfoSheet : Bool = false
@ State private var chatItemInfo : ChatItemInfo ?
2022-12-03 15:53:46 +04:00
2023-05-14 20:07:34 +03:00
@ State private var allowMenu : Bool = true
@ State private var audioPlayer : AudioPlayer ?
@ State private var playbackState : VoiceMessagePlaybackState = . noPlayback
@ State private var playbackTime : TimeInterval ?
2022-12-03 15:40:31 +04:00
var body : some View {
let alignment : Alignment = ci . chatDir . sent ? . trailing : . leading
2023-01-11 13:29:09 +00:00
let uiMenu : Binding < UIMenu > = Binding (
get : { UIMenu ( title : " " , children : menu ( live : composeState . liveMessage != nil ) ) } ,
set : { _ in }
)
2023-05-14 20:07:34 +03:00
2023-05-16 10:34:25 +02:00
VStack ( alignment : alignment . horizontal , spacing : 3 ) {
2023-05-15 12:28:53 +02:00
ChatItemView ( chatInfo : chat . chatInfo , chatItem : ci , showMember : showMember , maxWidth : maxWidth , scrollProxy : scrollProxy , revealed : $ revealed , allowMenu : $ allowMenu , audioPlayer : $ audioPlayer , playbackState : $ playbackState , playbackTime : $ playbackTime )
. uiKitContextMenu ( menu : uiMenu , allowMenu : $ allowMenu )
2023-05-16 10:34:25 +02:00
if ci . content . msgContent != nil && ci . meta . itemDeleted = = nil && ci . reactions . count > 0 {
chatItemReactions ( )
2023-05-15 12:28:53 +02:00
. padding ( . bottom , 4 )
}
}
2022-12-03 15:40:31 +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
}
2022-12-03 15:40:31 +04:00
if let di = deletingItem , di . meta . editable {
Button ( broadcastDeleteButtonText , role : . destructive ) {
deleteMessage ( . cidmBroadcast )
}
2022-03-30 08:57:42 +01:00
}
2022-03-30 20:37:47 +04:00
}
2022-12-03 15:40:31 +04:00
. frame ( maxWidth : maxWidth , maxHeight : . infinity , alignment : alignment )
. frame ( minWidth : 0 , maxWidth : . infinity , alignment : alignment )
2023-05-14 20:07:34 +03:00
. onDisappear {
if ci . content . msgContent ? . isVoice = = true {
allowMenu = true
audioPlayer ? . stop ( )
playbackState = . noPlayback
playbackTime = TimeInterval ( 0 )
}
}
2023-05-09 20:43:21 +04:00
. sheet ( isPresented : $ showChatItemInfoSheet , onDismiss : {
chatItemInfo = nil
} ) {
ChatItemInfoView ( chatItemSent : ci . chatDir . sent , chatItemInfo : $ chatItemInfo )
}
2022-12-03 15:40:31 +04:00
}
2023-05-15 12:28:53 +02:00
2023-05-16 10:34:25 +02:00
private func chatItemReactions ( ) -> some View {
2023-05-15 12:28:53 +02:00
HStack ( spacing : 4 ) {
2023-05-16 10:34:25 +02:00
ForEach ( ci . reactions , id : \ . reaction ) { r in
let v = HStack ( spacing : 4 ) {
2023-05-15 12:28:53 +02:00
switch r . reaction {
2023-05-16 10:34:25 +02:00
case let . emoji ( emoji ) : Text ( emoji . rawValue ) . font ( . caption )
case . unknown : EmptyView ( )
2023-05-15 12:28:53 +02:00
}
if r . totalReacted > 1 {
2023-05-16 10:34:25 +02:00
Text ( " \( r . totalReacted ) " )
. font ( . caption )
. fontWeight ( r . userReacted ? . bold : . light )
. foregroundColor ( r . userReacted ? . accentColor : . secondary )
2023-05-15 12:28:53 +02:00
}
}
2023-05-16 10:34:25 +02:00
. padding ( . horizontal , 6 )
2023-05-15 12:28:53 +02:00
. padding ( . vertical , 4 )
2023-05-16 10:34:25 +02:00
if chat . chatInfo . featureEnabled ( . reactions ) && ( ci . allowAddReaction || r . userReacted ) {
v . onTapGesture {
2023-05-17 10:31:27 +02:00
setReaction ( add : ! r . userReacted , reaction : r . reaction )
2023-05-16 10:34:25 +02:00
}
} else {
v
}
2023-05-15 12:28:53 +02:00
}
}
}
2023-05-16 10:34:25 +02:00
private func menu ( live : Bool ) -> [ UIMenuElement ] {
var menu : [ UIMenuElement ] = [ ]
2023-02-09 15:10:35 +04:00
if let mc = ci . content . msgContent , ci . meta . itemDeleted = = nil || revealed {
2023-05-18 11:43:44 +02:00
if chat . chatInfo . featureEnabled ( . reactions ) && ci . allowAddReaction ,
2023-05-16 10:34:25 +02:00
let rm = reactionUIMenu ( ) {
menu . append ( rm )
}
2023-02-09 15:10:35 +04:00
if ci . meta . itemDeleted = = nil && ! ci . isLiveDummy && ! live {
2022-12-03 15:53:46 +04:00
menu . append ( replyUIAction ( ) )
2022-11-24 21:18:28 +04:00
}
2022-12-03 15:53:46 +04:00
menu . append ( shareUIAction ( ) )
menu . append ( copyUIAction ( ) )
2022-12-26 18:08:58 +04:00
if let filePath = getLoadedFilePath ( ci . file ) {
2022-12-24 00:22:12 +03:00
if case . image = ci . content . msgContent , let image = getLoadedImage ( ci . file ) {
2022-12-26 18:08:58 +04:00
if image . imageData != nil {
2022-12-24 00:22:12 +03:00
menu . append ( saveFileAction ( filePath ) )
} else {
menu . append ( saveImageAction ( image ) )
}
2022-12-26 18:08:58 +04:00
} else {
2022-12-03 15:53:46 +04:00
menu . append ( saveFileAction ( filePath ) )
2022-12-03 15:40:31 +04:00
}
2022-12-26 18:08:58 +04:00
}
2023-01-11 13:29:09 +00:00
if ci . meta . editable && ! mc . isVoice && ! live {
2022-12-03 15:53:46 +04:00
menu . append ( editAction ( ) )
2022-08-15 21:07:11 +01:00
}
2023-05-09 20:43:21 +04:00
menu . append ( viewInfoUIAction ( ) )
2022-12-03 19:21:47 +00:00
if revealed {
menu . append ( hideUIAction ( ) )
}
2023-03-30 14:10:13 +04:00
if ci . meta . itemDeleted = = nil ,
let file = ci . file ,
2023-04-18 12:48:36 +04:00
let cancelAction = file . cancelAction {
menu . append ( cancelFileUIAction ( file . fileId , cancelAction ) )
2023-03-30 14:10:13 +04:00
}
2023-01-11 13:29:09 +00:00
if ! live || ! ci . meta . isLive {
menu . append ( deleteUIAction ( ) )
}
2023-03-06 21:57:58 +00:00
if let ( groupInfo , _ ) = ci . memberToModerate ( chat . chatInfo ) {
menu . append ( moderateUIAction ( groupInfo ) )
}
2023-02-09 15:10:35 +04:00
} else if ci . meta . itemDeleted != nil {
2023-03-06 21:57:58 +00:00
if ! ci . isDeletedContent {
menu . append ( revealUIAction ( ) )
}
2022-12-03 15:40:31 +04:00
menu . append ( deleteUIAction ( ) )
} else if ci . isDeletedContent {
menu . append ( deleteUIAction ( ) )
}
return menu
}
2022-12-03 15:53:46 +04:00
private func replyUIAction ( ) -> UIAction {
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 ) )
} else {
composeState = composeState . copy ( contextItem : . quotedItem ( chatItem : ci ) )
}
}
}
}
2023-05-16 10:34:25 +02:00
private func reactionUIMenu ( ) -> UIMenu ? {
let rs = MsgReaction . values . compactMap { r in
ci . reactions . contains ( where : { $0 . userReacted && $0 . reaction = = r } )
? nil
2023-05-17 10:31:27 +02:00
: UIAction ( title : r . text ) { _ in setReaction ( add : true , reaction : r ) }
2023-05-16 10:34:25 +02:00
}
if rs . count > 0 {
return UIMenu (
title : NSLocalizedString ( " React... " , comment : " chat item menu " ) ,
2023-05-17 10:31:27 +02:00
image : UIImage ( systemName : " face.smiling " ) ,
2023-05-16 10:34:25 +02:00
children : rs
)
}
return nil
}
2023-05-17 10:31:27 +02:00
private func setReaction ( add : Bool , reaction : MsgReaction ) {
2023-05-16 10:34:25 +02:00
Task {
do {
let chatItem = try await apiChatItemReaction (
type : chat . chatInfo . chatType ,
id : chat . chatInfo . apiId ,
itemId : ci . id ,
2023-05-17 10:31:27 +02:00
add : add ,
reaction : reaction
2023-05-16 10:34:25 +02:00
)
await MainActor . run {
ChatModel . shared . updateChatItem ( chat . chatInfo , chatItem )
}
} catch let error {
logger . error ( " apiChatItemReaction error: \( responseError ( error ) ) " )
}
}
}
2022-12-03 15:53:46 +04:00
private func shareUIAction ( ) -> UIAction {
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 )
}
}
private func copyUIAction ( ) -> UIAction {
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
}
}
}
private func saveImageAction ( _ image : UIImage ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Save " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.arrow.down " )
) { _ in
UIImageWriteToSavedPhotosAlbum ( image , nil , nil , nil )
}
}
private func saveFileAction ( _ filePath : String ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Save " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.arrow.down " )
) { _ in
let fileURL = URL ( fileURLWithPath : filePath )
showShareSheet ( items : [ fileURL ] )
}
}
private func editAction ( ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Edit " , comment : " chat item action " ) ,
image : UIImage ( systemName : " square.and.pencil " )
) { _ in
withAnimation {
composeState = ComposeState ( editingItem : ci )
}
}
}
2022-12-03 19:21:47 +00:00
2023-05-09 20:43:21 +04:00
private func viewInfoUIAction ( ) -> UIAction {
UIAction (
title : NSLocalizedString ( " View details " , comment : " chat item action " ) ,
image : UIImage ( systemName : " info " )
) { _ in
Task {
do {
let ciInfo = try await apiGetChatItemInfo ( itemId : ci . id )
await MainActor . run {
chatItemInfo = ciInfo
}
} catch let error {
logger . error ( " apiGetChatItemInfo error: \( responseError ( error ) ) " )
}
await MainActor . run { showChatItemInfoSheet = true }
}
}
}
2023-04-18 12:48:36 +04:00
private func cancelFileUIAction ( _ fileId : Int64 , _ cancelAction : CancelAction ) -> UIAction {
return UIAction (
title : cancelAction . uiAction ,
image : UIImage ( systemName : " xmark " ) ,
attributes : [ . destructive ]
2023-03-30 14:10:13 +04:00
) { _ in
AlertManager . shared . showAlert ( Alert (
2023-04-18 12:48:36 +04:00
title : Text ( cancelAction . alert . title ) ,
message : Text ( cancelAction . alert . message ) ,
primaryButton : . destructive ( Text ( cancelAction . alert . confirm ) ) {
2023-03-30 14:10:13 +04:00
Task {
if let user = ChatModel . shared . currentUser {
await cancelFile ( user : user , fileId : fileId )
}
}
} ,
secondaryButton : . cancel ( )
) )
}
}
2022-12-03 19:21:47 +00:00
private func hideUIAction ( ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Hide " , comment : " chat item action " ) ,
image : UIImage ( systemName : " eye.slash " )
) { _ in
withAnimation {
revealed = false
}
}
}
2022-12-03 15:53:46 +04:00
2022-12-03 15:40:31 +04:00
private func deleteUIAction ( ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Delete " , comment : " chat item action " ) ,
image : UIImage ( systemName : " trash " ) ,
attributes : [ . destructive ]
) { _ in
showDeleteMessage = true
deletingItem = ci
}
2022-08-15 21:07:11 +01:00
}
2023-03-06 21:57:58 +00:00
private func moderateUIAction ( _ groupInfo : GroupInfo ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Moderate " , comment : " chat item action " ) ,
image : UIImage ( systemName : " flag " ) ,
attributes : [ . destructive ]
) { _ in
AlertManager . shared . showAlert ( Alert (
title : Text ( " Delete member message? " ) ,
message : Text (
groupInfo . fullGroupPreferences . fullDelete . on
? " The message will be deleted for all members. "
: " The message will be marked as moderated for all members. "
) ,
primaryButton : . destructive ( Text ( " Delete " ) ) {
deletingItem = ci
deleteMessage ( . cidmBroadcast )
} ,
secondaryButton : . cancel ( )
) )
}
}
2022-12-03 15:40:31 +04:00
private func revealUIAction ( ) -> UIAction {
UIAction (
title : NSLocalizedString ( " Reveal " , comment : " chat item action " ) ,
image : UIImage ( systemName : " eye " )
) { _ in
withAnimation {
revealed = true
2022-04-19 13:24:26 +04:00
}
2022-03-30 08:57:42 +01:00
}
2022-12-03 15:40:31 +04:00
}
2022-12-03 15:53:46 +04:00
2022-12-03 15:40:31 +04:00
private var broadcastDeleteButtonText : LocalizedStringKey {
2022-12-22 21:01:29 +00:00
chat . chatInfo . featureEnabled ( . fullDelete ) ? " Delete for everyone " : " Mark deleted for everyone "
2022-12-03 15:40:31 +04:00
}
2022-03-30 08:57:42 +01:00
}
2023-05-15 12:28:53 +02: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-12-03 15:53:46 +04:00
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-12-03 15:53:46 +04: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-12-03 15:53:46 +04: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 {
2023-03-06 21:57:58 +00:00
var deletedItem : ChatItem
var toItem : ChatItem ?
2023-03-23 18:47:55 +00:00
if case . cidmBroadcast = mode ,
let ( groupInfo , groupMember ) = di . memberToModerate ( chat . chatInfo ) {
2023-03-06 21:57:58 +00:00
( deletedItem , toItem ) = try await apiDeleteMemberChatItem (
groupId : groupInfo . apiId ,
groupMemberId : groupMember . groupMemberId ,
itemId : di . id
)
} else {
( deletedItem , toItem ) = try await apiDeleteChatItem (
type : chat . chatInfo . chatType ,
id : chat . chatInfo . apiId ,
itemId : di . id ,
mode : mode
)
}
2022-03-30 20:37:47 +04:00
DispatchQueue . main . async {
deletingItem = nil
2022-12-03 15:40:31 +04:00
if let toItem = toItem {
_ = chatModel . upsertChatItem ( chat . chatInfo , toItem )
} else {
chatModel . removeChatItem ( chat . chatInfo , deletedItem )
}
2022-03-30 20:37:47 +04:00
}
}
} catch {
logger . error ( " ChatView.deleteMessage error: \( error . localizedDescription ) " )
}
}
}
2022-01-29 11:10:04 +00:00
}
2022-08-20 12:47:48 +01:00
@ ViewBuilder func toggleNtfsButton ( _ chat : Chat ) -> some View {
Button {
toggleNotifications ( chat , enableNtfs : ! chat . chatInfo . ntfsEnabled )
} label : {
if chat . chatInfo . ntfsEnabled {
Label ( " Mute " , systemImage : " speaker.slash " )
} else {
Label ( " Unmute " , systemImage : " speaker.wave.2 " )
}
}
}
func toggleNotifications ( _ chat : Chat , enableNtfs : Bool ) {
Task {
do {
let chatSettings = ChatSettings ( enableNtfs : enableNtfs )
try await apiSetChatSettings ( type : chat . chatInfo . chatType , id : chat . chatInfo . apiId , chatSettings : chatSettings )
await MainActor . run {
switch chat . chatInfo {
case var . direct ( contact ) :
contact . chatSettings = chatSettings
ChatModel . shared . updateContact ( contact )
case var . group ( groupInfo ) :
groupInfo . chatSettings = chatSettings
ChatModel . shared . updateGroup ( groupInfo )
default : ( )
}
}
} catch let error {
logger . error ( " apiSetChatSettings error \( responseError ( error ) ) " )
}
}
}
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 )
}
}