import QtQuick 2.7 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.3 import Common 1.0 import Linphone 1.0 import Linphone.Styles 1.0 import Utils 1.0 import UtilsCpp 1.0 import LinphoneEnums 1.0 import Units 1.0 import 'Chat.js' as Logic import 'qrc:/ui/scripts/Utils/utils.js' as Utils // ============================================================================= Rectangle { id: container property alias proxyModel: chat.model // ChatRoomProxyModel property alias tryingToLoadMoreEntries : chat.tryToLoadMoreEntries property alias noticeBannerText : messageBlock.noticeBannerText // When set, show a banner with text and hide after some time // --------------------------------------------------------------------------- signal messageToSend (string text) signal addContactClicked(string contactAddress) signal viewContactClicked(string contactAddress) // --------------------------------------------------------------------------- color: ChatStyle.colorModel.color clip: true Item{// Let some time to have a better cell sizes id: moveToEvent property int indexToMove: -1 property bool toReposition: indexToMove>=0 && !container.tryingToLoadMoreEntries function reposition(){ chat.positionViewAtIndex(indexToMove, ListView.Center) repositionerDelay.indexToMove = indexToMove indexToMove = -1 repositionerDelay.restart() } onToRepositionChanged: if(toReposition){ console.debug('Moving to ' + indexToMove) Qt.callLater(reposition); } } Timer{// Let some time to have a better cell sizes id: repositionerDelay property int indexToMove: -1 interval: 100 onTriggered: { chat.positionViewAtIndex(indexToMove, ListView.Center) } } function positionViewAtIndex(index){ if(index>=0) { chat.bindToEnd = false chat.positionViewAtIndex(index, ListView.Center) moveToEvent.indexToMove = index } } function goToMessage(message){ positionViewAtIndex(container.proxyModel.loadTillMessage(message)) } function goToMessageId(messageId){ positionViewAtIndex(container.proxyModel.loadTillMessageId(messageId)) } SplitView{ anchors.fill: parent spacing: 0 orientation: Qt.Vertical ScrollableListView { id: chat // ----------------------------------------------------------------------- property bool displaying: false property bool entriesLoading: container.proxyModel.chatRoomModel && container.proxyModel.chatRoomModel.entriesLoading onEntriesLoadingChanged: console.log("entriesLoading="+entriesLoading) property bool loadingEntries: (container.proxyModel.chatRoomModel && container.proxyModel.chatRoomModel.entriesLoading) || displaying property bool tryToLoadMoreEntries: loadingEntries || remainingLoadersCount>0 property bool isMoving : false // replace moving read-only property to allow using movement signals. // Load optimizations property int remainingLoadersCount: 0 property int visibleItemsEstimation: chat.height / (2 * textMetrics.height) // Title + body property int syncLoaderBatch: visibleItemsEstimation // batch of simultaneous loaders on synchronous mode //------------------------------------ signal refreshContents() onLoadingEntriesChanged: { if( loadingEntries && !displaying && container.proxyModel.chatRoomModel.entriesLoading) { displaying = true } } onBindToEndChanged: if( bindToEnd){ markAsReadTimer.start() } Timer{ id: markAsReadTimer interval: 5000 repeat: false running: false onTriggered: if(container.proxyModel.chatRoomModel) container.proxyModel.chatRoomModel.resetMessageCount() } Timer{ id: refreshContentsTimer interval: 200 repeat: true running: false onTriggered: chat.refreshContents() } TextMetrics{ id: textMetrics font: SettingsModel.textMessageFont text: "X" } SplitView.fillHeight: true SplitView.fillWidth: true clip: false highlightFollowsCurrentItem: false // Use moving event => this is a user action. onIsMovingChanged:{ if(!chat.isMoving && chat.atYBeginning && !chat.loadingEntries){// Moving has stopped. Check if we are at beginning chat.displaying = true console.log("Trying to load more entries") Qt.callLater(container.proxyModel.loadMoreEntriesAsync) } } // ----------------------------------------------------------------------- Component.onCompleted: { Logic.initView() refreshContentsTimer.start() console.debug("Chat loading with "+chat.visibleItemsEstimation+" visible items. Count="+chat.count) if(chat.visibleItemsEstimation >= chat.count) Qt.callLater(container.proxyModel.loadMoreEntriesAsync) } onMovementStarted: {Logic.handleMovementStarted(); chat.isMoving = true} onMovementEnded: {Logic.handleMovementEnded(); chat.isMoving = false} // ----------------------------------------------------------------------- Connections { target: proxyModel // When the view is changed (for example `Calls` -> `Messages`), // the position is set at end and it can be possible to load // more entries. onEntryTypeFilterChanged: Logic.initView() onMoreEntriesLoaded: { Logic.handleMoreEntriesLoaded(n)// move view to n - 1 item chat.displaying = false } onTillMessagesLoaded: positionViewAtIndex(messageIndex) onDisplayMessageIdRequested: { console.log("Display Message requested "+messageId) container.goToMessageId(messageId) } } // ----------------------------------------------------------------------- // Message/Event renderer. // ----------------------------------------------------------------------- delegate: Rectangle { id: entry property var chatEntry: $chatEntry property bool isNotice : chatEntry && (chatEntry.type === ChatRoomModel.NoticeEntry) property bool isCall : chatEntry && (chatEntry.type === ChatRoomModel.CallEntry) property bool isMessage : chatEntry && (chatEntry.type === ChatRoomModel.MessageEntry) property var previousItem : proxyModel.count > 0 && index >0 ? proxyModel.getAt(index-1) : null property var nextItem : proxyModel.count > 0 ? proxyModel.getAt(index+1) : null // bind to count property bool displayDate: chatEntry && !Utils.equalDate(new Date(chatEntry.timestamp), new Date()) property bool isTopGrouped: isGrouped(entry.previousItem, chatEntry) || false property bool isBottomGrouped: isGrouped(chatEntry, entry.nextItem) || false onIsBottomGroupedChanged: if(loader.item) loader.item.isBottomGrouped = isBottomGrouped onIsTopGroupedChanged: if(loader.item) loader.item.isTopGrouped = isTopGrouped function isGrouped(item1, item2){ return item1 && item2 //Have a previous entry && item1.type == ChatRoomModel.MessageEntry // Previous entry is a message && item2.type == ChatRoomModel.MessageEntry // Previous entry is a message && item2.fromSipAddress == item1.fromSipAddress // Same user && Math.abs((new Date(item2.timestamp)).getTime() - (new Date(item1.timestamp)).getTime())/1000 < 60 } function isHoverEntry () { return mouseArea.containsMouse } function removeEntry () { proxyModel.removeRow(index) } color: ChatStyle.colorModel.color implicitHeight: layout.height + (entry.isBottomGrouped? 1 : ChatStyle.entry.bottomMargin) width: chat.contentWidth // Fill all space clip: false visible: loader.status == Loader.Ready // --------------------------------------------------------------------- MouseArea { id: mouseArea cursorShape: Qt.ArrowCursor hoverEnabled: true implicitHeight: layout.height width: parent.width + parent.anchors.rightMargin anchors.top: parent.top //anchors.topMargin: (entry.isTopGrouped? 1 : ChatStyle.entry.bottomMargin) clip: false acceptedButtons: Qt.NoButton onContainsMouseChanged: if(loader.item) loader.item.isHovering = containsMouse ColumnLayout{ id: layout spacing: 0 width: entry.width RowLayout{ id: headerLayout Layout.fillWidth: true Layout.alignment: Qt.AlignTop | (entry.chatEntry && entry.chatEntry.isOutgoing ? Qt.AlignRight : Qt.AlignLeft) Layout.leftMargin: ChatStyle.entry.metaWidth// + ChatStyle.entry.message.extraContent.spacing Layout.rightMargin: ChatStyle.entry.message.outgoing.areaSize spacing:0 // Display time. visible: !entry.isTopGrouped && !entry.isNotice Text { id:timeDisplay Layout.alignment: Qt.AlignTop | (entry.chatEntry && entry.chatEntry.isOutgoing ? Qt.AlignRight : Qt.AlignLeft) Layout.preferredHeight: implicitHeight// ChatStyle.entry.lineHeight //Layout.preferredWidth: ChatStyle.entry.time.width color: ChatStyle.entry.event.text.colorModel.color font.pointSize: ChatStyle.entry.time.pointSize property bool displayYear: entry.displayDate && (new Date(entry.chatEntry.timestamp)).getFullYear() != (new Date()).getFullYear() text: entry.chatEntry ? (entry.displayDate ? UtilsCpp.toDateString(entry.chatEntry.timestamp, (displayYear ? 'yyyy/':'') + 'MM/dd') + ' ' : '') + UtilsCpp.toTimeString(entry.chatEntry.timestamp, 'hh:mm') + (authorName.visible ? ' - ' : '') : '' verticalAlignment: Text.AlignVCenter TooltipArea { text: entry.chatEntry ? UtilsCpp.toDateTimeString(entry.chatEntry.timestamp) : '' } } Text{ id:authorName //Layout.leftMargin: timeDisplay.width + ChatStyle.entry.metaWidth + ChatStyle.entry.message.extraContent.spacing property var displayName: entry.chatEntry ? entry.chatEntry.fromDisplayName ? entry.chatEntry.fromDisplayName : entry.chatEntry.name : '' text : displayName != undefined ? displayName : '' color: ChatStyle.entry.event.text.colorModel.color font.pointSize: ChatStyle.entry.event.text.pointSize visible: entry.chatEntry && !entry.chatEntry.isOutgoing && text != '' } } // Display content. Loader { id: loader height: (item !== null && typeof(item)!== 'undefined')? item.height: 0 Layout.fillWidth: true source: Logic.getComponentFromEntry(entry.chatEntry) z:1 asynchronous: index < chat.count - 1 - chat.visibleItemsEstimation property int loaderIndex: 0 function updateSync(){ if( asynchronous && loaderIndex > 0 && chat.remainingLoadersCount - loaderIndex - chat.syncLoaderBatch <= 0 ) asynchronous = false// Sync load the end } function stopLoading(){ chatConnections.enabled = false // No more update is needed : ignore signals. --chat.remainingLoadersCount // Loader is ready: remove one from remaining count. } onStatusChanged: if( status == Loader.Ready) { loader.item.isTopGrouped = entry.isTopGrouped loader.item.isBottomGrouped = entry.isBottomGrouped stopLoading(); }else if( status == Loader.Error) { stopLoading(); } Component.onCompleted: { loaderIndex = ++chat.remainingLoadersCount // on new Loader : one more remaining } Component.onDestruction: if( status != Loader.Ready && status != Loader.Error) {stopLoading();} // Remove remaining count if not loaded Connections{ id: chatConnections target: chat onRefreshContents:loader.updateSync() } } Connections{ target: loader.item ignoreUnknownSignals: true //: "Copied to clipboard" : when a user copy a text from the menu, this message show up. onCopyAllDone: container.noticeBannerText = qsTr("allTextCopied") //: "Selection copied to clipboard" : when a user copy a text from the menu, this message show up. onCopySelectionDone: container.noticeBannerText = qsTr("selectedTextCopied") onReplyClicked: { proxyModel.chatRoomModel.reply = entry.chatEntry } onForwardClicked:{ window.attachVirtualWindow(Qt.resolvedUrl('../Dialog/SipAddressDialog.qml') //: 'Choose where to forward the message' : Dialog title for choosing where to forward the current message. , {title: qsTr('forwardDialogTitle'), addressSelectedCallback: function (sipAddress) { Logic.forwardMessage(undefined, entry.chatEntry, {subject:'', haveEncryption: proxyModel.chatRoomModel.haveEncryption, participants: [sipAddress], toSelect: false} ) }, chatRoomSelectedCallback: function (chatRoomModel){ if(chatRoomModel) Logic.forwardMessage(chatRoomModel, entry.chatEntry) } }) } onGoToMessage:{ container.goToMessage(message) // sometimes, there is no access to chat id (maybe because of cleaning component while loading new items). Use a global intermediate. } onConferenceIcsCopied: container.noticeBannerText = qsTr('conferencesCopiedICS') onAddContactClicked: container.addContactClicked(contactAddress) onViewContactClicked: container.viewContactClicked(contactAddress) onReactionsClicked: chatReactionsDetails.show(message) } } } } footer: Item{ implicitHeight: composersItem.implicitHeight width: parent.width clip: false Text { id: composersItem property var composers : container.proxyModel.chatRoomModel ? container.proxyModel.chatRoomModel.composers : undefined property int count : composers && composers.length ? composers.length : 0 color: ChatStyle.composingText.colorModel.color font.pointSize: ChatStyle.composingText.pointSize height: visible ? undefined : 0 leftPadding: ChatStyle.composingText.leftPadding visible: count > 0 && ( (!proxyModel.chatRoomModel.haveEncryption && SettingsModel.standardChatEnabled) || (proxyModel.chatRoomModel.haveEncryption && SettingsModel.secureChatEnabled) ) wrapMode: Text.Wrap //: '%1 is typing...' indicate that someone is composing in chat text:(count==0?'': qsTr('chatTyping','',count).arg(container.proxyModel.getDisplayNameComposers())) } } ActionButton{ id: gotToBottomButton anchors.bottom: parent.bottom anchors.bottomMargin: 10 anchors.right: parent.right anchors.rightMargin: 35 visible: !chat.endIsDisplayed onVisibleChanged: updateMarkAsRead() Component.onCompleted: updateMarkAsRead() function updateMarkAsRead(){ if(!visible) container.proxyModel.markAsReadEnabled = true } Connections{ target: container.proxyModel onMarkAsReadEnabledChanged: if( !container.proxyModel.markAsReadEnabled) gotToBottomButton.updateMarkAsRead() } isCustom: true backgroundRadius: width/2 colorSet: ChatStyle.gotToBottom onClicked: { chat.bindToEnd = true } MessageCounter{ anchors.left: parent.right anchors.bottom: parent.top anchors.bottomMargin: 0 anchors.leftMargin: -14 count: container.proxyModel.chatRoomModel ? container.proxyModel.chatRoomModel.unreadMessagesCount : 0 showOnlyNumber: true iconSize: 15 pointSize: Units.dp * 7 } } } Rectangle { id: bottomChatBackground SplitView.fillWidth: true SplitView.minimumHeight: textAreaBorders.minimumHeight + chatMessagePreview.height+messageBlock.height + chatEmojis.minimumHeight color: ChatStyle.sendArea.backgroundBorder.colorModel.color visible: proxyModel.chatRoomModel && !proxyModel.chatRoomModel.isReadOnly && (!proxyModel.chatRoomModel.haveEncryption && SettingsModel.standardChatEnabled || proxyModel.chatRoomModel.haveEncryption && SettingsModel.secureChatEnabled) ColumnLayout{ anchors.fill: parent spacing: 0 MessageBanner{ id: messageBlock onHeightChanged: height = Layout.preferredHeight Layout.fillWidth: true Layout.preferredHeight: fitHeight Layout.leftMargin: ChatStyle.entry.leftMargin Layout.rightMargin: ChatStyle.entry.rightMargin noticeBannerText: '' } ChatMessagePreview{ id: chatMessagePreview Layout.fillWidth: true Layout.fillHeight: true Layout.leftMargin: ChatStyle.sendArea.backgroundBorder.width maxHeight: container.height - textAreaBorders.height replyChatRoomModel: proxyModel.chatRoomModel replyRightMargin: textArea.textRightMargin replyLeftMargin: textArea.textLeftMargin } ChatEmojis{ id: chatEmojis property int minimumHeight: visible ? 150 : 0 onEmojiClicked: textArea.insertEmoji(emoji) Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: minimumHeight } // ------------------------------------------------------------------------- // Send area. // ------------------------------------------------------------------------- Borders { id: textAreaBorders property int minimumHeight: visible ? ChatStyle.sendArea.height + ChatStyle.sendArea.border.width : 0 Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: minimumHeight Layout.leftMargin: ChatStyle.sendArea.backgroundBorder.width borderColor: ChatStyle.sendArea.border.colorModel.color topWidth: ChatStyle.sendArea.border.width DroppableTextArea { id: textArea enabled:proxyModel && proxyModel.chatRoomModel ? !proxyModel.chatRoomModel.isReadOnly:false isEphemeral : proxyModel && proxyModel.chatRoomModel ? proxyModel.chatRoomModel.ephemeralEnabled:false anchors.fill: parent minimumHeight:ChatStyle.sendArea.height + ChatStyle.sendArea.border.width maximumHeight:container.height/2 dropEnabled: SettingsModel.fileTransferUrl.length > 0 dropDisabledReason: qsTr('noFileTransferUrl') placeholderText: qsTr('newMessagePlaceholder') recordAudioToggled: RecorderManager.haveVocalRecorder && RecorderManager.getVocalRecorder().state != LinphoneEnums.RecorderStateClosed emojiVisible: chatEmojis.visible onDropped: Logic.handleFilesDropped(files) property bool componentReady: false onTextChanged: {// This slot can be call before the item has been completed because of Rich text. So the cache must not take it account. if(componentReady) { proxyModel.cachedText=text } } onComposing: proxyModel.compose(getText()) onValidText: { textArea.text = '' chat.bindToEnd = true if(proxyModel.chatRoomModel) { proxyModel.sendMessage(text)//Note : 'text' is coming from validText. It's not the text member. }else{ proxyModel.chatRoomModel = CallsListModel.createChat(proxyModel.peerAddress) proxyModel.sendMessage(text) } } onAudioRecordRequest: RecorderManager.resetVocalRecorder() onEmojiClicked: { chatEmojis.visible = !chatEmojis.visible } Component.onCompleted: {text = proxyModel.cachedText; cursorPosition=text.length;componentReady=true} Rectangle{ anchors.fill:parent color:'white' opacity: 0.5 visible:!textArea.enabled } } }// Send Area }// ColumnLayout }// Bottom background } ChatReactionsDetails { id: chatReactionsDetails anchors.fill: parent visible: false } }