linphone-desktop/linphone-app/ui/modules/Linphone/Chat/Chat.qml
Julien Wadel 3824ecaa78 Add debug feedback on missing colors.
Add contact URL.
Change unread message count design in chat.
Add page text about missing features when using third party SIP account.
Add Translation link for contribution in about.
Fix history view.
Add delay of 500ms on message search.
Fix unresponsive check update button in settings.
Fix wrong characters in record name.

# Conflicts:
#	linphone-app/cmake_builder/linphone_package/CMakeLists.txt
2022-02-24 13:13:50 +01:00

513 lines
17 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 Units 1.0
import 'Chat.js' as Logic
// =============================================================================
Rectangle {
id: container
property alias proxyModel: chat.model // ChatRoomProxyModel
property alias tryingToLoadMoreEntries : chat.tryToLoadMoreEntries
property string noticeBannerText : '' // When set, show a banner with text and hide after some time
onNoticeBannerTextChanged: if(noticeBannerText!='') messageBlock.state = "showed"
// ---------------------------------------------------------------------------
signal messageToSend (string text)
// ---------------------------------------------------------------------------
color: ChatStyle.color
function positionViewAtIndex(index){
chat.bindToEnd = false
chat.positionViewAtIndex(index, ListView.Beginning)
}
function goToMessage(message){
positionViewAtIndex(container.proxyModel.loadTillMessage(message))
}
ColumnLayout {
anchors.fill: parent
spacing: 0
ScrollableListView {
id: chat
// -----------------------------------------------------------------------
property bool bindToEnd: false
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
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()
}
}
section {
criteria: ViewSection.FullString
delegate: sectionHeading
property: '$sectionDate'
}
// -----------------------------------------------------------------------
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
}
}
// -----------------------------------------------------------------------
// Heading.
// -----------------------------------------------------------------------
Component {
id: sectionHeading
Item {
implicitHeight: container.height + ChatStyle.sectionHeading.bottomMargin
width: parent.width
Borders {
id: container
borderColor: ChatStyle.sectionHeading.border.color
bottomWidth: ChatStyle.sectionHeading.border.width
implicitHeight: text.contentHeight +
ChatStyle.sectionHeading.padding * 2 +
ChatStyle.sectionHeading.border.width * 2
topWidth: ChatStyle.sectionHeading.border.width
width: parent.width
Text {
id: text
anchors.fill: parent
color: ChatStyle.sectionHeading.text.color
font {
bold: true
pointSize: ChatStyle.sectionHeading.text.pointSize
}
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
// Cast section to integer because Qt converts the
// sectionDate in string!!!
text: new Date(section).toLocaleDateString(
Qt.locale(App.locale)
)
}
}
}
}
// -----------------------------------------------------------------------
// Message/Event renderer.
// -----------------------------------------------------------------------
delegate: Rectangle {
id: entry
property bool isNotice : $chatEntry.type === ChatRoomModel.NoticeEntry
property bool isCall : $chatEntry.type === ChatRoomModel.CallEntry
property bool isMessage : $chatEntry.type === ChatRoomModel.MessageEntry
function isHoverEntry () {
return mouseArea.containsMouse
}
function removeEntry () {
proxyModel.removeRow(index)
}
anchors {
left: parent ? parent.left : undefined
leftMargin: isNotice?0:ChatStyle.entry.leftMargin
right: parent ? parent.right : undefined
rightMargin: isNotice?0:ChatStyle.entry.deleteIconSize +
ChatStyle.entry.message.extraContent.spacing +
ChatStyle.entry.message.extraContent.rightMargin +
ChatStyle.entry.message.extraContent.leftMargin +
ChatStyle.entry.message.outgoing.areaSize
}
color: ChatStyle.color
implicitHeight: layout.height + ChatStyle.entry.bottomMargin
// ---------------------------------------------------------------------
MouseArea {
id: mouseArea
cursorShape: Qt.ArrowCursor
hoverEnabled: true
implicitHeight: layout.height
width: parent.width + parent.anchors.rightMargin
acceptedButtons: Qt.NoButton
ColumnLayout{
id: layout
spacing: 0
width: entry.width
Text{
id:authorName
Layout.leftMargin: timeDisplay.width + 10
Layout.fillWidth: true
text : $chatEntry.fromDisplayName ? $chatEntry.fromDisplayName : ''
property var previousItem : {
if(index >0)
return proxyModel.getAt(index-1)
else
return null
}
color: ChatStyle.entry.event.text.color
font.pointSize: ChatStyle.entry.event.text.pointSize
visible: isMessage
&& $chatEntry != undefined
&& !$chatEntry.isOutgoing // Only outgoing
&& (!previousItem //No previous entry
|| previousItem.type != ChatRoomModel.MessageEntry // Previous entry is a message
|| previousItem.fromSipAddress != $chatEntry.fromSipAddress // Different user
|| (new Date(previousItem.timestamp)).setHours(0, 0, 0, 0) != (new Date($chatEntry.timestamp)).setHours(0, 0, 0, 0) // Same day == section
)
}
RowLayout {
spacing: 0
width: entry.width
// Display time.
Text {
id:timeDisplay
Layout.alignment: Qt.AlignTop
Layout.preferredHeight: ChatStyle.entry.lineHeight
Layout.preferredWidth: ChatStyle.entry.time.width
color: ChatStyle.entry.event.text.color
font.pointSize: ChatStyle.entry.time.pointSize
text: $chatEntry.timestamp.toLocaleString(
Qt.locale(App.locale),
'hh:mm'
)
verticalAlignment: Text.AlignVCenter
TooltipArea {
text: $chatEntry.timestamp.toLocaleString(Qt.locale(App.locale))
}
visible:!isNotice
}
// 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
onStatusChanged: if( status == Loader.Ready) {
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.createChat(sipAddress)
if(chat){
chat.forwardMessage($chatEntry)
TimelineListModel.select(chat)
}
},
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.
}
}
}
}
}
}
footer: Item{
implicitHeight: composersItem.implicitHeight
width: parent.width
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.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.isIndexAfter(chat.count-1)
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
}
}
}
ChatMessagePreview{
id: chatMessagePreview
Layout.fillWidth: true
replyChatRoomModel: proxyModel.chatRoomModel
}
Rectangle{
id: messageBlock
onHeightChanged: height = Layout.preferredHeight
Layout.preferredHeight: visible && opacity > 0 ? 32 : 0
Layout.fillWidth: true
Layout.leftMargin: ChatStyle.entry.leftMargin
Layout.rightMargin: ChatStyle.entry.rightMargin
color: ChatStyle.messageBanner.color
radius: 10
state: "hidden"
Timer{
id: hideNoticeBanner
interval: 4000
repeat: false
onTriggered: messageBlock.state = "hidden"
}
RowLayout{
anchors.centerIn: parent
spacing: 5
Icon{
icon: ChatStyle.copyTextIcon
overwriteColor: ChatStyle.messageBanner.textColor
iconSize: 20
}
Text{
Layout.fillHeight: true
Layout.fillWidth: true
text: container.noticeBannerText
font {
pointSize: ChatStyle.messageBanner.pointSize
}
color: ChatStyle.messageBanner.textColor
}
}
states: [
State {
name: "hidden"
PropertyChanges { target: messageBlock; opacity: 0 }
},
State {
name: "showed"
PropertyChanges { target: messageBlock; opacity: 1 }
}
]
transitions: [
Transition {
from: "*"; to: "showed"
SequentialAnimation{
NumberAnimation{ properties: "opacity"; easing.type: Easing.OutBounce; duration: 500 }
ScriptAction{ script: hideNoticeBanner.start()}
}
},
Transition {
SequentialAnimation{
NumberAnimation{ properties: "opacity"; duration: 1000 }
ScriptAction{ script: container.noticeBannerText = '' }
}
}
]
}
// -------------------------------------------------------------------------
// Send area.
// -------------------------------------------------------------------------
Borders {
id: textAreaBorders
Layout.fillWidth: true
Layout.preferredHeight: textArea.height
borderColor: ChatStyle.sendArea.border.color
topWidth: ChatStyle.sendArea.border.width
visible: proxyModel.chatRoomModel && !proxyModel.chatRoomModel.hasBeenLeft && (!proxyModel.chatRoomModel.haveEncryption && SettingsModel.standardChatEnabled || proxyModel.chatRoomModel.haveEncryption && SettingsModel.secureChatEnabled)
DroppableTextArea {
id: textArea
enabled:proxyModel && proxyModel.chatRoomModel ? !proxyModel.chatRoomModel.hasBeenLeft:false
isEphemeral : proxyModel && proxyModel.chatRoomModel ? proxyModel.chatRoomModel.ephemeralEnabled:false
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height:ChatStyle.sendArea.height + ChatStyle.sendArea.border.width
minimumHeight:ChatStyle.sendArea.height + ChatStyle.sendArea.border.width
maximumHeight:container.height/2
dropEnabled: SettingsModel.fileTransferUrl.length > 0
dropDisabledReason: qsTr('noFileTransferUrl')
placeholderText: qsTr('newMessagePlaceholder')
onDropped: Logic.handleFilesDropped(files)
onTextChanged: Logic.handleTextChanged(text)
onValidText: {
textArea.text = ''
chat.bindToEnd = true
if(proxyModel.chatRoomModel) {
proxyModel.sendMessage(text)
}else{
console.log("Peer : " +proxyModel.peerAddress+ "/"+chat.model.peerAddress)
proxyModel.chatRoomModel = CallsListModel.createChat(proxyModel.peerAddress)
proxyModel.sendMessage(text)
}
}
onAudioRecordRequest: RecorderManager.resetVocalRecorder()
Component.onCompleted: {text = proxyModel.cachedText; cursorPosition=text.length}
Rectangle{
anchors.fill:parent
color:'white'
opacity: 0.5
visible:!textArea.enabled
}
}
}
}
// ---------------------------------------------------------------------------
// Scroll at end if necessary.
// ---------------------------------------------------------------------------
Timer {
interval: 100
repeat: true
running: true
onTriggered: chat.bindToEnd && chat.positionViewAtEnd()
}
}