feat: Generate link previews in StatusChatInput - introduce ChatInputLinksPreviewArea

This component is a wrapper for LinkPreviewMiniCard and StatusChatInputImageArea. The purpose of this component is to arrange the cards in a row layout and provide scrolling behaviour. This component also has an opacity mask that will provide a fade out appearance when the items are scrolled out of view.

+ adding storybook page
+ integrate ChatInputLinksPreviewArea in StatusChatInput
This commit is contained in:
Alex Jbanca 2023-09-26 17:16:53 +03:00 committed by Alex Jbanca
parent 2c8ad61947
commit 3ce9d66d25
7 changed files with 445 additions and 80 deletions

View File

@ -0,0 +1,154 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtGraphicalEffects 1.15
import StatusQ.Core.Theme 0.1
import shared.controls.chat 1.0
SplitView {
Pane {
SplitView.fillWidth: true
SplitView.fillHeight: true
Rectangle {
id: wrapper
anchors.fill: parent
color: Theme.palette.statusChatInput.secondaryBackgroundColor
ChatInputLinksPreviewArea {
id: chatInputLinkPreviewsArea
anchors.centerIn: parent
width: parent.width
imagePreviewModel: ["https://picsum.photos/200/300?random=1", "https://picsum.photos/200/300?random=1"]
linkPreviewModel: linkPreviewListModel
onLinkRemoved: linkPreviewListModel.remove(index)
}
}
}
Pane {
SplitView.fillWidth: true
SplitView.fillHeight: true
}
ListModel {
id: linkPreviewListModel
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=1"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: false
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=2"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: ""
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=3"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=4"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=5"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=6"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=7"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=8"
thumbnailDataUri: ""
}
ListElement {
url: "https://www.youtube.com/watch?v=9bZkp7q19f0"
unfurled: true
immutable: false
hostname: "youtube.com"
title: "PSY - GANGNAM STYLE(강남스타일) M/V"
description: ""
linkType: 0
thumbnailWidth: 480
thumbnailHeight: 360
thumbnailUrl: "https://picsum.photos/480/360?random=9"
thumbnailDataUri: ""
}
}
}
// category: Panels
// https://www.figma.com/file/Mr3rqxxgKJ2zMQ06UAKiWL/💬-ChatDesktop?type=design&node-id=22341-184809&mode=design&t=VWBVK4DOUxr1BmTp-0

View File

