linphone-desktop/linphone-app/ui/modules/Linphone/Chat/Chat.qml

448 lines
18 KiB
QML

import QtQuick 2.7
import QtQuick.Controls 2.2
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
Timer{// Let some time to have a better cell sizes
id: repositionerDelay
property int indexToMove
interval: 100
onTriggered: chat.positionViewAtIndex(indexToMove, ListView.Center)
}
function positionViewAtIndex(index){
chat.bindToEnd = false
chat.positionViewAtIndex(index, ListView.Center)
repositionerDelay.indexToMove = index
repositionerDelay.restart()
}
function goToMessage(message){
positionViewAtIndex(container.proxyModel.loadTillMessage(message))
}
ColumnLayout {
anchors.fill: parent
spacing: 0
ScrollableListView {
id: chat
// -----------------------------------------------------------------------
property bool displaying: false
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 syncLoaderBatch: 50 // batch of simultaneous loaders on synchronous mode
//------------------------------------
onLoadingEntriesChanged: {
if( loadingEntries && !displaying)
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()
}
Layout.fillHeight: true
Layout.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
container.proxyModel.loadMoreEntriesAsync()
}
}
// -----------------------------------------------------------------------
Component.onCompleted: Logic.initView()
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
}
}
// -----------------------------------------------------------------------
// Message/Event renderer.
// -----------------------------------------------------------------------
delegate: Rectangle {
id: entry
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
// ---------------------------------------------------------------------
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 | ($chatEntry && $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
Text {
id:timeDisplay
Layout.alignment: Qt.AlignTop | ($chatEntry && $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($chatEntry.timestamp)).getFullYear() != (new Date()).getFullYear()
text: $chatEntry
? (entry.displayDate ? UtilsCpp.toDateString($chatEntry.timestamp, (displayYear ? 'yyyy/':'') + 'MM/dd') + ' ' : '')
+ UtilsCpp.toTimeString($chatEntry.timestamp, 'hh:mm') + (authorName.visible ? ' - ' : '')
: ''
verticalAlignment: Text.AlignVCenter
TooltipArea {
text: $chatEntry ? UtilsCpp.toDateTimeString($chatEntry.timestamp) : ''
}
visible:!isNotice
}
Text{
id:authorName
//Layout.leftMargin: timeDisplay.width + ChatStyle.entry.metaWidth + ChatStyle.entry.message.extraContent.spacing
property var displayName: $chatEntry ? $chatEntry.fromDisplayName ? $chatEntry.fromDisplayName : $chatEntry.name : ''
text : displayName != undefined ? displayName : ''
color: ChatStyle.entry.event.text.colorModel.color
font.pointSize: ChatStyle.entry.event.text.pointSize
visible: isMessage
&& $chatEntry != undefined
&& !$chatEntry.isOutgoing // Only outgoing
&& (!entry.previousItem //No previous entry
|| entry.previousItem.type != ChatRoomModel.MessageEntry // Previous entry is a message
|| entry.previousItem.fromSipAddress != $chatEntry.fromSipAddress // Different user
|| (new Date(entry.previousItem.timestamp)).setHours(0, 0, 0, 0) != (new Date($chatEntry.timestamp)).setHours(0, 0, 0, 0) // Same day == section
)
}
}
// Display content.
Loader {
id: loader
height: (item !== null && typeof(item)!== 'undefined')? item.height: 0
Layout.fillWidth: true
source: Logic.getComponentFromEntry($chatEntry)
property int loaderIndex: 0 // index of loader from remaining loaders
property int remainingIndex : loaderIndex % ((chat.remainingLoadersCount) / chat.syncLoaderBatch) != 0 // Check loader index to remaining loader.
onRemainingIndexChanged: if( remainingIndex == 0 && asynchronous) asynchronous = false
asynchronous: true
z:1
onStatusChanged: if( status == Loader.Ready) {
loader.item.isTopGrouped = entry.isTopGrouped
loader.item.isBottomGrouped = entry.isBottomGrouped
remainingIndex = -1 // overwrite to remove signal changed. That way, there is no more binding loops.
--chat.remainingLoadersCount // Loader is ready: remove one from remaining count.
}
Component.onCompleted: {
loaderIndex = ++chat.remainingLoadersCount // on new Loader : one more remaining
}
Component.onDestruction: if( status != Loader.Ready) --chat.remainingLoadersCount // Remove remaining count if not loaded
}
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 = $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) {
var chat = CallsListModel.createChatRoom( '', proxyModel.chatRoomModel.haveEncryption, [sipAddress], false )
if(chat){
chat.chatRoomModel.forwardMessage($chatEntry)
TimelineListModel.select(chat.chatRoomModel)
}
},
chatRoomSelectedCallback: function (chatRoomModel){
if(chatRoomModel){
chatRoomModel.forwardMessage($chatEntry)
TimelineListModel.select(chatRoomModel)
}
}
})
}
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)
}
}
}
}
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
Layout.fillWidth: true
Layout.preferredHeight: textAreaBorders.height + chatMessagePreview.height+messageBlock.height + chatEmojis.height
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.leftMargin: ChatStyle.sendArea.backgroundBorder.width
maxHeight: container.height - textAreaBorders.height
replyChatRoomModel: proxyModel.chatRoomModel
replyRightMargin: textArea.textRightMargin
replyLeftMargin: textArea.textLeftMargin
}
ChatEmojis{
id: chatEmojis
onEmojiClicked: textArea.insertEmoji(emoji)
Layout.fillWidth: true
}
// -------------------------------------------------------------------------
// Send area.
// -------------------------------------------------------------------------
Borders {
id: textAreaBorders
Layout.fillWidth: true
Layout.preferredHeight: textArea.height
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.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: visible ? ChatStyle.sendArea.height + ChatStyle.sendArea.border.width : 0
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
}
}
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
}
}