diff --git a/src/qml/Component/Timeline/AudioDelegate.qml b/src/qml/Component/Timeline/AudioDelegate.qml index 44120bbe5..e3a8a3125 100644 --- a/src/qml/Component/Timeline/AudioDelegate.qml +++ b/src/qml/Component/Timeline/AudioDelegate.qml @@ -13,9 +13,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for an audio message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/Timeline/EncryptedDelegate.qml b/src/qml/Component/Timeline/EncryptedDelegate.qml index 133a49a81..67625c6a8 100644 --- a/src/qml/Component/Timeline/EncryptedDelegate.qml +++ b/src/qml/Component/Timeline/EncryptedDelegate.qml @@ -10,9 +10,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for an encrypted message that can't be decrypted. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: encryptedDelegate innerObject: TextEdit { diff --git a/src/qml/Component/Timeline/EventDelegate.qml b/src/qml/Component/Timeline/EventDelegate.qml index 69e3075d4..1284970be 100644 --- a/src/qml/Component/Timeline/EventDelegate.qml +++ b/src/qml/Component/Timeline/EventDelegate.qml @@ -22,21 +22,21 @@ DelegateChooser { DelegateChoice { roleValue: DelegateType.Emote - delegate: MessageDelegate { + delegate: TextDelegate { connection: root.connection } } DelegateChoice { roleValue: DelegateType.Message - delegate: MessageDelegate { + delegate: TextDelegate { connection: root.connection } } DelegateChoice { roleValue: DelegateType.Notice - delegate: MessageDelegate { + delegate: TextDelegate { connection: root.connection } } diff --git a/src/qml/Component/Timeline/FileDelegate.qml b/src/qml/Component/Timeline/FileDelegate.qml index 73a24cebe..1c8096b14 100644 --- a/src/qml/Component/Timeline/FileDelegate.qml +++ b/src/qml/Component/Timeline/FileDelegate.qml @@ -13,9 +13,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for an file message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/Timeline/ImageDelegate.qml b/src/qml/Component/Timeline/ImageDelegate.qml index 80caf7f4e..2297e0ec4 100644 --- a/src/qml/Component/Timeline/ImageDelegate.qml +++ b/src/qml/Component/Timeline/ImageDelegate.qml @@ -14,9 +14,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for an image message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/Timeline/LiveLocationDelegate.qml b/src/qml/Component/Timeline/LiveLocationDelegate.qml index d9efef775..7faa5689b 100644 --- a/src/qml/Component/Timeline/LiveLocationDelegate.qml +++ b/src/qml/Component/Timeline/LiveLocationDelegate.qml @@ -14,9 +14,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for a location message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root property alias room: liveLocationModel.room diff --git a/src/qml/Component/Timeline/LocationDelegate.qml b/src/qml/Component/Timeline/LocationDelegate.qml index 19658c28c..29644b40e 100644 --- a/src/qml/Component/Timeline/LocationDelegate.qml +++ b/src/qml/Component/Timeline/LocationDelegate.qml @@ -13,9 +13,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for a location message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/Timeline/MessageDelegate.qml b/src/qml/Component/Timeline/MessageDelegate.qml index 9191396d7..111c9c83b 100644 --- a/src/qml/Component/Timeline/MessageDelegate.qml +++ b/src/qml/Component/Timeline/MessageDelegate.qml @@ -1,76 +1,583 @@ -// SPDX-FileCopyrightText: 2021 Tobias Fella +// 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 Qt.labs.qmlmodels 1.0 +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 A timeline delegate for an text message. + * @brief The base delegate for all messages in the timeline. * - * @inherit TimelineContainer + * 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. */ -TimelineContainer { +TimelineDelegate { id: root /** - * @brief The link preview properties. - * - * This is a list or object containing the following: - * - url - The URL being previewed. - * - loaded - Whether the URL preview has been loaded. - * - title - the title of the URL preview. - * - description - the description of the URL preview. - * - imageSource - a source URL for the preview image. - * - * @note An empty link previewer should be passed if there are no links to - * preview. - */ - required property var linkPreview + * @brief The index of the delegate in the model. + */ + required property var index /** - * @brief Whether there are any links to preview. + * @brief The matrix ID of the message event. + */ + required property string eventId + + /** + * @brief The timestamp of the message. + */ + required property var time + + /** + * @brief The timestamp of the message as a string. + */ + required property string timeString + + /** + * @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 Quotient::User object for the author. + * + * @sa Quotient::User + */ + required property var author + + /** + * @brief Whether the author should be shown. + */ + required property bool showAuthor + + /** + * @brief Whether the author should always be shown. + * + * This is primarily used when these delegates are used in a filtered list of + * events rather than a sequential timeline, e.g. the media model view. + * + * @note This setting still respects the avatar configuration settings. + */ + property bool alwaysShowAuthor: false + + /** + * @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 Quotient::User object for the reply author. + * + * @sa Quotient::User + */ + required property var replyAuthor + + /** + * @brief The delegate type of the message replied to. + */ + required property int replyDelegateType + + /** + * @brief The display text of the message replied to. + */ + required property string replyDisplay + + /** + * @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 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 + + required property NeoChatConnection connection + + /** + * @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: eventID => 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 the delegate should always stretch to the maximum availabel width. + */ + property bool alwaysMaxWidth: false + + /** + * @brief Whether local user messages should be aligned right. + * + * TODO: make private + */ + property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout && !alwaysMaxWidth + + /** + * @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 + } + + /** + * @brief The width available to the bubble content. */ - required property bool showLinkPreview + property alias contentMaxWidth: bubbleSizeHelper.currentWidth - onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, display, label.selectedText) + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing - innerObject: ColumnLayout { - Layout.maximumWidth: root.contentMaxWidth - RichLabel { - id: label + SectionDelegate { + id: sectionDelegate Layout.fillWidth: true - visible: currentRoom.chatBoxEditId !== root.eventId + visible: root.showSection + labelText: root.section + colorSet: Config.compactLayout || root.alwaysMaxWidth ? Kirigami.Theme.View : Kirigami.Theme.Window + } + QQC2.ItemDelegate { + id: mainContainer - isReply: root.isReply + Layout.fillWidth: true + Layout.topMargin: root.showAuthor || root.alwaysShowAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.rightMargin: Kirigami.Units.smallSpacing - textMessage: root.display + implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height) + + Component.onCompleted: { + if (root.isReply && root.replyDelegateType === DelegateType.Other) { + 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 || root.alwaysShowAuthor) && + Config.showAvatarInTimeline && + (Config.compactLayout || !showUserMessageOnRight) + name: root.author.displayName + source: root.author.avatarSource + color: root.author.color + + MouseArea { + anchors.fill: parent + onClicked: { + RoomManager.visitUser(root.author.object, "mention") + } + 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 || root.alwaysMaxWidth ? 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 || root.alwaysShowAuthor + + 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: { + RoomManager.visitUser(root.author.object, "mention") + } + } + } + QQC2.Label { + id: timeLabel + + text: root.timeString + 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 && root.replyDelegateType !== DelegateType.Other + visible: active + + sourceComponent: ReplyComponent { + author: root.replyAuthor + type: root.replyDelegateType + display: root.replyDisplay + mediaInfo: root.replyMediaInfo + contentMaxWidth: bubbleSizeHelper.currentWidth + } + + 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 || root.alwaysMaxWidth) + color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) + radius: Kirigami.Units.smallSpacing + } + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: root.openContextMenu() + } TapHandler { - enabled: !label.hoveredLink acceptedButtons: Qt.LeftButton onLongPressed: root.openContextMenu() } } - Loader { - Layout.fillWidth: true - Layout.minimumHeight: item ? item.minimumHeight : -1 - Layout.preferredWidth: item ? item.preferredWidth : -1 - visible: currentRoom.chatBoxEditId === root.eventId - active: visible - sourceComponent: MessageEditComponent { - room: currentRoom - messageId: root.eventId - } + + 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) } - LinkPreviewDelegate { - Layout.fillWidth: true - active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview && !root.linkPreview.empty - linkPreviewer: root.linkPreview - indicatorEnabled: root.isVisibleInTimeline() + AvatarFlow { + Layout.alignment: Qt.AlignRight + Layout.rightMargin: Kirigami.Units.largeSpacing + visible: root.showReadMarkers + model: root.readMarkers + toolTipText: root.readMarkersString + excessAvatars: root.excessReadMarkers + } + + DelegateSizeHelper { + id: bubbleSizeHelper + startBreakpoint: Kirigami.Units.gridUnit * 25 + endBreakpoint: Kirigami.Units.gridUnit * 40 + startPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 90 + endPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 60 + + parentWidth: mainContainer.availableWidth - (Config.showAvatarInTimeline ? avatar.width + bubble.anchors.leftMargin : 0) + } + } + + function isVisibleInTimeline() { + let yoff = Math.round(y - ListView.view.contentY); + return (yoff + height > 0 && yoff < ListView.view.height) + } + + function setHoverActionsToDelegate() { + if (ListView.view.setHoverActionsToDelegate) { + ListView.view.setHoverActionsToDelegate(root) } } } diff --git a/src/qml/Component/Timeline/PollDelegate.qml b/src/qml/Component/Timeline/PollDelegate.qml index ad082b9ef..144e32993 100644 --- a/src/qml/Component/Timeline/PollDelegate.qml +++ b/src/qml/Component/Timeline/PollDelegate.qml @@ -12,9 +12,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for a poll message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/Timeline/ReadMarkerDelegate.qml b/src/qml/Component/Timeline/ReadMarkerDelegate.qml index 51cc5a86a..a457d7dad 100644 --- a/src/qml/Component/Timeline/ReadMarkerDelegate.qml +++ b/src/qml/Component/Timeline/ReadMarkerDelegate.qml @@ -10,86 +10,49 @@ import org.kde.kirigami 2.15 as Kirigami import org.kde.neochat 1.0 -QQC2.ItemDelegate { +TimelineDelegate { id: root + contentItem: QQC2.ItemDelegate { + padding: Kirigami.Units.largeSpacing + topInset: Kirigami.Units.largeSpacing + topPadding: Kirigami.Units.largeSpacing * 2 - padding: Kirigami.Units.largeSpacing - topInset: Kirigami.Units.largeSpacing - topPadding: Kirigami.Units.largeSpacing * 2 + property bool isTemporaryHighlighted: false - // extraWidth defines how the delegate can grow after the listView gets very wide - readonly property int extraWidth: parent ? (parent.width >= Kirigami.Units.gridUnit * 46 ? Math.min((parent.width - Kirigami.Units.gridUnit * 46), Kirigami.Units.gridUnit * 20) : 0) : 0 - readonly property int delegateMaxWidth: parent ? (Config.compactLayout ? parent.width - Kirigami.Units.largeSpacing * 2 : Math.min(parent.width - Kirigami.Units.largeSpacing * 2, Kirigami.Units.gridUnit * 40 + extraWidth)) : 0 + onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start() - property bool isTemporaryHighlighted: false + Timer { + id: temporaryHighlightTimer - onIsTemporaryHighlightedChanged: if (isTemporaryHighlighted) temporaryHighlightTimer.start() - - Timer { - id: temporaryHighlightTimer - - interval: 1500 - onTriggered: isTemporaryHighlighted = false - } - - width: delegateMaxWidth - anchors.leftMargin: Kirigami.Units.largeSpacing - anchors.rightMargin: Kirigami.Units.largeSpacing - - 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 - } + interval: 1500 + onTriggered: isTemporaryHighlighted = false } - ] - transitions: [ - Transition { - AnchorAnimation { - duration: Kirigami.Units.longDuration - easing.type: Easing.OutCubic - } + contentItem: QQC2.Label { + text: i18nc("Relative time since the room was last read", "Last read: %1", time) } - ] - contentItem: QQC2.Label { - text: i18nc("Relative time since the room was last read", "Last read: %1", time) - } - - background: Kirigami.ShadowedRectangle { - id: readMarkerBackground - color: { - if (root.isTemporaryHighlighted) { - return Kirigami.Theme.positiveBackgroundColor - } else { - return Kirigami.Theme.backgroundColor + background: Kirigami.ShadowedRectangle { + id: readMarkerBackground + color: { + if (root.isTemporaryHighlighted) { + return Kirigami.Theme.positiveBackgroundColor + } else { + return Kirigami.Theme.backgroundColor + } } - } - Kirigami.Theme.inherit: false - Kirigami.Theme.colorSet: Kirigami.Theme.View - opacity: root.isTemporaryHighlighted ? 1 : 0.6 - radius: Kirigami.Units.smallSpacing - shadow.size: Kirigami.Units.smallSpacing - shadow.color: Qt.rgba(0.0, 0.0, 0.0, 0.10) - border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15) - border.width: 1 + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.View + opacity: root.isTemporaryHighlighted ? 1 : 0.6 + radius: Kirigami.Units.smallSpacing + shadow.size: Kirigami.Units.smallSpacing + shadow.color: Qt.rgba(0.0, 0.0, 0.0, 0.10) + border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15) + border.width: 1 - Behavior on color { - ColorAnimation {target: readMarkerBackground; duration: Kirigami.Units.veryLongDuration; easing.type: Easing.InOutCubic} + Behavior on color { + ColorAnimation {target: readMarkerBackground; duration: Kirigami.Units.veryLongDuration; easing.type: Easing.InOutCubic} + } } } } diff --git a/src/qml/Component/Timeline/SectionDelegate.qml b/src/qml/Component/Timeline/SectionDelegate.qml index 98497b353..bc1917b76 100644 --- a/src/qml/Component/Timeline/SectionDelegate.qml +++ b/src/qml/Component/Timeline/SectionDelegate.qml @@ -18,6 +18,8 @@ QQC2.ItemDelegate { property int colorSet: Kirigami.Theme.Window + leftPadding: 0 + rightPadding: 0 topPadding: Kirigami.Units.largeSpacing bottomPadding: 0 // Note not 0 by default diff --git a/src/qml/Component/Timeline/StateComponent.qml b/src/qml/Component/Timeline/StateComponent.qml index 09b3cd34a..73ec70771 100644 --- a/src/qml/Component/Timeline/StateComponent.qml +++ b/src/qml/Component/Timeline/StateComponent.qml @@ -10,17 +10,44 @@ import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents import org.kde.neochat 1.0 +/** + * @brief A component for visualising a single state event + */ RowLayout { id: root - property var name - property alias avatar: stateAvatar.source - property var color - property alias text: label.text - signal avatarClicked() - signal linkClicked(string link) + /** + * @brief All model roles as a map with the property names as the keys. + */ + required property var modelData - implicitHeight: Math.max(label.contentHeight, stateAvatar.implicitHeight) + /** + * @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 Quotient::User object for the author. + * + * @sa Quotient::User + */ + property var author: modelData.author + + /** + * @brief The displayname for the event's sender; for name change events, the old displayname. + */ + property string authorDisplayName: modelData.authorDisplayName + + /** + * @brief The display text for the state event. + */ + property string text: modelData.text KirigamiComponents.Avatar { id: stateAvatar @@ -28,21 +55,22 @@ RowLayout { Layout.preferredWidth: Kirigami.Units.iconSizes.small Layout.preferredHeight: Kirigami.Units.iconSizes.small - name: root.name - color: root.color + source: root.author?.avatarUrl ?? "" + name: root.author?.displayName ?? "" + color: root.author?.color ?? undefined Rectangle { radius: height height: 4 width: 4 - color: root.color + color: root.author.color anchors.centerIn: parent } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: avatarClicked() + onClicked: RoomManager.openResource("https://matrix.to/#/" + root.author.id) } } @@ -50,8 +78,9 @@ RowLayout { id: label Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true + text: `${root.authorDisplayName} ${root.text}` wrapMode: Text.WordWrap textFormat: Text.RichText - onLinkActivated: link => linkClicked(link) + onLinkActivated: link => RoomManager.openResource(link) } } diff --git a/src/qml/Component/Timeline/StateDelegate.qml b/src/qml/Component/Timeline/StateDelegate.qml index 6eb2ebb92..bb9ed4282 100644 --- a/src/qml/Component/Timeline/StateDelegate.qml +++ b/src/qml/Component/Timeline/StateDelegate.qml @@ -10,72 +10,88 @@ import org.kde.kirigamiaddons.labs.components 1.0 as KirigamiComponents import org.kde.neochat 1.0 -QQC2.Control { +/** + * @brief A timeline delegate for visualising an aggregated list of consecutive state events. + * + * @inherit TimelineDelegate + */ +TimelineDelegate { id: root - readonly property bool sectionVisible: model.showSection + /** + * @brief List of the first 5 unique authors of the aggregated state event. + */ + required property var authorList - width: stateDelegateSizeHelper.currentWidth + /** + * @brief The number of unique authors beyond the first 5. + */ + required property string excessAuthors - 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 - } - } - ] + /** + * @brief Single line aggregation of all the state events. + */ + required property string aggregateDisplay - transitions: [ - Transition { - AnchorAnimation{duration: Kirigami.Units.longDuration; easing.type: Easing.OutCubic} - } - ] + /** + * @brief List of state events in the aggregated state. + */ + required property var stateEvents - height: columnLayout.implicitHeight + columnLayout.anchors.topMargin + /** + * @brief Whether the section header should be shown. + */ + required property bool showSection - ColumnLayout { - id: columnLayout + /** + * @brief The date of the event as a string. + */ + required property string section - property bool folded: true + /** + * @brief A model with the first 5 other user read markers for this message. + */ + required property var readMarkers - spacing: sectionVisible ? Kirigami.Units.largeSpacing : 0 - anchors.top: parent.top - anchors.topMargin: sectionVisible ? 0 : Kirigami.Units.largeSpacing - anchors.left: parent.left - anchors.right: parent.right + /** + * @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 Whether the state event is folded to a single line. + */ + property bool folded: true + + contentItem: ColumnLayout { SectionDelegate { - id: sectionDelegate Layout.fillWidth: true - visible: sectionVisible - labelText: sectionVisible ? section : "" + visible: root.showSection + labelText: root.section colorSet: Config.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window } RowLayout { Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0) - Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.leftMargin: Kirigami.Units.largeSpacing * 1.5 + Layout.rightMargin: Kirigami.Units.largeSpacing * 1.5 visible: stateEventRepeater.count !== 1 Flow { - visible: columnLayout.folded + visible: root.folded spacing: -Kirigami.Units.iconSizes.small / 2 Repeater { - model: authorList + model: root.authorList delegate: Item { id: avatarDelegate @@ -108,8 +124,8 @@ QQC2.Control { QQC2.Label { id: excessAuthorsLabel - text: model.excessAuthors - visible: model.excessAuthors !== "" + text: root.excessAuthors + visible: root.excessAuthors !== "" color: Kirigami.Theme.textColor horizontalAlignment: Text.AlignHCenter background: Kirigami.ShadowedRectangle { @@ -141,9 +157,9 @@ QQC2.Control { } QQC2.Label { Layout.fillWidth: true - visible: columnLayout.folded + visible: root.folded - text: aggregateDisplay + text: root.aggregateDisplay elide: Qt.ElideRight textFormat: Text.RichText wrapMode: Text.WordWrap @@ -151,59 +167,41 @@ QQC2.Control { } Item { Layout.fillWidth: true - visible: !columnLayout.folded + implicitHeight: foldButton.implicitHeight + visible: !root.folded } QQC2.ToolButton { + id: foldButton icon { - name: (!columnLayout.folded ? "go-up" : "go-down") + name: (!root.folded ? "go-up" : "go-down") width: Kirigami.Units.iconSizes.small height: Kirigami.Units.iconSizes.small } - onClicked: columnLayout.toggleFolded() + onClicked: root.toggleFolded() } } Repeater { id: stateEventRepeater - model: stateEvents + model: root.stateEvents delegate: StateComponent { Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.gridUnit * 1.5 + Kirigami.Units.smallSpacing * 1.5 + (Config.compactLayout ? Kirigami.Units.largeSpacing * 1.25 : 0) - Layout.rightMargin: Kirigami.Units.largeSpacing - visible: !columnLayout.folded || stateEventRepeater.count === 1 - - name: modelData.author.displayName - avatar: modelData.author.avatarSource - color: modelData.author.color - text: `${modelData.authorDisplayName} ${modelData.text}` - - onAvatarClicked: RoomManager.openResource("https://matrix.to/#/" + modelData.author.id) - onLinkClicked: link => RoomManager.openResource(link) + Layout.leftMargin: Kirigami.Units.largeSpacing * 1.5 + Layout.rightMargin: Kirigami.Units.largeSpacing * 1.5 + visible: !root.folded || stateEventRepeater.count === 1 } } - - function toggleFolded() { - folded = !folded - foldedChanged() - } AvatarFlow { Layout.alignment: Qt.AlignRight - Layout.rightMargin: Kirigami.Units.largeSpacing - visible: showReadMarkers - model: readMarkers - toolTipText: readMarkersString - excessAvatars: excessReadMarkers + visible: root.showReadMarkers + model: root.readMarkers + toolTipText: root.readMarkersString + excessAvatars: root.excessReadMarkers } } - DelegateSizeHelper { - id: stateDelegateSizeHelper - 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 : 0 + function toggleFolded() { + folded = !folded + foldedChanged() } } diff --git a/src/qml/Component/Timeline/TextDelegate.qml b/src/qml/Component/Timeline/TextDelegate.qml new file mode 100644 index 000000000..4293d7d98 --- /dev/null +++ b/src/qml/Component/Timeline/TextDelegate.qml @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2021 Tobias Fella +// SPDX-License-Identifier: GPL-3.0-only + +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +import Qt.labs.qmlmodels 1.0 + +import org.kde.neochat 1.0 + +/** + * @brief A timeline delegate for a text message. + * + * @inherit MessageDelegate + */ +MessageDelegate { + id: root + + /** + * @brief The link preview properties. + * + * This is a list or object containing the following: + * - url - The URL being previewed. + * - loaded - Whether the URL preview has been loaded. + * - title - the title of the URL preview. + * - description - the description of the URL preview. + * - imageSource - a source URL for the preview image. + * + * @note An empty link previewer should be passed if there are no links to + * preview. + */ + required property var linkPreview + + /** + * @brief Whether there are any links to preview. + */ + required property bool showLinkPreview + + onOpenContextMenu: RoomManager.viewEventMenu(eventId, author, delegateType, plainText, display, label.selectedText) + + innerObject: ColumnLayout { + Layout.maximumWidth: root.contentMaxWidth + RichLabel { + id: label + Layout.fillWidth: true + visible: currentRoom.chatBoxEditId !== root.eventId + + isReply: root.isReply + + textMessage: root.display + + TapHandler { + enabled: !label.hoveredLink + acceptedButtons: Qt.LeftButton + onLongPressed: root.openContextMenu() + } + } + Loader { + Layout.fillWidth: true + Layout.minimumHeight: item ? item.minimumHeight : -1 + Layout.preferredWidth: item ? item.preferredWidth : -1 + visible: currentRoom.chatBoxEditId === root.eventId + active: visible + sourceComponent: MessageEditComponent { + room: currentRoom + messageId: root.eventId + } + } + LinkPreviewDelegate { + Layout.fillWidth: true + active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && root.showLinkPreview && !root.linkPreview.empty + linkPreviewer: root.linkPreview + indicatorEnabled: root.isVisibleInTimeline() + } + } +} diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml deleted file mode 100644 index faf37b460..000000000 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ /dev/null @@ -1,617 +0,0 @@ -// 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 timestamp of the message as a string. - */ - required property string timeString - - /** - * @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 Quotient::User object for the author. - * - * @sa Quotient::User - */ - required property var author - - /** - * @brief Whether the author should be shown. - */ - required property bool showAuthor - - /** - * @brief Whether the author should always be shown. - * - * This is primarily used when these delegates are used in a filtered list of - * events rather than a sequential timeline, e.g. the media model view. - * - * @note This setting still respects the avatar configuration settings. - */ - property bool alwaysShowAuthor: false - - /** - * @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 Quotient::User object for the reply author. - * - * @sa Quotient::User - */ - required property var replyAuthor - - /** - * @brief The delegate type of the message replied to. - */ - required property int replyDelegateType - - /** - * @brief The display text of the message replied to. - */ - required property string replyDisplay - - /** - * @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 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 - - required property NeoChatConnection connection - - /** - * @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: eventID => 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 the delegate should always stretch to the maximum availabel width. - */ - property bool alwaysMaxWidth: false - - /** - * @brief Whether local user messages should be aligned right. - * - * TODO: make private - */ - property bool showUserMessageOnRight: Config.showLocalMessagesOnRight && root.author.isLocalUser && !Config.compactLayout && !alwaysMaxWidth - - /** - * @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 || root.alwaysMaxWidth ? "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 - colorSet: Config.compactLayout || root.alwaysMaxWidth ? Kirigami.Theme.View : Kirigami.Theme.Window - } - - QQC2.ItemDelegate { - id: mainContainer - - Layout.fillWidth: true - Layout.topMargin: root.showAuthor || root.alwaysShowAuthor ? Kirigami.Units.largeSpacing : (Config.compactLayout ? 1 : Kirigami.Units.smallSpacing) - Layout.leftMargin: Kirigami.Units.smallSpacing - Layout.rightMargin: Kirigami.Units.smallSpacing - - implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height) - - Component.onCompleted: { - if (root.isReply && root.replyDelegateType === DelegateType.Other) { - 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 || root.alwaysShowAuthor) && - Config.showAvatarInTimeline && - (Config.compactLayout || !showUserMessageOnRight) - name: root.author.displayName - source: root.author.avatarSource - color: root.author.color - - MouseArea { - anchors.fill: parent - onClicked: { - RoomManager.visitUser(root.author.object, "mention") - } - 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 || root.alwaysMaxWidth ? 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 || root.alwaysShowAuthor - - 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: { - RoomManager.visitUser(root.author.object, "mention") - } - } - } - QQC2.Label { - id: timeLabel - - text: root.timeString - 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 && root.replyDelegateType !== DelegateType.Other - visible: active - - sourceComponent: ReplyComponent { - author: root.replyAuthor - type: root.replyDelegateType - display: root.replyDisplay - mediaInfo: root.replyMediaInfo - contentMaxWidth: bubbleSizeHelper.currentWidth - } - - 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 || root.alwaysMaxWidth) - color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, Kirigami.Theme.highlightColor, 0.15) - radius: Kirigami.Units.smallSpacing - } - - TapHandler { - 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) - } - - 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 || root.alwaysMaxWidth ? 100 : 85 - maxWidth: Config.compactLayout || root.alwaysMaxWidth ? -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 || root.alwaysMaxWidth ? 100 : 90 - endPercentWidth: Config.compactLayout || root.alwaysMaxWidth ? 100 : 60 - - parentWidth: mainContainer.availableWidth - (Config.showAvatarInTimeline ? avatar.width + bubble.anchors.leftMargin : 0) - } -} diff --git a/src/qml/Component/Timeline/TimelineDelegate.qml b/src/qml/Component/Timeline/TimelineDelegate.qml new file mode 100644 index 000000000..60e355f53 --- /dev/null +++ b/src/qml/Component/Timeline/TimelineDelegate.qml @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 + +import org.kde.kirigami 2.15 as Kirigami + +import org.kde.neochat 1.0 + +/** + * @brief The base Item for all delegates in the timeline. + * + * This component handles the placing of the main content for a delegate in the + * timeline. The component is designed for all delegates, positioning them in the + * timeline with variable padding depending on the window width. + * + * This component also supports always setting the delegate to fill the available + * width in the timeline, e.g. in compact mode. + */ +Item { + id: root + + /** + * @brief The Item representing the delegate's main content. + */ + property Item contentItem + + /** + * @brief Whether the delegate should always stretch to the maximum available width. + */ + property bool alwaysMaxWidth: false + + /** + * @brief The padding to the left of the content. + */ + property real leftPadding: Kirigami.Units.largeSpacing + + /** + * @brief The padding to the right of the content. + */ + property real rightPadding: Config.compactLayout && root.ListView.view.width >= Kirigami.Units.gridUnit * 20 ? Kirigami.Units.gridUnit * 2 + Kirigami.Units.largeSpacing : Kirigami.Units.largeSpacing + + width: parent?.width + implicitHeight: contentItemParent.implicitHeight + + Item { + id: contentItemParent + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: state === "alignLeft" ? Kirigami.Units.largeSpacing : 0 + + state: Config.compactLayout || root.alwaysMaxWidth ? "alignLeft" : "alignCenter" + // Align left when in compact mode and center when using bubbles + states: [ + State { + name: "alignLeft" + AnchorChanges { + target: contentItemParent + anchors.horizontalCenter: undefined + anchors.left: parent ? parent.left : undefined + } + }, + State { + name: "alignCenter" + AnchorChanges { + target: contentItemParent + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined + anchors.left: undefined + } + } + ] + + width: (Config.compactLayout || root.alwaysMaxWidth ? root.width : delegateSizeHelper.currentWidth) - root.leftPadding - root.rightPadding + implicitHeight: root.contentItem?.implicitHeight ?? 0 + } + + DelegateSizeHelper { + id: delegateSizeHelper + startBreakpoint: Kirigami.Units.gridUnit * 46 + endBreakpoint: Kirigami.Units.gridUnit * 66 + startPercentWidth: 100 + endPercentWidth: 85 + maxWidth: Kirigami.Units.gridUnit * 60 + + parentWidth: root.width + } + + onContentItemChanged: { + if (!contentItem) { + return; + } + + contentItem.parent = contentItemParent; + contentItem.anchors.fill = contentItem.parent; + } +} diff --git a/src/qml/Component/Timeline/VideoDelegate.qml b/src/qml/Component/Timeline/VideoDelegate.qml index 758db5d3d..32d26f2f2 100644 --- a/src/qml/Component/Timeline/VideoDelegate.qml +++ b/src/qml/Component/Timeline/VideoDelegate.qml @@ -14,9 +14,9 @@ import org.kde.neochat 1.0 /** * @brief A timeline delegate for a video message. * - * @inherit TimelineContainer + * @inherit MessageDelegate */ -TimelineContainer { +MessageDelegate { id: root /** diff --git a/src/qml/Component/TimelineView.qml b/src/qml/Component/TimelineView.qml index 30e409f7a..923660fc4 100644 --- a/src/qml/Component/TimelineView.qml +++ b/src/qml/Component/TimelineView.qml @@ -88,7 +88,7 @@ QQC2.ScrollView { } } - // Not rendered because the sections are part of the TimelineContainer.qml, this is only so that items have the section property available for use by sectionBanner. + // Not rendered because the sections are part of the MessageDelegate.qml, this is only so that items have the section property available for use by sectionBanner. // This is due to the fact that the ListView verticalLayout is BottomToTop. // This also flips the sections which would appear at the bottom but for a timeline they still need to be at the top (bottom from the qml perspective). // There is currently no option to put section headings at the bottom in qml. @@ -110,10 +110,10 @@ QQC2.ScrollView { id: sectionBanner anchors.left: parent.left - anchors.leftMargin: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.x : 0 + anchors.leftMargin: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.contentItem.parent.x : 0 anchors.right: parent.right - maxWidth: Config.compactLayout ? messageListView.width : (messageListView.sectionBannerItem ? messageListView.sectionBannerItem.width - Kirigami.Units.largeSpacing * 2 : 0) + maxWidth: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.contentItem.width : 0 z: 3 visible: !!messageListView.sectionBannerItem && messageListView.sectionBannerItem.ListView.section !== "" && !Config.blur labelText: messageListView.sectionBannerItem ? messageListView.sectionBannerItem.ListView.section : "" diff --git a/src/res.qrc b/src/res.qrc index d4ac028c3..5231339a1 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -44,10 +44,11 @@ qml/Component/Devtools/RoomData.qml qml/Component/Devtools/ServerData.qml qml/Component/Emoji/EmojiPicker.qml + qml/Component/Timeline/TimelineDelegate.qml qml/Component/Timeline/ReplyComponent.qml qml/Component/Timeline/StateDelegate.qml qml/Component/Timeline/RichLabel.qml - qml/Component/Timeline/TimelineContainer.qml + qml/Component/Timeline/MessageDelegate.qml qml/Component/Timeline/SectionDelegate.qml qml/Component/Timeline/VideoDelegate.qml qml/Component/Timeline/ReactionDelegate.qml @@ -57,7 +58,7 @@ qml/Component/Timeline/ImageDelegate.qml qml/Component/Timeline/EncryptedDelegate.qml qml/Component/Timeline/EventDelegate.qml - qml/Component/Timeline/MessageDelegate.qml + qml/Component/Timeline/TextDelegate.qml qml/Component/Timeline/ReadMarkerDelegate.qml qml/Component/Timeline/PollDelegate.qml qml/Component/Timeline/MimeComponent.qml