@ -75,6 +75,10 @@ SplitView {
id: fakeUsersModel
}
ListModel {
id: fakeLinksModel
}
SplitView {
orientation: Qt.Vertical
SplitView.fillWidth: true
@ -85,11 +89,13 @@ SplitView {
}
Loader {
id: chatInputLoader
active: rootStoreMock.ready && globalUtilsMock.ready
sourceComponent: StatusChatInput {
id: chatInput
property var globalUtils: globalUtilsMock.globalUtils
enabled: enabledCheckBox.checked
linkPreviewModel: fakeLinksModel
usersStore: QtObject {
readonly property var usersModel: fakeUsersModel
}
@ -122,18 +128,92 @@ SplitView {
text: "enabled"
checked: true
}
MenuSeparator {
Layout.fillWidth: true
}
UsersModelEditor {
id: modelEditor
Layout.fillWidth: true
Layout.fillHeight: true
model: fakeUsersModel
onRemoveClicked: fakeUsersModel.remove(index, 1)
onRemoveAllClicked: fakeUsersModel.clear()
onAddClicked: fakeUsersModel.append(modelEditor.getNewUser(fakeUsersModel.count))
TabBar {
id: bar
TabButton {
text: "Attachments"
}
TabButton {
text: "Users"
}
}
StackLayout {
currentIndex: bar.currentIndex
ColumnLayout {
id: attachmentsTab
Layout.fillWidth: true
Layout.fillHeight: true
Label {
text: "Images"
Layout.fillWidth: true
}
ComboBox {
id: imageNb
editable: true
model: 20
validator: IntValidator {bottom: 0; top: 20;}
focus: true
onCurrentIndexChanged: {
if(!chatInputLoader.item)
return
const urls = []
for (let i = 0; i < imageNb.currentIndex ; i++) {
urls.push("https://picsum.photos/200/300?random=" + i)
}
console.log(urls.length)
chatInputLoader.item.fileUrlsAndSources = urls
}
}
Label {
text: "Links"
Layout.fillWidth: true
}
ComboBox {
id: linksNb
editable: true
model: 20
validator: IntValidator {bottom: 0; top: 20;}
onCurrentIndexChanged: {
if(!chatInputLoader.item)
return
chatInputLoader.item.textInput.clear()
fakeLinksModel.clear()
for (let i = 0; i < linksNb.currentIndex ; i++) {
const url = "https://www.youtube.com/watch?v=9bZkp7q19f0" + Math.floor(Math.random() * 100)
chatInputLoader.item.textInput.append(url + "\n")
fakeLinksModel.append({
url: url,
unfurled: Math.floor(Math.random() * 2),
immutable: false,
hostname: Math.floor(Math.random() * 2) ? "youtube.com" : "",
title: "PSY - GANGNAM STYLE(강남스타일) M/V",
description: "This is the description of the link",
linkType: Math.floor(Math.random() * 3),
thumbnailWidth: 480,
thumbnailHeight: 360,
thumbnailUrl: "https://picsum.photos/480/360?random=1",
thumbnailDataUri: ""
})
}
}
}
}
UsersModelEditor {
id: modelEditor
Layout.fillWidth: true
Layout.fillHeight: true
model: fakeUsersModel
onRemoveClicked: fakeUsersModel.remove(index, 1)
onRemoveAllClicked: fakeUsersModel.clear()
onAddClicked: fakeUsersModel.append(modelEditor.getNewUser(fakeUsersModel.count))
}
}
Label {
text: "Attachments"
Layout.fillWidth: true
}
}
}

View File

@ -215,31 +215,6 @@ Item {
}
}
// This is a non-designed preview of unfurled urls.
// Should be replaced with a proper UI when it's ready.
//
// StatusListView {
// Layout.fillWidth: true
// Layout.maximumHeight: 200
// Layout.margins: Style.current.smallPadding
// // For a vertical list bind the imlicitHeight to contentHeight
// implicitHeight: contentHeight
// spacing: 10
// model: d.activeChatContentModule.inputAreaModule.linkPreviewModel
// delegate: StatusBaseText {
// width: ListView.view.width
// wrapMode: Text.WordWrap
// text: {
// const icon = unfurled ? (hostname !== "" ? '' : '') : '👀'
// const thumbnailInfo = `thumbnail: (${thumbnailWidth}*${thumbnailHeight}, url: ${thumbnailUrl.length} symbols, data: ${thumbnailDataUri.length} symbols)`
// return `${icon} ${url} (hostname: ${hostname}): ${title}\ndescription: ${description}\n${thumbnailInfo}`
// }
// }
// }
RowLayout {
Layout.fillWidth: true
Layout.margins: Style.current.smallPadding
@ -268,6 +243,7 @@ Item {
store: root.rootStore
usersStore: d.activeUsersStore
linkPreviewModel: d.activeChatContentModule.inputAreaModule.linkPreviewModel
textInput.placeholderText: {
if (!channelPostRestrictions.visible) {
@ -290,9 +266,10 @@ Item {
chatType: root.activeChatType
textInput.onTextChanged: {
d.updateLinkPreviews()
if (!!d.activeChatContentModule)
if (!!d.activeChatContentModule) {
d.activeChatContentModule.inputAreaModule.preservedProperties.text = textInput.text
d.updateLinkPreviews()
}
}
onReplyMessageIdChanged: {
@ -337,6 +314,9 @@ Item {
onKeyUpPress: {
d.activeMessagesStore.setEditModeOnLastMessage(root.rootStore.userProfileInst.pubKey)
}
onLinkPreviewRemoved: (link) => d.activeChatContentModule.inputAreaModule.removeLinkPreview(link)
onLinkPreviewReloaded: (link) => d.activeChatContentModule.inputAreaModule.reloadLinkPreview(link)
}
ChatPermissionQualificationPanel {

View File

@ -0,0 +1,153 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtGraphicalEffects 1.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import shared.status 1.0
import shared.controls.chat 1.0
import SortFilterProxyModel 0.2
Control {
id: root
required property var imagePreviewModel
required property var linkPreviewModel
readonly property alias hoveredUrl: d.hoveredUrl
readonly property int contentItemsCount: imagePreviewModel.length + d.filteredModel.count
signal imageRemoved(int index)
signal imageClicked(var chatImage)
signal linkReload(string link)
signal linkClicked(string link)
signal linkRemoved(string link)
horizontalPadding: 12
topPadding: 12
contentItem: Item {
id: opacityMaskWrapper
anchors.fill: parent
implicitWidth: flickable.implicitWidth
implicitHeight: flickable.implicitHeight
opacity: 0
Flickable {
id: flickable
anchors.fill: parent
anchors.leftMargin: root.leftPadding
anchors.rightMargin: root.rightPadding
anchors.bottomMargin: root.bottomPadding
anchors.topMargin: root.topPadding
implicitHeight: contentHeight
implicitWidth: contentWidth
contentWidth: layout.width
contentHeight: layout.height
RowLayout {
id: layout
spacing: 8
StatusChatInputImageArea {
id: imageArea
Layout.preferredHeight: 64
spacing: layout.spacing
imageSource: imagePreviewModel
onImageClicked: root.imageClicked(chatImage)
onImageRemoved: root.imageRemoved(index)
visible: !!imagePreviewModel && imagePreviewModel.length > 0
}
Repeater {
model: d.filteredModel
delegate: LinkPreviewMiniCard {
// Model properties
required property string title
required property string url
required property bool unfurled
required property bool immutable
required property string hostname
required property string description
required property int linkType
required property int thumbnailWidth
required property int thumbnailHeight
required property string thumbnailUrl
required property string thumbnailDataUri
required property int index
Layout.preferredHeight: 64
titleStr: title
domain: hostname //TODO: use domain when available
favIconUrl: thumbnailImageUrl //TODO: use favicon when available
communityName: "" //TODO: add community info when available
channelName: "" //TODO: add community info when available
thumbnailImageUrl: thumbnailDataUri.length > 0 ? thumbnailDataUri : thumbnailUrl
type: linkType === 0 ? LinkPreviewMiniCard.Type.Link : LinkPreviewMiniCard.Type.Image
previewState: unfurled && hostname != "" ? LinkPreviewMiniCard.State.Loaded :
unfurled && hostname === "" ? LinkPreviewMiniCard.State.LoadingFailed :
!unfurled ? LinkPreviewMiniCard.State.Loading : LinkPreviewMiniCard.State.Invalid
onClose: root.linkRemoved(url)
onRetry: root.linkReload(url)
onClicked: root.linkClicked(url)
onContainsMouseChanged: {
if (containsMouse) {
d.hoveredUrl = url
} else if (d.hoveredUrl === url) {
d.hoveredUrl = ""
}
}
Component.onDestruction: {
if(d.hoveredUrl === url) {
d.hoveredUrl = ""
}
}
}
}
}
}
}
LinearGradient {
id: horizontalClipMask
anchors.fill: opacityMaskWrapper
visible: false
start: Qt.point(0 , 0)
end: Qt.point(horizontalClipMask.width, 0)
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: root.horizontalPadding / horizontalClipMask.width; color: "white" }
GradientStop { position: 1 - root.horizontalPadding / horizontalClipMask.width; color: "white" }
GradientStop { position: 1; color: "transparent" }
}
}
OpacityMask {
anchors.fill: opacityMaskWrapper
source: opacityMaskWrapper
maskSource: horizontalClipMask
}
QtObject {
id: d
property string hoveredUrl: ""
property SortFilterProxyModel filteredModel: SortFilterProxyModel {
id: filteredModel
sourceModel: root.linkPreviewModel
filters: [
ExpressionFilter {
expression: { return !model.immutable || model.unfurled } // Filter out immutable links that haven't been unfurled yet
}
]
}
}
}

View File

@ -1,4 +1,6 @@
CalloutCard 1.0 CalloutCard.qml
CalloutOpacityMask 1.0 CalloutOpacityMask.qml
ChatInputLinksPreviewArea 1.0 ChatInputLinksPreviewArea.qml
FetchMoreMessagesButton 1.0 FetchMoreMessagesButton.qml
GapComponent 1.0 GapComponent.qml
LinkPreviewCard 1.0 LinkPreviewCard.qml

View File

@ -6,6 +6,7 @@ import QtQuick.Dialogs 1.3
import utils 1.0
import shared 1.0
import shared.controls.chat 1.0
import shared.panels 1.0
import shared.popups 1.0
import shared.stores 1.0
@ -27,6 +28,8 @@ Rectangle {
signal stickerSelected(string hashId, string packId, string url)
signal sendMessage(var event)
signal keyUpPress()
signal linkPreviewRemoved(string link)
signal linkPreviewReloaded(string link)
property var usersStore
property var store
@ -56,6 +59,8 @@ Rectangle {
property var fileUrlsAndSources: []
property var linkPreviewModel: null
property var imageErrorMessageLocation: StatusChatInput.ImageErrorMessageLocation.Top // TODO: Remove this property?
property alias suggestions: suggestionsBox
@ -845,7 +850,6 @@ Rectangle {
function resetImageArea() {
isImage = false;
control.fileUrlsAndSources = []
imageArea.imageSource = [];
for (let i=0; i<validators.children.length; i++) {
const validator = validators.children[i]
validator.images = []
@ -866,8 +870,8 @@ Rectangle {
if (!imagePaths || !imagePaths.length) {
return []
}
// needed because imageArea.imageSource is not a normal js array
const existing = (imageArea.imageSource || []).map(x => x.toString())
// needed because control.fileUrlsAndSources is not a normal js array
const existing = (control.fileUrlsAndSources || []).map(x => x.toString())
let validImages = Utils.deduplicate(existing.concat(imagePaths))
for (let i=0; i<validators.children.length; i++) {
const validator = validators.children[i]
@ -879,8 +883,7 @@ Rectangle {
function showImageArea(imagePathsOrData) {
isImage = imagePathsOrData.length > 0
imageArea.imageSource = imagePathsOrData
control.fileUrlsAndSources = imageArea.imageSource
control.fileUrlsAndSources = imagePathsOrData
}
// Use this to validate and show the images. The concatenation of previous selected images is done automatically
@ -1162,21 +1165,26 @@ Rectangle {
}
}
StatusChatInputImageArea {
id: imageArea
ChatInputLinksPreviewArea {
id: linkPreviewArea
Layout.fillWidth: true
Layout.leftMargin: Style.current.halfPadding
Layout.rightMargin: Style.current.halfPadding
visible: isImage
onImageClicked: {
Global.openImagePopup(chatImage)
}
onImageRemoved: {
if (control.fileUrlsAndSources.length > index && control.fileUrlsAndSources[index]) {
control.fileUrlsAndSources.splice(index, 1)
visible: contentItemsCount > 0
horizontalPadding: 12
topPadding: 12
imagePreviewModel: control.fileUrlsAndSources
linkPreviewModel: control.linkPreviewModel
onImageRemoved: (index) => {
//Just do a copy and replace the whole thing because it's a plain JS array and thre's no signal when a single item is removed
let urls = control.fileUrlsAndSources
if (urls.length > index && urls[index]) {
urls.splice(index, 1)
}
showImageArea(control.fileUrlsAndSources)
control.fileUrlsAndSources = urls
}
onImageClicked: (chatImage) => Global.openImagePopup(chatImage)
onLinkReload: (link) => control.linkPreviewReloaded(link)
onLinkRemoved: (link) => control.linkPreviewRemoved(link)
onLinkClicked: (link) => Global.openLink(link)
}
RowLayout {

View File

@ -4,27 +4,31 @@ import QtQuick.Controls 2.13
import utils 1.0
import shared 1.0
import shared.controls.chat 1.0
import shared.panels 1.0
Row {
id: imageArea
spacing: 0
spacing: Style.current.halfPadding
signal imageRemoved(int index)
signal imageClicked(var chatImage)
property bool leftTail: true
property alias imageSource: rptImages.model
Repeater {
id: rptImages
Item {
height: Style.current.halfPadding * 2 + chatImage.height + closeBtn.height / 3
width: chatImage.width + closeBtn.width / 3
height: chatImage.height
width: chatImage.width
Image {
id: chatImage
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.bottom: parent.bottom
width: 64
height: 64
fillMode: Image.PreserveAspectCrop
@ -34,26 +38,10 @@ Row {
cache: false
source: modelData
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Item {
width: chatImage.width
height: chatImage.height
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
width: chatImage.width
height: chatImage.height
radius: 16
}
Rectangle {
anchors.bottom: parent.bottom
anchors.right: parent.right
width: 32
height: 32
radius: 4
}
}
layer.effect: CalloutOpacityMask {
width: parent.width
height: parent.height
leftTail: imageArea.leftTail
}
}