// SPDX-FileCopyrightText: 2020 Black Hat // SPDX-License-Identifier: GPL-3.0-only import QtQuick 2.15 import QtQuick.Controls 2.15 as QQC2 import QtQuick.Layouts 1.15 import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents import org.kde.neochat 1.0 /** * @brief The base delegate for all messages in the timeline. * * This supports a message bubble plus sender avatar for each message as well as reactions * and read markers. A date section can be show for when the message is on a different * day to the previous one. * * The component is designed for all messages, positioning them in the timeline with * variable padding depending on the window width. Local user messages are highlighted * and can also be aligned to the right if configured. * * This component also supports a compact mode where the padding is adjusted, the * background is hidden and the delegate spans the full width of the timeline. */ ColumnLayout { id: root /** * @brief The index of the delegate in the model. */ required property var index /** * @brief The matrix ID of the message event. */ required property string eventId /** * @brief The timestamp of the message. */ required property var time /** * @brief The message author. * * This should consist of the following: * - id - The matrix ID of the author. * - isLocalUser - Whether the author is the local user. * - avatarSource - The mxc URL for the author's avatar in the current room. * - avatarMediaId - The media ID of the author's avatar. * - avatarUrl - The mxc URL for the author's avatar. * - displayName - The display name of the author. * - display - The name of the author. * - color - The color for the author. * - object - The NeoChatUser object for the author. * * @sa NeoChatUser */ required property var author /** * @brief Whether the author should be shown. */ required property bool showAuthor /** * @brief The delegate type of the message. */ required property int delegateType /** * @brief The display text of the message. */ required property string display /** * @brief The display text of the message as plain text. */ required property string plainText /** * @brief The date of the event as a string. */ required property string section /** * @brief Whether the section header should be shown. */ required property bool showSection /** * @brief A model with the reactions to the message in. */ required property var reaction /** * @brief Whether the reaction component should be shown. */ required property bool showReactions /** * @brief A model with the first 5 other user read markers for this message. */ required property var readMarkers /** * @brief String with the display name and matrix ID of the other user read markers. */ required property string readMarkersString /** * @brief The number of other users at the event after the first 5. */ required property var excessReadMarkers /** * @brief Whether the other user read marker component should be shown. */ required property bool showReadMarkers /** * @brief The matrix ID of the reply event. */ required property var replyId /** * @brief The reply author. * * This should consist of the following: * - id - The matrix ID of the reply author. * - isLocalUser - Whether the reply author is the local user. * - avatarSource - The mxc URL for the reply author's avatar in the current room. * - avatarMediaId - The media ID of the reply author's avatar. * - avatarUrl - The mxc URL for the reply author's avatar. * - displayName - The display name of the reply author. * - display - The name of the reply author. * - color - The color for the reply author. * - object - The NeoChatUser object for the reply author. * * @sa NeoChatUser */ required property var replyAuthor /** * @brief The reply content. * * This should consist of the following: * - display - The display text of the reply. * - type - The delegate type of the reply. */ required property var reply /** * @brief The media info for the reply event. * * This could be an image, audio, video or file. * * This should consist of the following: * - source - The mxc URL for the media. * - mimeType - The MIME type of the media. * - mimeIcon - The MIME icon name. * - size - The file size in bytes. * - duration - The length in seconds of the audio media (audio/video only). * - width - The width in pixels of the audio media (image/video only). * - height - The height in pixels of the audio media (image/video only). * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only). */ required property var replyMediaInfo /** * @brief Whether this message is replying to another. */ required property bool isReply /** * @brief Whether this message has a local user mention. */ required property bool isHighlighted /** * @brief Whether an event is waiting to be accepted by the server. */ required property bool isPending /** * @brief Progress info when downloading files. * * @sa Quotient::FileTransferInfo */ required property var progressInfo /** * @brief Whether an encrypted message is sent in a verified session. */ required property bool verified /** * @brief The mime type of the message's file or media. */ required property var mimeType /** * @brief The full message source JSON. */ required property var jsonSource /** * @brief The x position of the message bubble. * * @note Used for positioning the hover actions. */ readonly property real bubbleX: bubble.x + bubble.anchors.leftMargin /** * @brief The y position of the message bubble. * * @note Used for positioning the hover actions. */ readonly property alias bubbleY: mainContainer.y /** * @brief The width of the message bubble. * * @note Used for sizing the hover actions. */ readonly property alias bubbleWidth: bubble.width /** * @brief Whether this message is hovered. */ readonly property alias hovered: bubble.hovered /** * @brief Open the context menu for the message. */ signal openContextMenu /** * @brief Open the any message media externally. */ signal openExternally() /** * @brief The reply has been clicked. */ signal replyClicked(string eventID) onReplyClicked: ListView.view.goToEvent(eventID) /** * @brief The component to display the delegate type. * * This is used by the inherited delegates to assign a component to visualise * the message content for that delegate type. */ default property alias innerObject : column.children /** * @brief Whether the bubble background is enabled. */ property bool cardBackground: true /** * @brief Whether local user messages should be aligned right. * * TODO: make private */ property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout /** * @brief Whether the message should be highlighted. */ property bool showHighlight: root.isHighlighted || isTemporaryHighlighted /** * @brief Whether the message should temporarily be highlighted. * * Normally triggered when jumping to the event in the timeline, e.g. when a reply * is clicked. */ property bool isTemporaryHighlighted: false onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start() Timer { id: temporaryHighlightTimer interval: 1500 onTriggered: isTemporaryHighlighted = false } readonly property int contentMaxWidth: bubbleSizeHelper.currentWidth width: parent ? timelineDelegateSizeHelper.currentWidth : 0 spacing: Kirigami.Units.smallSpacing state: Config.compactLayout ? "alignLeft" : "alignCenter" // Align left when in compact mode and center when using bubbles states: [ State { name: "alignLeft" AnchorChanges { target: root anchors.horizontalCenter: undefined anchors.left: parent ? parent.left : undefined } }, State { name: "alignCenter" AnchorChanges { target: root anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined anchors.left: undefined } } ] transitions: [ Transition { AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic} } ] SectionDelegate { id: sectionDelegate Layout.fillWidth: true visible: root.showSection labelText: root.section } QQC2.ItemDelegate { id: mainContainer Layout.fillWidth: true Layout.topMargin: root.showAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) Layout.leftMargin: Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing implicitHeight: Math.max(root.showAuthor ? avatar.implicitHeight : 0, bubble.height) Component.onCompleted: { if (root.isReply && root.reply === undefined) { currentRoom.loadReply(root.eventId, root.replyId) } } // show hover actions onHoveredChanged: { if (hovered && !Kirigami.Settings.isMobile) { root.setHoverActionsToDelegate() } } KirigamiComponents.Avatar { id: avatar width: visible || Config.showAvatarInTimeline ? Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2: 0 height: width anchors { left: parent.left leftMargin: Kirigami.Units.smallSpacing top: parent.top topMargin: Kirigami.Units.smallSpacing } visible: root.showAuthor && Config.showAvatarInTimeline && (Config.compactLayout || !showUserMessageOnRight) name: root.author.displayName source: root.author.avatarSource color: root.author.color MouseArea { anchors.fill: parent onClicked: { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { room: currentRoom, user: root.author }).open(); } cursorShape: Qt.PointingHandCursor } } QQC2.Control { id: bubble topPadding: Config.compactLayout ? Kirigami.Units.smallSpacing / 2 : Kirigami.Units.largeSpacing bottomPadding: Config.compactLayout ? Kirigami.Units.mediumSpacing / 2 : Kirigami.Units.largeSpacing leftPadding: Config.compactLayout ? 0 : Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing rightPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing hoverEnabled: true anchors { left: avatar.right leftMargin: Kirigami.Units.largeSpacing rightMargin: Kirigami.Units.largeSpacing } // HACK: anchoring didn't reset anchors.right when switching from parent.right to undefined reliably width: Config.compactLayout ? mainContainer.width - (Config.showAvatarInTimeline ? Kirigami.Units.gridUnit * 2 : 0) + Kirigami.Units.largeSpacing * 2 : implicitWidth state: showUserMessageOnRight ? "userMessageOnRight" : "userMessageOnLeft" // states for anchor animations on window resize // as setting anchors to undefined did not work reliably states: [ State { name: "userMessageOnRight" AnchorChanges { target: bubble anchors.left: undefined anchors.right: parent.right } }, State { name: "userMessageOnLeft" AnchorChanges { target: bubble anchors.left: avatar.right anchors.right: undefined } } ] transitions: [ Transition { AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic} } ] contentItem: RowLayout { Kirigami.Icon { source: "content-loading-symbolic" width: height Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small visible: root.isPending && Config.showLocalMessagesOnRight } ColumnLayout { id: column spacing: Kirigami.Units.smallSpacing RowLayout { id: rowLayout spacing: Kirigami.Units.smallSpacing visible: root.showAuthor QQC2.Label { id: nameLabel Layout.maximumWidth: contentMaxWidth - timeLabel.implicitWidth - rowLayout.spacing text: visible ? root.author.displayName : "" textFormat: Text.PlainText font.weight: Font.Bold color: root.author.color elide: Text.ElideRight MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { userDetailDialog.createObject(QQC2.ApplicationWindow.overlay, { room: currentRoom, user: root.author }).open(); } } } QQC2.Label { id: timeLabel text: visible ? root.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" color: Kirigami.Theme.disabledTextColor QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.text: root.time.toLocaleString(Qt.locale(), Locale.LongFormat) QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay HoverHandler { id: hoverHandler } } } Loader { id: replyLoader Layout.maximumWidth: contentMaxWidth active: root.isReply visible: active sourceComponent: ReplyComponent { author: root.replyAuthor type: root.reply.type display: root.reply.display mediaInfo: root.replyMediaInfo } Connections { target: replyLoader.item function onReplyClicked() { replyClicked(root.replyId) } } } } Kirigami.Icon { source: "content-loading-symbolic" width: height Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small visible: root.isPending && !Config.showLocalMessagesOnRight } } background: Item { Kirigami.ShadowedRectangle { id: bubbleBackground visible: cardBackground && !Config.compactLayout anchors.fill: parent Kirigami.Theme.colorSet: Kirigami.Theme.View Kirigami.Theme.inherit: false color: if (root.author.isLocalUser) { return Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) } else if (root.showHighlight) { return Kirigami.Theme.positiveBackgroundColor } else { return Kirigami.Theme.backgroundColor } radius: Kirigami.Units.smallSpacing shadow { size: Kirigami.Units.smallSpacing color: root.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) } Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } } } } background: Rectangle { visible: mainContainer.hovered && Config.compactLayout color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) radius: Kirigami.Units.smallSpacing } TapHandler { acceptedDevices: PointerDevice.Mouse acceptedButtons: Qt.RightButton onTapped: root.openContextMenu() } TapHandler { acceptedButtons: Qt.LeftButton onLongPressed: root.openContextMenu() } } ReactionDelegate { Layout.maximumWidth: root.width - Kirigami.Units.largeSpacing * 2 Layout.alignment: showUserMessageOnRight ? Qt.AlignRight : Qt.AlignLeft Layout.leftMargin: showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin Layout.rightMargin: showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0 visible: root.showReactions model: root.reaction onReactionClicked: (reaction) => currentRoom.toggleReaction(root.eventId, reaction) } AvatarFlow { Layout.alignment: Qt.AlignRight Layout.rightMargin: Kirigami.Units.largeSpacing visible: root.showReadMarkers model: root.readMarkers toolTipText: root.readMarkersString excessAvatars: root.excessReadMarkers } function isVisibleInTimeline() { let yoff = Math.round(y - ListView.view.contentY); return (yoff + height > 0 && yoff < ListView.view.height) } /// Open message context dialog for file and videos function openFileContext(file) { const contextMenu = fileDelegateContextMenu.createObject(root, { author: root.author, eventId: root.eventId, source: root.jsonSource, file: file, mimeType: root.mimeType, progressInfo: root.progressInfo, plainText: root.plainText, }); contextMenu.open(); } /// Open context menu for normal message function openMessageContext(selectedText) { const contextMenu = messageDelegateContextMenu.createObject(root, { selectedText: selectedText, author: root.author, eventId: root.eventId, source: root.jsonSource, eventType: root.delegateType, plainText: root.plainText, }); contextMenu.open(); } function setHoverActionsToDelegate() { if (ListView.view.setHoverActionsToDelegate) { ListView.view.setHoverActionsToDelegate(root) } } DelegateSizeHelper { id: timelineDelegateSizeHelper startBreakpoint: Kirigami.Units.gridUnit * 46 endBreakpoint: Kirigami.Units.gridUnit * 66 startPercentWidth: 100 endPercentWidth: Config.compactLayout ? 100 : 85 maxWidth: Config.compactLayout ? -1 : Kirigami.Units.gridUnit * 60 parentWidth: root.parent ? root.parent.width - (Config.compactLayout && root.ListView.view.width >= Kirigami.Units.gridUnit * 20 ? Kirigami.Units.gridUnit * 2 + Kirigami.Units.largeSpacing : 0) : 0 } DelegateSizeHelper { id: bubbleSizeHelper startBreakpoint: Kirigami.Units.gridUnit * 25 endBreakpoint: Kirigami.Units.gridUnit * 40 startPercentWidth: Config.compactLayout ? 100 : 90 endPercentWidth: Config.compactLayout ? 100 : 60 parentWidth: mainContainer.availableWidth - (Config.showAvatarInTimeline ? avatar.width + bubble.anchors.leftMargin : 0) } }