linphone-desktop/linphone-app/ui/modules/Linphone/Chat/Chat.qml
Julien Wadel 4532e278ac New chat layout :
- Split content type to be filtered by proxy lists.
- Add a message in notification when receiving a conference invitation.
- Change chat bubbles colors to match mobile application.
- Change date display on messages to remove sections. It allows to be more coherent when sorting messages.
- Change Chat Layout : outgoing messages to right, incoming messages to left.
- Change bubble design to be squared when grouped.
- Group messages on 1 second away from previous (and same sender).
- Add a background color with radius to files in reply messages.
- Make color corners on reply.
- Fix filename to 2 lines in file download icon.
- Add a background color on conference invitations.
- Change conference title from bold to normal on invitations.
- Rework chat message content layout to be used with grids and lists : files are now displayed in grid.
- Remove cyclic dependencies with reply design (which was recursivly linked with ChatContent).
- Fix center layouts that were not bind to the correct one.
- Align pictures to center.
- Fix hidden admin usernames in participant view.
2023-03-03 17:09:25 +01:00

450 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 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 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
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.type === ChatRoomModel.NoticeEntry
property bool isCall : $chatEntry.type === ChatRoomModel.CallEntry
property bool isMessage : $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: !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.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.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: 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.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.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
}
}
}
Rectangle {
id: bottomChatBackground
Layout.fillWidth: true
Layout.preferredHeight: textAreaBorders.height + chatMessagePreview.height+messageBlock.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
}
// -------------------------------------------------------------------------
// 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
onDropped: Logic.handleFilesDropped(files)
onTextChanged: Logic.handleTextChanged(text)
onValidText: {
textArea.text = ''
chat.bindToEnd = true
if(proxyModel.chatRoomModel) {
proxyModel.sendMessage(text)
}else{
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
}
}
}// Send Area
}// ColumnLayout
}// Bottom background
}
// ---------------------------------------------------------------------------
// Scroll at end if necessary.
// ---------------------------------------------------------------------------
Timer {
interval: 100
repeat: true
running: true
onTriggered: chat.bindToEnd && chat.positionViewAtEnd()
}
}