Merge branch 'delegate-rework'

pull/1542/head
Nicolas Werner 1 year ago
commit 3a0d5788e1
No known key found for this signature in database
GPG Key ID: C8D75E610773F2D9
  1. 2
      .gitlab-ci.yml
  2. 13
      CMakeLists.txt
  3. 5
      resources/qml/Completer.qml
  4. 20
      resources/qml/ForwardCompleter.qml
  5. 30
      resources/qml/MatrixText.qml
  6. 403
      resources/qml/MessageView.qml
  7. 2
      resources/qml/Reactions.qml
  8. 18
      resources/qml/ReplyPopup.qml
  9. 2
      resources/qml/RoomList.qml
  10. 25
      resources/qml/Root.qml
  11. 334
      resources/qml/TimelineBubbleMessageStyle.qml
  12. 327
      resources/qml/TimelineDefaultMessageStyle.qml
  13. 266
      resources/qml/TimelineEvent.qml
  14. 98
      resources/qml/TimelineMetadata.qml
  15. 349
      resources/qml/TimelineRow.qml
  16. 164
      resources/qml/TimelineSectionHeader.qml
  17. 19
      resources/qml/TopBar.qml
  18. 33
      resources/qml/delegates/Encrypted.qml
  19. 47
      resources/qml/delegates/EncryptionEnabled.qml
  20. 36
      resources/qml/delegates/FileMessage.qml
  21. 30
      resources/qml/delegates/ImageMessage.qml
  22. 779
      resources/qml/delegates/MessageDelegate.qml
  23. 2
      resources/qml/delegates/PlayableMediaMessage.qml
  24. 41
      resources/qml/delegates/Redacted.qml
  25. 134
      resources/qml/delegates/Reply.qml
  26. 10
      resources/qml/delegates/TextMessage.qml
  27. 43
      resources/qml/ui/TimelineEffects.qml
  28. 356
      src/timeline/EventDelegateChooser.cpp
  29. 276
      src/timeline/EventDelegateChooser.h
  30. 4
      src/timeline/EventStore.cpp
  31. 2
      src/timeline/RoomlistModel.cpp
  32. 297
      src/timeline/TimelineModel.cpp
  33. 35
      src/timeline/TimelineModel.h
  34. 9
      src/ui/MxcAnimatedImage.cpp
  35. 7
      src/ui/MxcAnimatedImage.h
  36. 1
      src/voip/CallManager.cpp

@ -106,7 +106,6 @@ build-tw:
"pkgconfig"
"spdlog-devel"
"zlib-devel"
"libQt5PlatformHeaders-devel"
"cmake(re2)"
"cmake(Qt6Core)"
"cmake(Qt6DBus)"
@ -117,6 +116,7 @@ build-tw:
"cmake(Qt6Svg)"
"cmake(Qt6Widgets)"
"cmake(Qt6Gui)"
"qt6-qml-private-devel"
"pkgconfig(libcurl)"
"pkgconfig(libevent)"
"pkgconfig(gstreamer-webrtc-1.0)"

@ -357,6 +357,8 @@ set(SRC_FILES
src/timeline/DelegateChooser.h
src/timeline/EventStore.cpp
src/timeline/EventStore.h
src/timeline/EventDelegateChooser.cpp
src/timeline/EventDelegateChooser.h
src/timeline/InputBar.cpp
src/timeline/InputBar.h
src/timeline/Permissions.cpp
@ -693,7 +695,6 @@ set(QML_SOURCES
resources/qml/ChatPage.qml
resources/qml/CommunitiesList.qml
resources/qml/RoomList.qml
resources/qml/TimelineView.qml
resources/qml/Avatar.qml
resources/qml/Completer.qml
resources/qml/EncryptionIndicator.qml
@ -709,7 +710,12 @@ set(QML_SOURCES
resources/qml/Reactions.qml
resources/qml/ReplyPopup.qml
resources/qml/StatusIndicator.qml
resources/qml/TimelineRow.qml
resources/qml/TimelineEvent.qml
resources/qml/TimelineSectionHeader.qml
resources/qml/TimelineDefaultMessageStyle.qml
resources/qml/TimelineBubbleMessageStyle.qml
resources/qml/TimelineMetadata.qml
resources/qml/TimelineView.qml
resources/qml/TopBar.qml
resources/qml/QuickSwitcher.qml
resources/qml/ForwardCompleter.qml
@ -731,7 +737,6 @@ set(QML_SOURCES
resources/qml/delegates/Encrypted.qml
resources/qml/delegates/FileMessage.qml
resources/qml/delegates/ImageMessage.qml
resources/qml/delegates/MessageDelegate.qml
resources/qml/delegates/NoticeMessage.qml
resources/qml/delegates/Pill.qml
resources/qml/delegates/Placeholder.qml
@ -874,6 +879,7 @@ target_link_libraries(nheko PRIVATE
Qt::Gui
Qt::Multimedia
Qt::Qml
Qt::QmlPrivate
Qt::QuickControls2
qt6keychain
nlohmann_json::nlohmann_json
@ -966,3 +972,4 @@ if(UNIX AND NOT APPLE)
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
endif()
endif()
# vim: tabstop=4 shiftwidth=4 expandtab

@ -145,7 +145,6 @@ Control {
roleValue: "user"
RowLayout {
anchors.centerIn: centerRowContent ? parent : undefined
spacing: rowSpacing
@ -171,7 +170,6 @@ Control {
roleValue: "emoji"
RowLayout {
anchors.centerIn: parent
spacing: rowSpacing
@ -207,7 +205,6 @@ Control {
roleValue: "command"
RowLayout {
anchors.centerIn: parent
spacing: rowSpacing
@ -226,7 +223,6 @@ Control {
roleValue: "room"
RowLayout {
anchors.centerIn: centerRowContent ? parent : undefined
spacing: rowSpacing
@ -251,7 +247,6 @@ Control {
roleValue: "roomAliases"
RowLayout {
anchors.centerIn: parent
spacing: rowSpacing

@ -54,25 +54,9 @@ Popup {
Reply {
id: replyPreview
property var modelData: room ? room.getDump(mid, "") : {}
blurhash: modelData.blurhash ?? ""
body: modelData.body ?? ""
encryptionError: modelData.encryptionError ?? ""
eventId: modelData.eventId ?? ""
filename: modelData.filename ?? ""
filesize: modelData.filesize ?? ""
formattedBody: modelData.formattedBody ?? ""
isOnlyEmoji: modelData.isOnlyEmoji ?? false
originalWidth: modelData.originalWidth ?? 0
proportionalHeight: modelData.proportionalHeight ?? 1
type: modelData.type ?? MtxEvent.UnknownMessage
typeString: modelData.typeString ?? ""
url: modelData.url ?? ""
eventId: mid
userColor: TimelineManager.userColor(modelData.userId, palette.window)
userId: modelData.userId ?? ""
userName: modelData.userName ?? ""
width: parent.width
maxWidth: parent.width
}
MatrixTextField {
id: roomTextInput

@ -4,33 +4,33 @@
// TODO: using any Qt 6 API version will screw up the reply text color. We need to
// figure out a more permanent fix than just importing the old version.
import QtQuick 2.15
//import QtQuick 2.15
import QtQuick
import QtQuick.Controls
import im.nheko
TextEdit {
TextArea {
id: r
property alias cursorShape: cs.cursorShape
//leftInset: 0
//bottomInset: 0
//rightInset: 0
//topInset: 0
//leftPadding: 0
//bottomPadding: 0
//rightPadding: 0
//topPadding: 0
//background: null
ToolTip.text: hoveredLink
ToolTip.visible: hoveredLink || false
background: null
bottomInset: 0
bottomPadding: 0
// this always has to be enabled, otherwise you can't click links anymore!
//enabled: selectByMouse
color: palette.text
focus: false
leftInset: 0
leftPadding: 0
readOnly: true
rightInset: 0
rightPadding: 0
textFormat: TextEdit.RichText
topInset: 0
topPadding: 0
wrapMode: Text.Wrap
// Setting a tooltip delay makes the hover text empty .-.
@ -40,9 +40,9 @@ TextEdit {
}
onLinkActivated: Nheko.openLink(link)
//// propagate events up
//onPressAndHold: (event) => event.accepted = false
//onPressed: (event) => event.accepted = (event.button == Qt.LeftButton)
// propagate events up
onPressAndHold: event => event.accepted = false
onPressed: event => event.accepted = (event.button == Qt.LeftButton)
NhekoCursorShape {
id: cs

@ -20,12 +20,13 @@ Item {
property int availableWidth: width
property int padding: Nheko.paddingMedium
property string searchString: ""
property Room roommodel: room
// HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu
Connections {
function onHideMenu() {
messageContextMenu.close();
replyContextMenu.close();
messageContextMenuC.close();
replyContextMenuC.close();
}
target: MainWindow
@ -51,182 +52,35 @@ Item {
//onModelChanged: if (room) room.sendReset()
//reuseItems: true
boundsBehavior: Flickable.StopAtBounds
displayMarginBeginning: height / 2
displayMarginEnd: height / 2
displayMarginBeginning: height / 4
displayMarginEnd: height / 4
model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room
//pixelAligned: true
spacing: 2
verticalLayoutDirection: ListView.BottomToTop
delegate: Item {
id: wrapper
required property string blurhash
required property string body
required property string callType
required property var day
required property string duration
required property int encryptionError
required property string eventId
required property string filename
required property string filesize
required property string formattedBody
required property int index
required property bool isEditable
required property bool isEdited
required property bool isEncrypted
required property bool isOnlyEmoji
required property bool isSender
required property bool isStateEvent
required property int notificationlevel
required property int originalWidth
property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day)
property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent)
property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId)
required property double proportionalHeight
required property var reactions
required property int relatedEventCacheBuster
required property string replyTo
required property string roomName
required property string roomTopic
property bool scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
required property int status
required property string threadId
required property string thumbnailUrl
required property var timestamp
required property int trustlevel
required property int type
required property string typeString
required property string url
required property string userId
required property string userName
required property int userPowerlevel
ListView.delayRemove: true
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
height: (section.item?.height ?? 0) + timelinerow.height
width: chat.delegateMaxWidth
Loader {
id: section
property var day: wrapper.day
property bool isSender: wrapper.isSender
property bool isStateEvent: wrapper.isStateEvent
property int parentWidth: parent.width
property var previousMessageDay: wrapper.previousMessageDay
property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent
property string previousMessageUserId: wrapper.previousMessageUserId
property date timestamp: wrapper.timestamp
property string userId: wrapper.userId
property string userName: wrapper.userName
property int userPowerlevel: wrapper.userPowerlevel
active: previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent
//asynchronous: true
sourceComponent: sectionHeader
visible: status == Loader.Ready
z: 4
}
TimelineRow {
id: timelinerow
blurhash: wrapper.blurhash
body: wrapper.body
callType: wrapper.callType
duration: wrapper.duration
encryptionError: wrapper.encryptionError
eventId: chat.model, wrapper.eventId
filename: wrapper.filename
filesize: wrapper.filesize
formattedBody: wrapper.formattedBody
index: wrapper.index
isEditable: wrapper.isEditable
isEdited: wrapper.isEdited
isEncrypted: wrapper.isEncrypted
isOnlyEmoji: wrapper.isOnlyEmoji
isSender: wrapper.isSender
isStateEvent: wrapper.isStateEvent
notificationlevel: wrapper.notificationlevel
originalWidth: wrapper.originalWidth
proportionalHeight: wrapper.proportionalHeight
reactions: wrapper.reactions
relatedEventCacheBuster: wrapper.relatedEventCacheBuster
replyTo: wrapper.replyTo
roomName: wrapper.roomName
roomTopic: wrapper.roomTopic
status: wrapper.status
threadId: wrapper.threadId
thumbnailUrl: wrapper.thumbnailUrl
timestamp: wrapper.timestamp
trustlevel: wrapper.trustlevel
type: chat.model, wrapper.type
typeString: wrapper.typeString
url: wrapper.url
userId: wrapper.userId
userName: wrapper.userName
width: wrapper.width
y: section.visible && section.active ? section.y + section.height : 0
background: Rectangle {
id: scrollHighlight
color: palette.highlight
enabled: false
opacity: 0
visible: true
z: 1
states: State {
name: "revealed"
when: wrapper.scrolledToThis
}
transitions: Transition {
from: ""
to: "revealed"
Component {
id: defaultMessageStyle
SequentialAnimation {
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 0
properties: "opacity"
target: scrollHighlight
to: 1
}
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 1
properties: "opacity"
target: scrollHighlight
to: 0
}
ScriptAction {
script: room.eventShown()
}
}
TimelineDefaultMessageStyle {
messageActions: messageActionsC
messageContextMenu: messageContextMenuC
replyContextMenu: replyContextMenuC
scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
}
}
Component {
id: bubbleMessageStyle
onHoveredChanged: {
if (!Settings.mobileMode && hovered) {
if (!messageActions.hovered) {
messageActions.attached = timelinerow;
messageActions.model = timelinerow;
}
TimelineBubbleMessageStyle {
messageActions: messageActionsC
messageContextMenu: messageContextMenuC
replyContextMenu: replyContextMenuC
scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY)
}
}
}
Connections {
function onMovementEnded() {
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
chat.model.currentIndex = index;
}
target: chat
}
}
delegate: Settings.bubbles ? bubbleMessageStyle : defaultMessageStyle
footer: Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.margins: Nheko.paddingLarge
@ -260,19 +114,19 @@ Item {
source: room
}
Control {
id: messageActions
id: messageActionsC
property Item attached: null
// use comma to update on scroll
property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null
property alias model: row.model
hoverEnabled: true
padding: Nheko.paddingSmall
visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || hovered)
x: attached ? attachedPos.x : 0
y: attached ? attachedPos.y + Nheko.paddingSmall : 0
z: 10
parent: chat.contentItem
anchors.bottom: attached?.top
anchors.right: attached?.right
background: Rectangle {
border.color: palette.buttonText
@ -285,7 +139,7 @@ Item {
property var model
spacing: messageActions.padding
spacing: messageActionsC.padding
Repeater {
model: Settings.recentReactions
@ -422,7 +276,7 @@ Item {
image: ":/icons/icons/ui/options.svg"
width: 16
onClicked: messageContextMenu.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton)
onClicked: messageContextMenuC.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton)
}
}
}
@ -504,148 +358,9 @@ Item {
room.setCurrentIndex(room.currentIndex);
}
}
Component {
id: sectionHeader
Column {
bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3
spacing: 8
topPadding: userName_.visible ? 4 : 0
visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent)
width: parentWidth
Label {
id: dateBubble
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
color: palette.text
height: Math.round(fontMetrics.height * 1.4)
horizontalAlignment: Text.AlignHCenter
text: room ? room.formatDateSeparator(timestamp) : ""
verticalAlignment: Text.AlignVCenter
visible: room && previousMessageDay !== day
width: contentWidth * 1.2
background: Rectangle {
color: palette.window
radius: parent.height / 2
}
}
Row {
id: userInfo
property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width
height: userName_.height
spacing: 8
visible: !isStateEvent && (!isSender || !Settings.bubbles)
Avatar {
id: messageUserAvatar
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userid
ToolTip.visible: messageUserAvatar.hovered
displayName: userName
height: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1)
url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/")
userid: userId
width: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1)
onClicked: room.openUserProfile(userId)
}
Connections {
function onRoomAvatarUrlChanged() {
messageUserAvatar.url = room.avatarUrl(userId).replace("mxc://", "image://MxcImage/");
}
function onScrollToIndex(index) {
chat.positionViewAtIndex(index, ListView.Center);
}
target: room
}
AbstractButton {
id: userNameButton
PowerlevelIndicator {
id: powerlevelIndicator
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
height: fontMetrics.ascent
width: height
powerlevel: userPowerlevel
permissions: room ? room.permissions : null
visible: isAdmin || isModerator
}
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userId
ToolTip.visible: hovered
leftPadding: powerlevelIndicator.visible ? 16 : 0
leftInset: 0
rightInset: 0
rightPadding: 0
contentItem: Label {
id: userName_
color: TimelineManager.userColor(userId, palette.base)
text: TimelineManager.escapeEmoji(userNameTextMetrics.elidedText)
textFormat: Text.RichText
}
onClicked: room.openUserProfile(userId)
TextMetrics {
id: userNameTextMetrics
elide: Text.ElideRight
elideWidth: userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3)
text: userName
}
NhekoCursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
}
Label {
id: statusMsg
property string userStatus: Presence.userStatus(userId)
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("%1's status message").arg(userName)
ToolTip.visible: statusMsgHoverHandler.hovered
anchors.baseline: userNameButton.baseline
color: palette.buttonText
elide: Text.ElideRight
font.italic: true
font.pointSize: Math.floor(fontMetrics.font.pointSize * 0.8)
text: userStatus.replace(/\n/g, " ")
textFormat: Text.PlainText
width: Math.min(implicitWidth, userInfo.remainingWidth - userName_.width - parent.spacing)
HoverHandler {
id: statusMsgHoverHandler
}
Connections {
function onPresenceChanged(id) {
if (id == userId)
statusMsg.userStatus = Presence.userStatus(userId);
}
target: Presence
}
}
}
}
}
}
Platform.Menu {
id: messageContextMenu
id: messageContextMenuC
property string eventId
property int eventType
@ -700,22 +415,22 @@ Item {
onTriggered: function () {
topBar.searchString = "";
room.showEvent(messageContextMenu.eventId);
room.showEvent(messageContextMenuC.eventId);
}
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Copy")
visible: messageContextMenu.text
visible: messageContextMenuC.text
onTriggered: Clipboard.text = messageContextMenu.text
onTriggered: Clipboard.text = messageContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: messageContextMenu.link
visible: messageContextMenuC.link
onTriggered: Clipboard.text = messageContextMenu.link
onTriggered: Clipboard.text = messageContextMenuC.link
}
Platform.MenuItem {
id: reactionOption
@ -724,7 +439,7 @@ Item {
visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false
onTriggered: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(null, room.roomId, function (plaintext, markdown) {
room.input.reaction(messageContextMenu.eventId, plaintext);
room.input.reaction(messageContextMenuC.eventId, plaintext);
TimelineManager.focusMessageInput();
})
}
@ -732,41 +447,41 @@ Item {
text: qsTr("Repl&y")
visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
onTriggered: room.reply = (messageContextMenu.eventId)
onTriggered: room.reply = (messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Edit")
visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
visible: messageContextMenuC.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
onTriggered: room.edit = (messageContextMenu.eventId)
onTriggered: room.edit = (messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Thread")
visible: (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
onTriggered: room.thread = (messageContextMenu.threadId || messageContextMenu.eventId)
onTriggered: room.thread = (messageContextMenuC.threadId || messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin")
text: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? qsTr("Un&pin") : qsTr("&Pin")
visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false)
onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId)
onTriggered: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? room.unpin(messageContextMenuC.eventId) : room.pin(messageContextMenuC.eventId)
}
Platform.MenuItem {
text: qsTr("&Read receipts")
onTriggered: room.showReadReceipts(messageContextMenu.eventId)
onTriggered: room.showReadReceipts(messageContextMenuC.eventId)
}
Platform.MenuItem {
text: qsTr("&Forward")
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker || messageContextMenu.eventType == MtxEvent.TextMessage || messageContextMenu.eventType == MtxEvent.LocationMessage || messageContextMenu.eventType == MtxEvent.EmoteMessage || messageContextMenu.eventType == MtxEvent.NoticeMessage
visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker || messageContextMenuC.eventType == MtxEvent.TextMessage || messageContextMenuC.eventType == MtxEvent.LocationMessage || messageContextMenuC.eventType == MtxEvent.EmoteMessage || messageContextMenuC.eventType == MtxEvent.NoticeMessage
onTriggered: {
var forwardMess = forwardCompleterComponent.createObject(timelineRoot);
forwardMess.setMessageEventId(messageContextMenu.eventId);
forwardMess.setMessageEventId(messageContextMenuC.eventId);
forwardMess.open();
timelineRoot.destroyOnClose(forwardMess);
}
@ -777,23 +492,23 @@ Item {
Platform.MenuItem {
text: qsTr("View raw message")
onTriggered: room.viewRawMessage(messageContextMenu.eventId)
onTriggered: room.viewRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("View decrypted raw message")
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
visible: messageContextMenu.isEncrypted
visible: messageContextMenuC.isEncrypted
onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId)
onTriggered: room.viewDecryptedRawMessage(messageContextMenuC.eventId)
}
Platform.MenuItem {
text: qsTr("Remo&ve message")
visible: (room ? room.permissions.canRedact() : false) || messageContextMenu.isSender
visible: (room ? room.permissions.canRedact() : false) || messageContextMenuC.isSender
onTriggered: function () {
var dialog = removeReason.createObject(timelineRoot);
dialog.eventId = messageContextMenu.eventId;
dialog.eventId = messageContextMenuC.eventId;
dialog.show();
dialog.forceActiveFocus();
timelineRoot.destroyOnClose(dialog);
@ -802,23 +517,23 @@ Item {
Platform.MenuItem {
enabled: visible
text: qsTr("&Save as")
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker
onTriggered: room.saveMedia(messageContextMenu.eventId)
onTriggered: room.saveMedia(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Open in external program")
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker
visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker
onTriggered: room.openMedia(messageContextMenu.eventId)
onTriggered: room.openMedia(messageContextMenuC.eventId)
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy link to eve&nt")
visible: messageContextMenu.eventId
visible: messageContextMenuC.eventId
onTriggered: room.copyLinkToEvent(messageContextMenu.eventId)
onTriggered: room.copyLinkToEvent(messageContextMenuC.eventId)
}
}
Component {
@ -828,7 +543,7 @@ Item {
}
}
Platform.Menu {
id: replyContextMenu
id: replyContextMenuC
property string eventId
property string link
@ -844,23 +559,23 @@ Item {
Platform.MenuItem {
enabled: visible
text: qsTr("&Copy")
visible: replyContextMenu.text
visible: replyContextMenuC.text
onTriggered: Clipboard.text = replyContextMenu.text
onTriggered: Clipboard.text = replyContextMenuC.text
}
Platform.MenuItem {
enabled: visible
text: qsTr("Copy &link location")
visible: replyContextMenu.link
visible: replyContextMenuC.link
onTriggered: Clipboard.text = replyContextMenu.link
onTriggered: Clipboard.text = replyContextMenuC.link
}
Platform.MenuItem {
enabled: visible
text: qsTr("&Go to quoted message")
visible: true
onTriggered: room.showEvent(replyContextMenu.eventId)
onTriggered: room.showEvent(replyContextMenuC.eventId)
}
}
RoundButton {

@ -74,10 +74,10 @@ Flow {
anchors.verticalCenter: divider.verticalCenter
fillMode: Image.PreserveAspectFit
height: textMetrics.height
mipmap: true
source: modelData.key.startsWith("mxc://") ? (modelData.key.replace("mxc://", "image://MxcImage/") + "?scale") : ""
visible: modelData.key.startsWith("mxc://")
width: textMetrics.height
mipmap: true
}
Rectangle {
id: divider

@ -29,24 +29,10 @@ Rectangle {
anchors.rightMargin: replyPopup.width < 450 ? 2 * (22 + 16) : 3 * (22 + 16)
anchors.top: parent.top
anchors.topMargin: Nheko.paddingSmall
blurhash: modelData.blurhash ?? ""
body: modelData.body ?? ""
encryptionError: modelData.encryptionError ?? 0
eventId: modelData.eventId ?? ""
filename: modelData.filename ?? ""
filesize: modelData.filesize ?? ""
formattedBody: modelData.formattedBody ?? ""
isOnlyEmoji: modelData.isOnlyEmoji ?? false
originalWidth: modelData.originalWidth ?? 0
proportionalHeight: modelData.proportionalHeight ?? 1
type: modelData.type ?? MtxEvent.UnknownMessage
typeString: modelData.typeString ?? ""
url: modelData.url ?? ""
eventId: room.reply ?? ""
userColor: TimelineManager.userColor(modelData.userId, palette.window)
userId: modelData.userId ?? ""
userName: modelData.userName ?? ""
visible: room && room.reply
width: parent.width
maxWidth: parent.width - anchors.leftMargin - anchors.rightMargin
}
ImageButton {
id: closeReplyButton

@ -728,9 +728,9 @@ Page {
}
Platform.MenuItem {
text: qsTr("Mark as read")
onTriggered: Rooms.getRoomById(roomContextMenu.roomid).markRoomAsRead()
}
Platform.MenuItem {
text: qsTr("Room settings")

@ -355,7 +355,6 @@ Pane {
onAccepted: UIA.continue3pidReceived()
}
Connections {
function onConfirm3pidToken() {
uiaConfirmationLinkDialog.open();
@ -363,6 +362,18 @@ Pane {
function onEmail() {
uiaEmailPrompt.show();
}
function onFallbackAuth(fallback) {
var component = Qt.createComponent("qrc:/resources/qml/dialogs/FallbackAuthDialog.qml");
if (component.status == Component.Ready) {
var dialog = component.createObject(timelineRoot, {
"fallback": fallback
});
dialog.show();
destroyOnClose(dialog);
} else {
console.error("Failed to create component: " + component.errorString());
}
}
function onPassword() {
console.log("UIA: password needed");
uiaPassPrompt.show();
@ -385,18 +396,6 @@ Pane {
console.error("Failed to create component: " + component.errorString());
}
}
function onFallbackAuth(fallback) {
var component = Qt.createComponent("qrc:/resources/qml/dialogs/FallbackAuthDialog.qml");
if (component.status == Component.Ready) {
var dialog = component.createObject(timelineRoot, {
"fallback": fallback
});
dialog.show();
destroyOnClose(dialog);
} else {
console.error("Failed to create component: " + component.errorString());
}
}
target: UIA
}

@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import "./components"
import "./delegates"
import "./emoji"
import "./ui"
import "./dialogs"
import Qt.labs.platform 1.1 as Platform
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import im.nheko
TimelineEvent {
id: wrapper
ListView.delayRemove: true
width: chat.delegateMaxWidth
height: Math.max((section.item?.height ?? 0) + gridContainer.implicitHeight + reactionRow.implicitHeight + unreadRow.height, 10)
anchors.horizontalCenter: ListView.view.contentItem.horizontalCenter
//room: chatRoot.roommodel
required property var day
required property bool isSender
required property int index
property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day)
property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent)
property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId)
required property date timestamp
required property string userId
required property string userName
required property string threadId
required property int userPowerlevel
required property bool isEdited
required property bool isEncrypted
required property var reactions
required property int status
required property int trustlevel
required property int notificationlevel
required property int type
required property bool isEditable
required property QtObject messageContextMenu
required property QtObject replyContextMenu
required property Item messageActions
property int avatarMargin: (wrapper.isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) // align bubble with section header
property alias hovered: messageHover.hovered
property bool scrolledToThis: false
mainInset: (threadId ? (4 + Nheko.paddingSmall) : 0) + 4
replyInset: mainInset + 4 + Nheko.paddingSmall
property int bubbleMargin: 40
maxWidth: chat.delegateMaxWidth - avatarMargin - bubbleMargin
data: [
Loader {
id: section
active: wrapper.previousMessageUserId !== wrapper.userId || wrapper.previousMessageDay !== wrapper.day || wrapper.previousMessageIsStateEvent !== wrapper.isStateEvent
//asynchronous: true
sourceComponent: TimelineSectionHeader {
day: wrapper.day
isSender: wrapper.isSender
isStateEvent: wrapper.isStateEvent
parentWidth: wrapper.width
previousMessageDay: wrapper.previousMessageDay
previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent
previousMessageUserId: wrapper.previousMessageUserId
timestamp: wrapper.timestamp
userId: wrapper.userId
userName: wrapper.userName
userPowerlevel: wrapper.userPowerlevel
}
visible: status == Loader.Ready
z: 4
},
Rectangle {
anchors.fill: gridContainer
property color threadColor: TimelineManager.userColor(wrapper.threadId, palette.base)
property color threadBackgroundColor: wrapper.threadId ? Qt.tint(palette.base, Qt.hsla(threadColor.hslHue, 0.7, threadColor.hslLightness, 0.1)) : "transparent"
color: (Settings.messageHoverHighlight && messageHover.hovered) ? palette.alternateBase : threadBackgroundColor
// this looks better without margins
TapHandler {
acceptedButtons: Qt.RightButton
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
gesturePolicy: TapHandler.ReleaseWithinBounds
onSingleTapped: messageContextMenu.show(wrapper.eventId, wrapper.threadId, wrapper.type, wrapper.isSender, wrapper.isEncrypted, wrapper.isEditable, wrapper.main.hoveredLink, wrapper.main.copyText)
}
},
Rectangle {
id: scrollHighlight
anchors.fill: gridContainer
color: palette.highlight
enabled: false
opacity: 0
visible: true
z: 1
states: State {
name: "revealed"
when: wrapper.scrolledToThis
}
transitions: Transition {
from: ""
to: "revealed"
SequentialAnimation {
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 0
properties: "opacity"
target: scrollHighlight
to: 1
}
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 1
properties: "opacity"
target: scrollHighlight
to: 0
}
ScriptAction {
script: wrapper.room.eventShown()
}
}
}
},
Item {
id: gridContainer
width: wrapper.width - wrapper.avatarMargin
implicitHeight: messageBubble.implicitHeight
x: wrapper.avatarMargin
y: section.visible && section.active ? section.y + section.height : 0
HoverHandler {
id: messageHover
blocking: false
onHoveredChanged: () => {
if (!Settings.mobileMode && hovered) {
if (!messageActions.hovered) {
messageActions.model = wrapper;
messageActions.attached = wrapper;
messageActions.anchors.bottomMargin = -gridContainer.y
//messageActions.anchors.rightMargin = metadata.width
}
}
}
}
AbstractButton {
id: messageBubble
anchors.left: (wrapper.isStateEvent || wrapper.isSender) ? undefined : parent.left
anchors.right: (wrapper.isStateEvent || !wrapper.isSender) ? undefined : parent.right
anchors.horizontalCenter: wrapper.isStateEvent ? parent.horizontalCenter : undefined
property color userColor: TimelineManager.userColor(wrapper.main?.userId ?? '', palette.base)
contentItem: Item {
id: contentPlacementContainer
property bool fitsMetadata: ((wrapper.main?.width ?? 0) + wrapper.mainInset + metadata.width) < wrapper.maxWidth
// This doesnt work because of tables. They might have content in the top of the cell, while the background reaches to the bottom. Maybe using the textDocument we could do more?
// property bool fitsMetadataInside: wrapper.main?.positionAt ? (wrapper.main.positionAt(wrapper.main.width, wrapper.main.height - 4) == wrapper.main.positionAt(wrapper.main.width - metadata.width, wrapper.main.height - 4)) : false
property bool fitsMetadataInside: false
implicitWidth: Math.max((wrapper.reply?.width ?? 0) + wrapper.replyInset, (wrapper.main?.width ?? 0) + wrapper.mainInset + ((fitsMetadata && !fitsMetadataInside) ? metadata.width : 0))
implicitHeight: contentColumn.implicitHeight + ((fitsMetadata || fitsMetadataInside) ? 0 : metadata.height)
TimelineMetadata {
id: metadata
scaling: 0.75
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: !wrapper.isStateEvent
eventId: wrapper.eventId
status: wrapper.status
trustlevel: wrapper.trustlevel
isEdited: wrapper.isEdited
isEncrypted: wrapper.isEncrypted
threadId: wrapper.threadId
timestamp: wrapper.timestamp
room: wrapper.room
}
Column {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
AbstractButton {
id: replyRow
visible: wrapper.reply
height: replyLine.height
anchors.left: parent.left
anchors.right: parent.right
property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base)
clip: true
NhekoCursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
contentItem: Row {
id: replyRowLay
spacing: Nheko.paddingSmall
Rectangle {
id: replyLine
height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height
color: replyRow.userColor
width: 4
}
Column {
spacing: 0
id: replyCol
AbstractButton {
id: replyUserButton
contentItem: Label {
id: userName_
text: wrapper.reply?.userName ?? ''
color: replyRow.userColor
textFormat: Text.RichText
width: wrapper.maxWidth
//elideWidth: wrapper.maxWidth
}
onClicked: wrapper.room.openUserProfile(wrapper.reply?.userId)
}
data: [
replyUserButton,
wrapper.reply,
]
}
}
background: Rectangle {
//width: replyRow.implicitContentWidth
color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1))
}
onClicked: {
let link = wrapper.reply.hoveredLink
if (link) {
Nheko.openLink(link)
} else {
console.log("Scrolling to "+wrapper.replyTo);
wrapper.room.showEvent(wrapper.replyTo)
}
}
onPressAndHold: wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(pressX-replyLine.width - Nheko.paddingSmall, pressY - replyUserButton.implicitHeight) : "", wrapper.replyTo)
TapHandler {
acceptedButtons: Qt.RightButton
onSingleTapped: (eventPoint) => wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(eventPoint.position.x-replyLine.width - Nheko.paddingSmall, eventPoint.position.y - replyUserButton.implicitHeight) : "", wrapper.replyTo)
gesturePolicy: TapHandler.ReleaseWithinBounds
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
}
}
data: [replyRow, wrapper.main]
}
}
padding: wrapper.isStateEvent ? 0 : 4
background: Rectangle {
color: !wrapper.isStateEvent ? Qt.tint(palette.base, Qt.hsla(messageBubble.userColor.hslHue, wrapper.hovered ? 0.8 : 0.5, messageBubble.userColor.hslLightness, 0.2)) : "transparent"
radius: 4
border.color: Nheko.theme.red
border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0
}
}
},
Reactions {
id: reactionRow
eventId: wrapper.eventId
layoutDirection: (!wrapper.isStateEvent && wrapper.isSender) ? Qt.RightToLeft : Qt.LeftToRight
reactions: wrapper.reactions
width: wrapper.width - wrapper.avatarMargin
x: wrapper.avatarMargin
anchors {
//left: row.bubbleOnRight ? undefined : row.left
//right: row.bubbleOnRight ? row.right : undefined
top: gridContainer.bottom
topMargin: -4
}
},
Rectangle {
id: unreadRow
color: palette.highlight
height: visible ? 3 : 0
visible: (wrapper.index > 0 && (wrapper.room.fullyReadEventId == wrapper.eventId))
anchors {
left: parent.left
right: parent.right
top: reactionRow.bottom
topMargin: 5
}
}
]
}

@ -0,0 +1,327 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import "./components"
import "./delegates"
import "./emoji"
import "./ui"
import "./dialogs"
import Qt.labs.platform 1.1 as Platform
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import im.nheko
TimelineEvent {
id: wrapper
ListView.delayRemove: true
width: chat.delegateMaxWidth
height: Math.max((section.item?.height ?? 0) + gridContainer.implicitHeight + reactionRow.implicitHeight + unreadRow.height, 10)
anchors.horizontalCenter: ListView.view.contentItem.horizontalCenter
//room: chatRoot.roommodel
required property var day
required property bool isSender
required property int index
property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day)
property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent)
property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId)
required property date timestamp
required property string userId
required property string userName
required property string threadId
required property int userPowerlevel
required property bool isEdited
required property bool isEncrypted
required property var reactions
required property int status
required property int trustlevel
required property int notificationlevel
required property int type
required property bool isEditable
required property QtObject messageContextMenu
required property QtObject replyContextMenu
required property Item messageActions
property int avatarMargin: (wrapper.isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) // align bubble with section header
property alias hovered: messageHover.hovered
property bool scrolledToThis: false
mainInset: (threadId ? (4 + Nheko.paddingSmall) : 0)
replyInset: mainInset + 4 + Nheko.paddingSmall
maxWidth: chat.delegateMaxWidth - avatarMargin - metadata.width
data: [
Loader {
id: section
active: wrapper.previousMessageUserId !== wrapper.userId || wrapper.previousMessageDay !== wrapper.day || wrapper.previousMessageIsStateEvent !== wrapper.isStateEvent
//asynchronous: true
sourceComponent: TimelineSectionHeader {
day: wrapper.day
isSender: wrapper.isSender
isStateEvent: wrapper.isStateEvent
parentWidth: wrapper.width
previousMessageDay: wrapper.previousMessageDay
previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent
previousMessageUserId: wrapper.previousMessageUserId
timestamp: wrapper.timestamp
userId: wrapper.userId
userName: wrapper.userName
userPowerlevel: wrapper.userPowerlevel
}
visible: status == Loader.Ready
z: 4
},
Rectangle {
anchors.fill: gridContainer
color: (Settings.messageHoverHighlight && messageHover.hovered) ? palette.alternateBase : "transparent"
// this looks better without margins
TapHandler {
acceptedButtons: Qt.RightButton
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
gesturePolicy: TapHandler.ReleaseWithinBounds
onSingleTapped: messageContextMenu.show(wrapper.eventId, wrapper.threadId, wrapper.type, wrapper.isSender, wrapper.isEncrypted, wrapper.isEditable, wrapper.main.hoveredLink, wrapper.main.copyText)
}
},
Rectangle {
id: scrollHighlight
anchors.fill: gridContainer
color: palette.highlight
enabled: false
opacity: 0
visible: true
z: 1
states: State {
name: "revealed"
when: wrapper.scrolledToThis
}
transitions: Transition {
from: ""
to: "revealed"
SequentialAnimation {
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 0
properties: "opacity"
target: scrollHighlight
to: 1
}
PropertyAnimation {
duration: 500
easing.type: Easing.InOutQuad
from: 1
properties: "opacity"
target: scrollHighlight
to: 0
}
ScriptAction {
script: wrapper.room.eventShown()
}
}
}
},
Rectangle {
anchors.top: gridContainer.top
anchors.left: gridContainer.left
anchors.topMargin: -2
anchors.leftMargin: -2
color: "transparent"
border.color: Nheko.theme.red
border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0
radius: 4
height: contentColumn.implicitHeight + 4
width: contentColumn.implicitWidth + 4
},
Row {
id: gridContainer
width: wrapper.width - wrapper.avatarMargin
x: wrapper.avatarMargin
y: section.visible && section.active ? section.y + section.height : 0
spacing: Nheko.paddingSmall
HoverHandler {
id: messageHover
blocking: false
onHoveredChanged: () => {
if (!Settings.mobileMode && hovered) {
if (!messageActions.hovered) {
messageActions.model = wrapper;
messageActions.attached = wrapper;
messageActions.anchors.bottomMargin = -gridContainer.y
messageActions.anchors.rightMargin = metadata.width
}
}
}
}
AbstractButton {
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Part of a thread")
ToolTip.visible: hovered
height: contentColumn.height
visible: wrapper.threadId
width: 4
onClicked: wrapper.room.thread = wrapper.threadId
Rectangle {
id: threadLine
anchors.fill: parent
color: TimelineManager.userColor(wrapper.threadId, palette.base)
}
}
Item {
visible: wrapper.isStateEvent
width: (wrapper.maxWidth - (wrapper.main?.width ?? 0)) / 2
height: 1
}
Column {
id: contentColumn
AbstractButton {
id: replyRow
visible: wrapper.reply
height: replyLine.height
property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base)
clip: true
NhekoCursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
contentItem: Row {
id: replyRowLay
spacing: Nheko.paddingSmall
Rectangle {
id: replyLine
height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height
color: replyRow.userColor
width: 4
}
Column {
spacing: 0
id: replyCol
AbstractButton {
id: replyUserButton
contentItem: Label {
id: userName_
text: wrapper.reply?.userName ?? ''
color: replyRow.userColor
textFormat: Text.RichText
width: wrapper.maxWidth
//elideWidth: wrapper.maxWidth
}
onClicked: wrapper.room.openUserProfile(wrapper.reply?.userId)
}
data: [
replyUserButton,
wrapper.reply,
]
}
}
background: Rectangle {
//width: replyRow.implicitContentWidth
color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1))
}
onClicked: {
let link = wrapper.reply.hoveredLink
if (link) {
Nheko.openLink(link)
} else {
console.log("Scrolling to "+wrapper.replyTo);
wrapper.room.showEvent(wrapper.replyTo)
}
}
onPressAndHold: wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(pressX-replyLine.width - Nheko.paddingSmall, pressY - replyUserButton.implicitHeight) : "", wrapper.replyTo)
TapHandler {
acceptedButtons: Qt.RightButton
onSingleTapped: (eventPoint) => wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(eventPoint.position.x-replyLine.width - Nheko.paddingSmall, eventPoint.position.y - replyUserButton.implicitHeight) : "", wrapper.replyTo)
gesturePolicy: TapHandler.ReleaseWithinBounds
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
}
}
data: [
replyRow, wrapper.main,
]
}
},
TimelineMetadata {
id: metadata
scaling: 1
anchors.right: parent.right
y: section.visible && section.active ? section.y + section.height : 0
visible: !wrapper.isStateEvent
eventId: wrapper.eventId
status: wrapper.status
trustlevel: wrapper.trustlevel
isEdited: wrapper.isEdited
isEncrypted: wrapper.isEncrypted
threadId: wrapper.threadId
timestamp: wrapper.timestamp
room: wrapper.room
},
Reactions {
id: reactionRow
eventId: wrapper.eventId
reactions: wrapper.reactions
width: wrapper.width - wrapper.avatarMargin
x: wrapper.avatarMargin
anchors {
top: gridContainer.bottom
topMargin: -4
}
},
Rectangle {
id: unreadRow
color: palette.highlight
height: visible ? 3 : 0
visible: (wrapper.index > 0 && (wrapper.room.fullyReadEventId == wrapper.eventId))
anchors {
left: parent.left
right: parent.right
top: reactionRow.bottom
topMargin: 5
}
}
]
}

@ -0,0 +1,266 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import "./components"
import "./delegates"
import "./emoji"
import "./ui"
import "./dialogs"
import Qt.labs.platform 1.1 as Platform
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.2
import QtQuick.Window 2.13
import im.nheko 1.0
EventDelegateChooser {
id: wrapper
required property bool isStateEvent
EventDelegateChoice {
roleValues: [MtxEvent.TextMessage, MtxEvent.NoticeMessage, MtxEvent.ElementEffectMessage, MtxEvent.UnknownMessage,]
TextMessage {
required property string formattedBody
required property int type
required property string userId
required property string userName
Layout.fillWidth: true
//Layout.maximumWidth: implicitWidth
color: type == MtxEvent.NoticeMessage ? palette.buttonText : palette.text
font.italic: type == MtxEvent.NoticeMessage
formatted: formattedBody
keepFullText: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.EmoteMessage,]
TextMessage {
required property string formattedBody
required property string userId
required property string userName
Layout.fillWidth: true
//Layout.maximumWidth: implicitWidth
color: TimelineManager.userColor(userId, palette.base)
font.italic: true
formatted: TimelineManager.escapeEmoji(userName) + " " + formattedBody
keepFullText: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.CanonicalAlias, MtxEvent.ServerAcl, MtxEvent.Name, MtxEvent.Topic, MtxEvent.Avatar, MtxEvent.PinnedEvents, MtxEvent.ImagePackInRoom, MtxEvent.SpaceParent, MtxEvent.RoomCreate, MtxEvent.PowerLevels, MtxEvent.PolicyRuleUser, MtxEvent.PolicyRuleRoom, MtxEvent.PolicyRuleServer, MtxEvent.RoomJoinRules, MtxEvent.RoomHistoryVisibility, MtxEvent.RoomGuestAccess,]
TextMessage {
required property string formattedStateEvent
required property string userId
required property string userName
body: ''
color: palette.buttonText
font.italic: true
font.pointSize: Settings.fontSize * 0.8
formatted: ''
horizontalAlignment: Text.AlignHCenter
isOnlyEmoji: false
keepFullText: true
text: formattedStateEvent
}
}
EventDelegateChoice {
roleValues: [MtxEvent.CallInvite,]
TextMessage {
required property string callType
required property string userId
required property string userName
Layout.fillWidth: true
body: formatted
color: palette.buttonText
font.italic: true
formatted: {
switch (callType) {
case "voice":
return qsTr("%1 placed a voice call.").arg(TimelineManager.escapeEmoji(userName));
case "video":
return qsTr("%1 placed a video call.").arg(TimelineManager.escapeEmoji(userName));
default:
return qsTr("%1 placed a call.").arg(TimelineManager.escapeEmoji(userName));
}
}
isOnlyEmoji: false
keepFullText: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.CallAnswer, MtxEvent.CallReject, MtxEvent.CallSelectAnswer, MtxEvent.CallHangUp, MtxEvent.CallCandidates, MtxEvent.CallNegotiate,]
TextMessage {
required property int type
required property string userId
required property string userName
Layout.fillWidth: true
body: formatted
color: palette.buttonText
font.italic: true
formatted: {
switch (type) {
case MtxEvent.CallAnswer:
return qsTr("%1 answered the call.").arg(TimelineManager.escapeEmoji(userName));
case MtxEvent.CallReject:
return qsTr("%1 rejected the call.").arg(TimelineManager.escapeEmoji(userName));
case MtxEvent.CallSelectAnswer:
return qsTr("%1 selected answer.").arg(TimelineManager.escapeEmoji(userName));
case MtxEvent.CallHangUp:
return qsTr("%1 ended the call.").arg(TimelineManager.escapeEmoji(userName));
case MtxEvent.CallCandidates:
return qsTr("%1 is negotiating the call...").arg(TimelineManager.escapeEmoji(userName));
case MtxEvent.CallNegotiate:
return qsTr("%1 is negotiating the call...").arg(TimelineManager.escapeEmoji(userName));
}
}
isOnlyEmoji: false
keepFullText: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.ImageMessage, MtxEvent.Sticker,]
ImageMessage {
required property string userId
required property string userName
Layout.fillWidth: true
//Layout.maximumWidth: tempWidth
//Layout.maximumHeight: timelineView.height / 8
containerHeight: timelineView.height
}
}
EventDelegateChoice {
roleValues: [MtxEvent.FileMessage,]
FileMessage {
required property string userId
required property string userName
Layout.fillWidth: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.VideoMessage, MtxEvent.AudioMessage,]
PlayableMediaMessage {
required property string userId
required property string userName
Layout.fillWidth: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.Encrypted,]
Encrypted {
required property string userId
required property string userName
Layout.fillWidth: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.Encryption,]
EncryptionEnabled {
required property string userId
Layout.fillWidth: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.Redacted]
Redacted {
required property string userId
required property string userName
Layout.fillWidth: true
}
}
EventDelegateChoice {
roleValues: [MtxEvent.Member]
ColumnLayout {
id: member
required property string formattedStateEvent
required property Room room
required property string userId
required property string userName
NoticeMessage {
Layout.fillWidth: true
body: formatted
formatted: member.formattedStateEvent
isOnlyEmoji: false
isReply: EventDelegateChooser.isReply
isStateEvent: true
keepFullText: true
}
Button {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Allow them in")
visible: member.room.showAcceptKnockButton(member.eventId)
onClicked: member.room.acceptKnock(member.eventId)
}
}
}
EventDelegateChoice {
roleValues: [MtxEvent.Tombstone]
ColumnLayout {
id: tombstone
required property string body
required property string eventId
required property Room room
required property string userId
required property string userName
NoticeMessage {
Layout.fillWidth: true
body: formatted
formatted: qsTr("This room was replaced for the following reason: %1").arg(tombstone.body)
isOnlyEmoji: false
isReply: EventDelegateChooser.isReply
isStateEvent: true
keepFullText: true
}
Button {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Go to replacement room")
onClicked: tombstone.room.joinReplacementRoom(tombstone.eventId)
}
}
}
EventDelegateChoice {
roleValues: []
MatrixText {
required property string typeString
required property string userId
required property string userName
Layout.fillWidth: true
text: "Unsupported: " + typeString
}
}
}

@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import "./components"
import "./delegates"
import "./emoji"
import "./ui"
import "./dialogs"
import Qt.labs.platform 1.1 as Platform
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import im.nheko
RowLayout {
id: metadata
property int iconSize: Math.floor(fontMetrics.ascent * scaling)
required property double scaling
required property string eventId
required property int status
required property int trustlevel
required property bool isEdited
required property bool isEncrypted
required property string threadId
required property date timestamp
required property Room room
spacing: 2
StatusIndicator {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
eventId: metadata.eventId
height: parent.iconSize
status: metadata.status
width: parent.iconSize
}
Image {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edited")
ToolTip.visible: editHovered.hovered
height: parent.iconSize
source: "image://colorimage/:/icons/icons/ui/edit.svg?" + ((metadata.eventId == metadata.room.edit) ? palette.highlight : palette.buttonText)
sourceSize.height: parent.iconSize * Screen.devicePixelRatio
sourceSize.width: parent.iconSize * Screen.devicePixelRatio
visible: metadata.isEdited || metadata.eventId == metadata.room.edit
width: parent.iconSize
HoverHandler {
id: editHovered
}
}
ImageButton {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Part of a thread")
ToolTip.visible: hovered
buttonTextColor: TimelineManager.userColor(metadata.threadId, palette.base)
height: parent.iconSize
image: ":/icons/icons/ui/thread.svg"
visible: metadata.threadId
width: parent.iconSize
onClicked: metadata.room.thread = threadId
}
EncryptionIndicator {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
encrypted: metadata.isEncrypted
height: parent.iconSize
sourceSize.height: parent.iconSize * Screen.devicePixelRatio
sourceSize.width: parent.iconSize * Screen.devicePixelRatio
trust: metadata.trustlevel
visible: metadata.room.isEncrypted
width: parent.iconSize
}
Label {
id: ts
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.preferredWidth: implicitWidth
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: Qt.formatDateTime(metadata.timestamp, Qt.DefaultLocaleLongDate)
ToolTip.visible: ma.hovered
color: palette.inactive.text
font.pointSize: fontMetrics.font.pointSize * parent.scaling
text: metadata.timestamp.toLocaleTimeString(Locale.ShortFormat)
HoverHandler {
id: ma
}
}
}

@ -1,349 +0,0 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import "./delegates"
import "./emoji"
import QtQuick 2.15
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import QtQuick.Window 2.13
import im.nheko 1.0
AbstractButton {
id: r
required property string blurhash
required property string body
required property string callType
required property int duration
required property int encryptionError
required property string eventId
required property string filename
required property string filesize
required property string formattedBody
required property int index
required property bool isEditable
required property bool isEdited
required property bool isEncrypted
required property bool isOnlyEmoji
required property bool isSender
required property bool isStateEvent
required property int notificationlevel
required property int originalWidth
required property double proportionalHeight
required property var reactions
required property int relatedEventCacheBuster
required property string replyTo
required property string roomName
required property string roomTopic
required property int status
required property string threadId
required property string thumbnailUrl
required property var timestamp
required property int trustlevel
required property int type
required property string typeString
required property string url
required property string userId
required property string userName
height: row.height + (reactionRow.height > 0 ? reactionRow.height - 2 : 0) + unreadRow.height
hoverEnabled: true
states: State {
name: "dragging"
when: draghandler.active
}
transitions: Transition {
from: "dragging"
to: ""
PropertyAnimation {
duration: 100
easing.type: Easing.InOutQuad
properties: "x"
target: r
to: 0
}
}
onClicked: {
let link = contentItem.child.linkAt != undefined && contentItem.child.linkAt(pressX - row.x - msg.x, pressY - row.y - msg.y - contentItem.y);
if (link) {
Nheko.openLink(link);
}
}
onDoubleClicked: room.reply = eventId
onPressAndHold: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText)
Rectangle {
anchors.fill: parent
color: (Settings.messageHoverHighlight && hovered) ? palette.alternateBase : "transparent"
// this looks better without margins
TapHandler {
acceptedButtons: Qt.RightButton
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
gesturePolicy: TapHandler.ReleaseWithinBounds
onSingleTapped: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText)
}
}
DragHandler {
id: draghandler
xAxis.maximum: 100
xAxis.minimum: -100
yAxis.enabled: false
onActiveChanged: {
if (!active && (x < -70 || x > 70))
room.reply = eventId;
}
}
AbstractButton {
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Part of a thread")
ToolTip.visible: hovered
anchors.left: parent.left
anchors.leftMargin: Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8) // align bubble with section header
height: parent.height
visible: threadId
width: 4
onClicked: room.thread = threadId
Rectangle {
id: threadLine
anchors.fill: parent
color: TimelineManager.userColor(threadId, palette.base)
}
}
Rectangle {
id: row
property color bgColor: palette.base
property bool bubbleOnRight: isSender && Settings.bubbles
property int maxWidth: (parent.width - (Settings.smallAvatars || isStateEvent ? 0 : Nheko.avatarSize + 8)) * (Settings.bubbles && !isStateEvent ? 0.9 : 1)
property color userColor: TimelineManager.userColor(userId, palette.base)
anchors.horizontalCenter: isStateEvent ? parent.horizontalCenter : undefined
anchors.left: (isStateEvent || bubbleOnRight) ? undefined : parent.left
anchors.leftMargin: (isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) + (threadId ? 6 : 0) // align bubble with section header
anchors.right: (isStateEvent || !bubbleOnRight) ? undefined : parent.right
border.color: Nheko.theme.red
border.width: r.notificationlevel == MtxEvent.Highlight ? 1 : 0
color: (Settings.bubbles && !isStateEvent) ? Qt.tint(bgColor, Qt.hsla(userColor.hslHue, 0.5, userColor.hslLightness, 0.2)) : "#00000000"
height: msg.height + msg.anchors.margins * 2
radius: 4
width: Settings.bubbles ? Math.min(maxWidth, Math.max(reply.implicitWidth + 8, contentItem.implicitWidth + metadata.width + 20)) : maxWidth
GridLayout {
id: msg
columnSpacing: 2
columns: Settings.bubbles ? 1 : 2
rowSpacing: 0
rows: Settings.bubbles ? 3 : 2
anchors {
left: parent.left
leftMargin: 4
margins: (Settings.bubbles && !isStateEvent) ? 4 : 2
right: parent.right
rightMargin: 4
top: parent.top
}
// fancy reply, if this is a reply
Reply {
id: reply
function fromModel(role) {
return replyTo != "" ? room.dataById(replyTo, role, r.eventId) : null;
}
Layout.bottomMargin: visible ? 2 : 0
Layout.column: 0
Layout.fillWidth: true
Layout.maximumWidth: Settings.bubbles ? Number.MAX_VALUE : implicitWidth
Layout.preferredHeight: height
Layout.row: 0
blurhash: r.relatedEventCacheBuster, fromModel(Room.Blurhash) ?? ""
body: r.relatedEventCacheBuster, fromModel(Room.Body) ?? ""
callType: r.relatedEventCacheBuster, fromModel(Room.Voip) ?? ""
duration: r.relatedEventCacheBuster, fromModel(Room.Duration) ?? 0
encryptionError: r.relatedEventCacheBuster, fromModel(Room.EncryptionError) ?? 0
eventId: fromModel(Room.EventId) ?? ""
filename: r.relatedEventCacheBuster, fromModel(Room.Filename) ?? ""
filesize: r.relatedEventCacheBuster, fromModel(Room.Filesize) ?? ""
formattedBody: r.relatedEventCacheBuster, fromModel(Room.FormattedBody) ?? ""
isOnlyEmoji: r.relatedEventCacheBuster, fromModel(Room.IsOnlyEmoji) ?? false
isStateEvent: r.relatedEventCacheBuster, fromModel(Room.IsStateEvent) ?? false
originalWidth: r.relatedEventCacheBuster, fromModel(Room.OriginalWidth) ?? 0
proportionalHeight: r.relatedEventCacheBuster, fromModel(Room.ProportionalHeight) ?? 1
relatedEventCacheBuster: r.relatedEventCacheBuster, fromModel(Room.RelatedEventCacheBuster) ?? 0
roomName: r.relatedEventCacheBuster, fromModel(Room.RoomName) ?? ""
roomTopic: r.relatedEventCacheBuster, fromModel(Room.RoomTopic) ?? ""
thumbnailUrl: r.relatedEventCacheBuster, fromModel(Room.ThumbnailUrl) ?? ""
type: r.relatedEventCacheBuster, fromModel(Room.Type) ?? MtxEvent.UnknownMessage
typeString: r.relatedEventCacheBuster, fromModel(Room.TypeString) ?? ""
url: r.relatedEventCacheBuster, fromModel(Room.Url) ?? ""
userColor: r.relatedEventCacheBuster, TimelineManager.userColor(userId, palette.base)
userId: r.relatedEventCacheBuster, fromModel(Room.UserId) ?? ""
userName: r.relatedEventCacheBuster, fromModel(Room.UserName) ?? ""
visible: replyTo
}
// actual message content
MessageDelegate {
id: contentItem
Layout.column: 0
Layout.fillWidth: true
Layout.preferredHeight: height
Layout.row: 1
blurhash: r.blurhash
body: r.body
callType: r.callType
duration: r.duration
encryptionError: r.encryptionError
eventId: r.eventId
filename: r.filename
filesize: r.filesize
formattedBody: r.formattedBody
isOnlyEmoji: r.isOnlyEmoji
isReply: false
isStateEvent: r.isStateEvent
metadataWidth: metadata.width
originalWidth: r.originalWidth
proportionalHeight: r.proportionalHeight
relatedEventCacheBuster: r.relatedEventCacheBuster
roomName: r.roomName
roomTopic: r.roomTopic
thumbnailUrl: r.thumbnailUrl
type: r.type
typeString: r.typeString ?? ""
url: r.url
userId: r.userId
userName: r.userName
}
Row {
id: metadata
property int iconSize: Math.floor(fontMetrics.ascent * scaling)
property double scaling: Settings.bubbles ? 0.75 : 1
Layout.alignment: Qt.AlignTop | Qt.AlignRight
Layout.bottomMargin: -2
Layout.column: Settings.bubbles ? 0 : 1
Layout.preferredWidth: implicitWidth
Layout.row: Settings.bubbles ? 2 : 0
Layout.rowSpan: Settings.bubbles ? 1 : 2
Layout.topMargin: (contentItem.fitsMetadata && Settings.bubbles) ? -height - Layout.bottomMargin : 0
spacing: 2
visible: !isStateEvent
StatusIndicator {
Layout.alignment: Qt.AlignRight | Qt.AlignTop
anchors.verticalCenter: ts.verticalCenter
eventId: r.eventId
height: parent.iconSize
status: r.status
width: parent.iconSize
}
Image {
Layout.alignment: Qt.AlignRight | Qt.AlignTop
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edited")
ToolTip.visible: editHovered.hovered
anchors.verticalCenter: ts.verticalCenter
height: parent.iconSize
source: "image://colorimage/:/icons/icons/ui/edit.svg?" + ((eventId == room.edit) ? palette.highlight : palette.buttonText)
sourceSize.height: parent.iconSize * Screen.devicePixelRatio
sourceSize.width: parent.iconSize * Screen.devicePixelRatio
visible: isEdited || eventId == room.edit
width: parent.iconSize
HoverHandler {
id: editHovered
}
}
ImageButton {
Layout.alignment: Qt.AlignRight | Qt.AlignTop
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Part of a thread")
ToolTip.visible: hovered
anchors.verticalCenter: ts.verticalCenter
buttonTextColor: TimelineManager.userColor(threadId, palette.base)
height: parent.iconSize
image: ":/icons/icons/ui/thread.svg"
visible: threadId
width: parent.iconSize
onClicked: room.thread = threadId
}
EncryptionIndicator {
Layout.alignment: Qt.AlignRight | Qt.AlignTop
anchors.verticalCenter: ts.verticalCenter
encrypted: isEncrypted
height: parent.iconSize
sourceSize.height: parent.iconSize * Screen.devicePixelRatio
sourceSize.width: parent.iconSize * Screen.devicePixelRatio
trust: trustlevel
visible: room.isEncrypted
width: parent.iconSize
}
Label {
id: ts
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.preferredWidth: implicitWidth
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: Qt.formatDateTime(timestamp, Qt.DefaultLocaleLongDate)
ToolTip.visible: ma.hovered
color: palette.inactive.text
font.pointSize: fontMetrics.font.pointSize * parent.scaling
text: timestamp.toLocaleTimeString(Locale.ShortFormat)
HoverHandler {
id: ma
}
}
}
}
}
Reactions {
id: reactionRow
eventId: r.eventId
layoutDirection: row.bubbleOnRight ? Qt.RightToLeft : Qt.LeftToRight
reactions: r.reactions
width: row.maxWidth
anchors {
left: row.bubbleOnRight ? undefined : row.left
right: row.bubbleOnRight ? row.right : undefined
top: row.bottom
topMargin: -4
}
}
Rectangle {
id: unreadRow
color: palette.highlight
height: visible ? 3 : 0
visible: (r.index > 0 && (room.fullyReadEventId == r.eventId))
anchors {
left: parent.left
right: parent.right
top: reactionRow.bottom
topMargin: 5
}
}
}

@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import Qt.labs.platform 1.1 as Platform
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.2
import QtQuick.Window 2.13
import im.nheko 1.0
Column {
required property var day
required property bool isSender
required property bool isStateEvent
required property int parentWidth
required property var previousMessageDay
required property bool previousMessageIsStateEvent
required property string previousMessageUserId
required property date timestamp
required property string userId
required property string userName
required property string userPowerlevel
bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3
spacing: 8
topPadding: userName_.visible ? 4 : 0
visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent)
width: parentWidth
Label {
id: dateBubble
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
color: palette.text
height: Math.round(fontMetrics.height * 1.4)
horizontalAlignment: Text.AlignHCenter
text: room ? room.formatDateSeparator(timestamp) : ""
verticalAlignment: Text.AlignVCenter
visible: room && previousMessageDay !== day
width: contentWidth * 1.2
background: Rectangle {
color: palette.window
radius: parent.height / 2
}
}
Row {
id: userInfo
property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width
height: userName_.height
spacing: 8
visible: !isStateEvent && (!isSender || !Settings.bubbles)
Avatar {
id: messageUserAvatar
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userid
ToolTip.visible: messageUserAvatar.hovered
displayName: userName
height: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1)
url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/")
userid: userId
width: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1)
onClicked: room.openUserProfile(userId)
}
Connections {
function onRoomAvatarUrlChanged() {
messageUserAvatar.url = room.avatarUrl(userId).replace("mxc://", "image://MxcImage/");
}
function onScrollToIndex(index) {
chat.positionViewAtIndex(index, ListView.Center);
}
target: room
}
AbstractButton {
id: userNameButton
PowerlevelIndicator {
id: powerlevelIndicator
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
powerlevel: userPowerlevel
height: fontMetrics.ascent
width: height
sourceSize.width: fontMetrics.lineSpacing
sourceSize.height: fontMetrics.lineSpacing
permissions: room ? room.permissions : null
visible: isAdmin || isModerator
}
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: userId
ToolTip.visible: hovered
leftPadding: powerlevelIndicator.visible ? 16 : 0
leftInset: 0
rightInset: 0
rightPadding: 0
contentItem: Label {
id: userName_
color: TimelineManager.userColor(userId, palette.base)
text: TimelineManager.escapeEmoji(userNameTextMetrics.elidedText)
textFormat: Text.RichText
}
onClicked: room.openUserProfile(userId)
TextMetrics {
id: userNameTextMetrics
elide: Text.ElideRight
elideWidth: userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3)
text: userName
}
NhekoCursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
}
Label {
id: statusMsg
property string userStatus: Presence.userStatus(userId)
ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("%1's status message").arg(userName)
ToolTip.visible: statusMsgHoverHandler.hovered
anchors.baseline: userNameButton.baseline
color: palette.buttonText
elide: Text.ElideRight
font.italic: true
font.pointSize: Math.floor(fontMetrics.font.pointSize * 0.8)
text: userStatus.replace(/\n/g, " ")
textFormat: Text.PlainText
width: Math.min(implicitWidth, userInfo.remainingWidth - userName_.width - parent.spacing)
HoverHandler {
id: statusMsgHoverHandler
}
Connections {
function onPresenceChanged(id) {
if (id == userId)
statusMsg.userStatus = Presence.userStatus(userId);
}
target: Presence
}
}
}
}

@ -285,25 +285,10 @@ Pane {
property var e: room ? room.getDump(modelData, "pins") : {}
Layout.fillWidth: true
Layout.preferredHeight: height
blurhash: e.blurhash ?? ""
body: e.body ?? ""
encryptionError: e.encryptionError ?? 0
maxWidth: pinnedMessages.width
//Layout.preferredHeight: height
eventId: e.eventId ?? ""
filename: e.filename ?? ""
filesize: e.filesize ?? ""
formattedBody: e.formattedBody ?? ""
isOnlyEmoji: e.isOnlyEmoji ?? false
keepFullText: true
originalWidth: e.originalWidth ?? 0
proportionalHeight: e.proportionalHeight ?? 1
type: e.type ?? MtxEvent.UnknownMessage
typeString: e.typeString ?? ""
url: e.url ?? ""
userColor: TimelineManager.userColor(e.userId, palette.window)
userId: e.userId ?? ""
userName: e.userName ?? ""
Connections {
function onPinnedMessagesChanged() {

@ -8,37 +8,34 @@ import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import im.nheko 1.0
Rectangle {
Control {
id: r
required property int encryptionError
required property string eventId
radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium
width: parent.width? parent.width : 0
implicitWidth: encryptedText.implicitWidth+24+Nheko.paddingMedium*3 // Column doesn't provide a useful implicitWidth, should be replaced by ColumnLayout
height: contents.implicitHeight + Nheko.paddingMedium * 2
color: palette.alternateBase
padding: Nheko.paddingMedium
implicitHeight: contents.implicitHeight + Nheko.paddingMedium * 2
Layout.maximumWidth: contents.Layout.maximumWidth + padding * 2
Layout.fillWidth: true
RowLayout {
contentItem: RowLayout {
id: contents
anchors.fill: parent
anchors.margins: Nheko.paddingMedium
spacing: Nheko.paddingMedium
Image {
source: "image://colorimage/:/icons/icons/ui/shield-filled-cross.svg?" + Nheko.theme.error
Layout.alignment: Qt.AlignVCenter
width: 24
height: width
Layout.preferredWidth: 24
Layout.preferredHeight: 24
}
Column {
ColumnLayout {
spacing: Nheko.paddingSmall
Layout.fillWidth: true
MatrixText {
Label {
id: encryptedText
text: {
switch (encryptionError) {
@ -58,8 +55,11 @@ Rectangle {
return qsTr("Unknown decryption error");
}
}
textFormat: Text.PlainText
wrapMode: Label.WordWrap
color: palette.text
width: parent.width
Layout.fillWidth: true
Layout.maximumWidth: implicitWidth + 1
}
Button {
@ -72,4 +72,9 @@ Rectangle {
}
background: Rectangle {
color: palette.alternateBase
radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingMedium
visible: !Settings.bubbles // the bubble in a bubble looks odd
}
}

@ -3,27 +3,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import ".."
import QtQuick 2.15
import QtQuick.Layouts 1.15
import im.nheko 1.0
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import im.nheko
Rectangle {
Control {
id: r
required property string username
required property string userName
radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium
width: parent.width ? Math.min(parent.width, 700) : 0
height: contents.implicitHeight + Nheko.paddingMedium * 2
color: palette.alternateBase
border.color: Nheko.theme.green
border.width: 2
padding: Nheko.paddingMedium
//implicitHeight: contents.implicitHeight + padd * 2
Layout.maximumWidth: contents.Layout.maximumWidth + padding * 2
Layout.fillWidth: true
RowLayout {
contentItem: RowLayout {
id: contents
anchors.fill: parent
anchors.margins: Nheko.paddingMedium
spacing: Nheko.paddingMedium
Image {
@ -33,26 +30,36 @@ Rectangle {
Layout.preferredHeight: 24
}
Column {
ColumnLayout {
spacing: Nheko.paddingSmall
Layout.fillWidth: true
MatrixText {
text: qsTr("%1 enabled end-to-end encryption").arg(r.username)
text: qsTr("%1 enabled end-to-end encryption").arg(r.userName)
font.bold: true
font.pointSize: 14
color: palette.text
width: parent.width
Layout.fillWidth: true
Layout.maximumWidth: implicitWidth + 1
}
MatrixText {
Label {
text: qsTr("Encryption keeps your messages safe by only allowing the people you sent the message to to read it. For extra security, if you want to make sure you are talking to the right people, you can verify them in real life.")
color: palette.text
width: parent.width
textFormat: Text.PlainText
wrapMode: Label.WordWrap
Layout.fillWidth: true
Layout.maximumWidth: implicitWidth + 1
}
}
}
background: Rectangle {
radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium
height: contents.implicitHeight + Nheko.paddingMedium * 2
color: palette.alternateBase
border.color: Nheko.theme.green
border.width: 2
}
}

@ -2,26 +2,30 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.12
import QtQuick.Layouts 1.2
import im.nheko 1.0
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import im.nheko
Control {
id: evRoot
Item {
required property string eventId
required property string filename
required property string filesize
height: rowa.height + (Settings.bubbles? 16: 24)
implicitWidth: rowa.implicitWidth + metadataWidth
property int metadataWidth
property bool fitsMetadata: true
padding: Settings.bubbles? 8 : 12
//Layout.preferredHeight: rowa.implicitHeight + padding
//Layout.maximumWidth: rowa.Layout.maximumWidth + metadataWidth + padding
property int metadataWidth: 0
property bool fitsMetadata: false
Layout.maximumWidth: rowa.Layout.maximumWidth + padding * 2
RowLayout {
contentItem: RowLayout {
id: rowa
anchors.centerIn: parent
width: parent.width - (Settings.bubbles? 16 : 24)
spacing: 15
spacing: 16
Rectangle {
id: button
@ -63,6 +67,7 @@ Item {
id: filename_
Layout.fillWidth: true
Layout.maximumWidth: implicitWidth + 1
text: filename
textFormat: Text.PlainText
elide: Text.ElideRight
@ -73,6 +78,7 @@ Item {
id: filesize_
Layout.fillWidth: true
Layout.maximumWidth: implicitWidth + 1
text: filesize
textFormat: Text.PlainText
elide: Text.ElideRight
@ -83,11 +89,9 @@ Item {
}
Rectangle {
background: Rectangle {
color: palette.alternateBase
z: -1
radius: 10
anchors.fill: parent
radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall
visible: !Settings.bubbles // the bubble in a bubble looks odd
}

@ -2,29 +2,31 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.15
import QtQuick.Window 2.15
import QtQuick.Controls 2.3
import im.nheko 1.0
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import im.nheko
AbstractButton {
required property int type
required property int originalWidth
required property int originalHeight
required property double proportionalHeight
required property string url
required property string blurhash
required property string body
required property string filename
required property bool isReply
required property string eventId
property double divisor: isReply ? 5 : 3
required property int containerHeight
property double divisor: EventDelegateChooser.isReply ? 10 : 4
property int tempWidth: originalWidth < 1? 400: originalWidth
EventDelegateChooser.keepAspectRatio: true
EventDelegateChooser.maxWidth: originalWidth
EventDelegateChooser.maxHeight: containerHeight / divisor
EventDelegateChooser.aspectRatio: proportionalHeight
implicitWidth: Math.round(tempWidth*Math.min((timelineView.height/divisor)/(tempWidth*proportionalHeight), 1))
width: Math.min(parent?.width ?? 2000,implicitWidth)
height: width*proportionalHeight
hoverEnabled: true
enabled: !EventDelegateChooser.isReply
state: (img.status != Image.Ready || timeline.privacyScreen.active) ? "BlurhashVisible" : "ImageVisible"
states: [
@ -116,6 +118,7 @@ AbstractButton {
source: url != "" ? (url.replace("mxc://", "image://MxcImage/") + "?scale") : ""
asynchronous: true
fillMode: Image.PreserveAspectFit
horizontalAlignment: Image.AlignLeft
smooth: true
mipmap: true
@ -127,21 +130,23 @@ AbstractButton {
id: mxcimage
visible: loaded
anchors.fill: parent
roomm: room
play: !Settings.animateImagesOnHover || parent.hovered
eventId: parent.eventId
anchors.fill: parent
}
Image {
id: blurhash_
anchors.fill: parent
source: blurhash ? ("image://blurhash/" + blurhash) : ("image://colorimage/:/icons/icons/ui/image-failed.svg?" + palette.buttonText)
asynchronous: true
fillMode: Image.PreserveAspectFit
sourceSize.width: parent.width * Screen.devicePixelRatio
sourceSize.height: parent.height * Screen.devicePixelRatio
anchors.fill: parent
}
onClicked: Settings.openImageExternal ? room.openMedia(eventId) : TimelineManager.openImageOverlay(room, url, eventId, originalWidth, proportionalHeight);
@ -150,6 +155,7 @@ AbstractButton {
id: overlay
anchors.fill: parent
visible: parent.hovered
Rectangle {

@ -1,779 +0,0 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.6
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.2
import im.nheko 1.0
Item {
id: d
required property bool isReply
property bool keepFullText: !isReply
property alias child: chooser.child
//implicitWidth: chooser.child?.implicitWidth ?? 0
required property double proportionalHeight
required property int type
required property string typeString
required property int originalWidth
required property int duration
required property string blurhash
required property string body
required property string formattedBody
required property string eventId
required property string filename
required property string filesize
required property string url
required property string thumbnailUrl
required property bool isOnlyEmoji
required property bool isStateEvent
required property string userId
required property string userName
required property string roomTopic
required property string roomName
required property string callType
required property int encryptionError
required property int relatedEventCacheBuster
property bool fitsMetadata: (chooser.child && chooser.child.fitsMetadata) ? chooser.child.fitsMetadata : false
property int metadataWidth
implicitWidth: chooser.child?.implicitWidth
height: chooser.child ? chooser.child.height : Nheko.paddingLarge
DelegateChooser {
id: chooser
//role: "type" //< not supported in our custom implementation, have to use roleValue
roleValue: type
//anchors.fill: parent
width: parent?.width ?? 0 // this should get rid of "cannot read property 'width' of null"
DelegateChoice {
roleValue: MtxEvent.UnknownEvent
Placeholder {
typeString: d.typeString
text: "Unretrieved event"
}
}
DelegateChoice {
roleValue: MtxEvent.Tombstone
ColumnLayout {
width: parent.width
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
Layout.fillWidth: true
formatted: qsTr("This room was replaced for the following reason: %1").arg(d.body)
}
Button {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Go to replacement room")
onClicked: room.joinReplacementRoom(eventId)
}
}
}
DelegateChoice {
roleValue: MtxEvent.TextMessage
TextMessage {
formatted: d.formattedBody
body: d.body
isOnlyEmoji: d.isOnlyEmoji
isReply: d.isReply
keepFullText: d.keepFullText
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.UnknownMessage
TextMessage {
formatted: d.formattedBody
body: d.body
isOnlyEmoji: d.isOnlyEmoji
isReply: d.isReply
keepFullText: d.keepFullText
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.ElementEffectMessage
TextMessage {
formatted: d.formattedBody
body: d.body
isOnlyEmoji: d.isOnlyEmoji
isReply: d.isReply
keepFullText: d.keepFullText
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.NoticeMessage
NoticeMessage {
formatted: d.formattedBody
body: d.body
isOnlyEmoji: d.isOnlyEmoji
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.EmoteMessage
NoticeMessage {
formatted: TimelineManager.escapeEmoji(d.userName) + " " + d.formattedBody
color: TimelineManager.userColor(d.userId, palette.base)
body: d.body
isOnlyEmoji: d.isOnlyEmoji
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.ImageMessage
ImageMessage {
type: d.type
originalWidth: d.originalWidth
proportionalHeight: d.proportionalHeight
url: d.url
blurhash: d.blurhash
body: d.body
filename: d.filename
isReply: d.isReply
eventId: d.eventId
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.Sticker
ImageMessage {
type: d.type
originalWidth: d.originalWidth
proportionalHeight: d.proportionalHeight
url: d.url
blurhash: d.blurhash
body: d.body
filename: d.filename
isReply: d.isReply
eventId: d.eventId
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.FileMessage
FileMessage {
eventId: d.eventId
filename: d.filename
filesize: d.filesize
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.VideoMessage
PlayableMediaMessage {
proportionalHeight: d.proportionalHeight
type: d.type
originalWidth: d.originalWidth
thumbnailUrl: d.thumbnailUrl
eventId: d.eventId
url: d.url
body: d.body
filesize: d.filesize
duration: d.duration
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.AudioMessage
PlayableMediaMessage {
proportionalHeight: d.proportionalHeight
type: d.type
originalWidth: d.originalWidth
thumbnailUrl: d.thumbnailUrl
eventId: d.eventId
url: d.url
body: d.body
filesize: d.filesize
duration: d.duration
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.Redacted
Redacted {
metadataWidth: d.metadataWidth
}
}
DelegateChoice {
roleValue: MtxEvent.Redaction
Pill {
text: qsTr("%1 removed a message").arg(d.userName)
isStateEvent: d.isStateEvent
}
}
DelegateChoice {
roleValue: MtxEvent.Encryption
EncryptionEnabled {
username: d.userName
}
}
DelegateChoice {
roleValue: MtxEvent.Encrypted
Encrypted {
encryptionError: d.encryptionError
eventId: d.eventId
}
}
DelegateChoice {
roleValue: MtxEvent.ServerAcl
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 changed which servers are allowed in this room.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.Name
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.roomName ? qsTr("%2 changed the room name to: %1").arg(d.roomName).arg(d.userName) : qsTr("%1 removed the room name").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.Topic
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.roomTopic ? qsTr("%2 changed the topic to: %1").arg(d.roomTopic).arg(d.userName): qsTr("%1 removed the topic").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.Avatar
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 changed the room avatar").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.PinnedEvents
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 changed the pinned messages.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.ImagePackInRoom
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatImagePackEvent(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.CanonicalAlias
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 changed the addresses for this room.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.SpaceParent
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 changed the parent communities for this room.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.RoomCreate
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 created and configured room: %2").arg(d.userName).arg(room.roomId)
}
}
DelegateChoice {
roleValue: MtxEvent.CallInvite
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: {
switch (d.callType) {
case "voice":
return qsTr("%1 placed a voice call.").arg(d.userName);
case "video":
return qsTr("%1 placed a video call.").arg(d.userName);
default:
return qsTr("%1 placed a call.").arg(d.userName);
}
}
}
}
DelegateChoice {
roleValue: MtxEvent.CallAnswer
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 answered the call.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.CallReject
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 rejected the call.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.CallSelectAnswer
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 select answer").arg(d.userName)
// formatted: qsTr("Call answered elsewhere")
}
}
DelegateChoice {
roleValue: MtxEvent.CallHangUp
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 ended the call.").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.CallCandidates
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 is negotiating the call...").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.CallNegotiate
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: qsTr("%1 is negotiating the call...").arg(d.userName)
}
}
DelegateChoice {
roleValue: MtxEvent.PowerLevels
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatPowerLevelEvent(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.PolicyRuleUser
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.PolicyRuleRoom
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.PolicyRuleServer
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.RoomJoinRules
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatJoinRuleEvent(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.RoomHistoryVisibility
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatHistoryVisibilityEvent(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.RoomGuestAccess
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: d.relatedEventCacheBuster, room.formatGuestAccessEvent(d.eventId)
}
}
DelegateChoice {
roleValue: MtxEvent.Member
ColumnLayout {
width: parent?.width ?? 100
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
Layout.fillWidth: true
formatted: d.relatedEventCacheBuster, room.formatMemberEvent(d.eventId)
}
Button {
visible: d.relatedEventCacheBuster, room.showAcceptKnockButton(d.eventId)
Layout.alignment: Qt.AlignHCenter
text: qsTr("Allow them in")
onClicked: room.acceptKnock(eventId)
}
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationRequest
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationRequest"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationStart
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationStart"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationReady
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationReady"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationCancel
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationCancel"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationKey
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationKey"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationMac
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationMac"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationDone
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationDone"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationDone
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationDone"
}
}
DelegateChoice {
roleValue: MtxEvent.KeyVerificationAccept
NoticeMessage {
body: formatted
isOnlyEmoji: false
isReply: d.isReply
keepFullText: d.keepFullText
isStateEvent: d.isStateEvent
formatted: "KeyVerificationAccept"
}
}
DelegateChoice {
Placeholder {
typeString: d.typeString
}
}
}
}

@ -22,7 +22,7 @@ Item {
required property string url
required property string body
required property string filesize
property double divisor: isReply ? 4 : 2
property double divisor: EventDelegateChooser.isReply ? 10 : 4
property int tempWidth: originalWidth < 1? 400: originalWidth
implicitWidth: type == MtxEvent.VideoMessage ? Math.round(tempWidth*Math.min((timelineView.height/divisor)/(tempWidth*proportionalHeight), 1)) : 500
width: Math.min(parent?.width ?? implicitWidth, implicitWidth)

@ -2,25 +2,22 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import im.nheko 1.0
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import im.nheko
Rectangle{
Control {
id: msgRoot
height: redactedLayout.implicitHeight + Nheko.paddingSmall
implicitWidth: redactedLayout.implicitWidth + 2 * Nheko.paddingMedium
width: Math.min(parent.width,implicitWidth+1)
radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall
color: palette.alternateBase
property int metadataWidth
property bool fitsMetadata: parent.width - redactedLayout.width > metadataWidth + 4
property int metadataWidth: 0
property bool fitsMetadata: false //parent.width - redactedLayout.width > metadataWidth + 4
required property string eventId
required property Room room
RowLayout {
contentItem: RowLayout {
id: redactedLayout
anchors.centerIn: parent
width: parent.width - 2 * Nheko.paddingMedium
spacing: Nheko.paddingSmall
Image {
@ -34,12 +31,11 @@ Rectangle{
id: redactedLabel
Layout.margins: 0
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
Layout.preferredWidth: implicitWidth
Layout.maximumWidth: implicitWidth + 1
Layout.fillWidth: true
property var redactedPair: room.formatRedactedEvent(eventId)
property var redactedPair: room.formatRedactedEvent(msgRoot.eventId)
text: redactedPair["first"]
wrapMode: Label.WordWrap
color: palette.text
ToolTip.text: redactedPair["second"]
ToolTip.visible: hh.hovered
@ -48,4 +44,13 @@ Rectangle{
}
}
}
padding: Nheko.paddingSmall
Layout.maximumWidth: redactedLayout.Layout.maximumWidth + padding * 2
background: Rectangle {
color: palette.alternateBase
radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall
}
}

@ -14,128 +14,88 @@ AbstractButton {
id: r
property color userColor: "red"
property double proportionalHeight
property int type
property string typeString
property int originalWidth
property string blurhash
property string body
property string formattedBody
property string eventId
property string filename
property string filesize
property string url
property bool isOnlyEmoji
property bool isStateEvent
property string userId
property string userName
property string thumbnailUrl
property string roomTopic
property string roomName
property string callType
property int duration
property int encryptionError
property int relatedEventCacheBuster
property int maxWidth
property bool keepFullText: false
height: replyContainer.height
implicitHeight: replyContainer.height
implicitWidth: visible? colorLine.width+Math.max(replyContainer.implicitWidth,userName_.fullTextWidth) : 0 // visible? seems to be causing issues
required property string eventId
property var room_: room
property string userId: eventId ? room.dataById(eventId, Room.UserId, "") : ""
property string userName: eventId ? room.dataById(eventId, Room.UserName, "") : ""
implicitHeight: replyContainer.implicitHeight
implicitWidth: replyContainer.implicitWidth
required property int maxWidth
NhekoCursorShape {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
}
Rectangle {
id: colorLine
anchors.top: replyContainer.top
anchors.bottom: replyContainer.bottom
width: 4
color: TimelineManager.userColor(userId, palette.base)
}
onClicked: {
let link = reply.child.linkAt != undefined && reply.child.linkAt(pressX-colorLine.width, pressY - userName_.implicitHeight);
let link = reply.child.linkAt != undefined && reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight);
if (link) {
Nheko.openLink(link)
} else {
room.showEvent(r.eventId)
}
}
onPressAndHold: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(pressX-colorLine.width, pressY - userName_.implicitHeight), r.eventId)
onPressAndHold: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight), r.eventId)
contentItem: TimelineEvent {
id: timelineEvent
ColumnLayout {
isStateEvent: false
room: room_
eventId: r.eventId
replyTo: ""
mainInset: 4 + Nheko.paddingMedium
maxWidth: r.maxWidth
//height: replyContainer.implicitHeight
data: Row {
id: replyContainer
anchors.left: colorLine.right
width: parent.width - 4
spacing: 0
spacing: Nheko.paddingSmall
TapHandler {
acceptedButtons: Qt.RightButton
onSingleTapped: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(eventPoint.position.x, eventPoint.position.y - userName_.implicitHeight), r.eventId)
gesturePolicy: TapHandler.ReleaseWithinBounds
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad
Rectangle {
id: colorline
width: 4
height: content.height
color: TimelineManager.userColor(r.userId, palette.base)
}
Column {
id: content
spacing: 0
AbstractButton {
Layout.leftMargin: 4
Layout.fillWidth: true
contentItem: ElidedLabel {
id: usernameBtn
contentItem: Label {
id: userName_
fullText: userName
text: r.userName
color: r.userColor
textFormat: Text.RichText
width: parent.width
elideWidth: width
width: timelineEvent.main?.width
}
onClicked: room.openUserProfile(userId)
onClicked: room.openUserProfile(r.userId)
}
MessageDelegate {
Layout.leftMargin: 4
Layout.preferredHeight: height
id: reply
blurhash: r.blurhash
body: r.body
formattedBody: r.formattedBody
eventId: r.eventId
filename: r.filename
filesize: r.filesize
proportionalHeight: r.proportionalHeight
type: r.type
typeString: r.typeString ?? ""
url: r.url
thumbnailUrl: r.thumbnailUrl
duration: r.duration
originalWidth: r.originalWidth
isOnlyEmoji: r.isOnlyEmoji
isStateEvent: r.isStateEvent
userId: r.userId
userName: r.userName
roomTopic: r.roomTopic
roomName: r.roomName
callType: r.callType
relatedEventCacheBuster: r.relatedEventCacheBuster
encryptionError: r.encryptionError
// This is disabled so that left clicking the reply goes to its location
enabled: false
Layout.fillWidth: true
isReply: true
keepFullText: r.keepFullText
data: [
usernameBtn, timelineEvent.main,
]
}
}
}
Rectangle {
background: Rectangle {
id: backgroundItem
z: -1
anchors.fill: replyContainer
property color userColor: TimelineManager.userColor(userId, palette.base)
property color userColor: TimelineManager.userColor(r.userId, palette.base)
property color bgColor: palette.base
color: Qt.tint(bgColor, Qt.hsla(userColor.hslHue, 0.5, userColor.hslLightness, 0.1))
}

@ -9,11 +9,12 @@ import im.nheko
MatrixText {
required property string body
required property bool isOnlyEmoji
required property bool isReply
property bool isReply: EventDelegateChooser.isReply
required property bool keepFullText
required property string formatted
property string copyText: selectedText ? getText(selectionStart, selectionEnd) : body
property int metadataWidth
property int metadataWidth: 100
property bool fitsMetadata: false //positionAt(width,height-4) == positionAt(width-metadataWidth-10, height-4)
// table border-collapse doesn't seem to work
@ -39,11 +40,8 @@ MatrixText {
}" : "") + // TODO(Nico): Figure out how to support mobile
"</style>
" + formatted.replace(/<del>/g, "<s>").replace(/<\/del>/g, "</s>").replace(/<strike>/g, "<s>").replace(/<\/strike>/g, "</s>")
width: parent?.width ?? 0
height: !keepFullText ? Math.round(Math.min(timelineView.height / 8, implicitHeight)) : implicitHeight
clip: !keepFullText
selectByMouse: !isReply
// enabled: !Settings.mobileMode
font.pointSize: (Settings.enlargeEmojiOnlyMessages && isOnlyEmoji > 0 && isOnlyEmoji < 4) ? Settings.fontSize * 3 : Settings.fontSize
NhekoCursorShape {

@ -9,6 +9,7 @@ Item {
id: effectRoot
readonly property int maxLifespan: Math.max(confettiEmitter.lifeSpan, rainfallEmitter.lifeSpan)
required property bool shouldEffectsRun
visible: effectRoot.shouldEffectsRun
function pulseConfetti()
{
@ -23,8 +24,9 @@ Item {
ParticleSystem {
id: particleSystem
Component.onCompleted: pause();
Component.onCompleted: stop();
paused: !effectRoot.shouldEffectsRun
running: effectRoot.shouldEffectsRun
}
Emitter {
@ -89,26 +91,47 @@ Item {
enabled: false
anchors.horizontalCenter: effectRoot.horizontalCenter
y: -60
emitRate: effectRoot.width / 50
emitRate: effectRoot.width / 30
lifeSpan: 10000
system: particleSystem
velocity: PointDirection {
x: 0
y: 300
y: 400
xVariation: 0
yVariation: 75
}
ItemParticle {
// causes high CPU load, see: https://bugreports.qt.io/browse/QTBUG-117923
//ItemParticle {
// system: particleSystem
// groups: ["rain"]
// fade: false
// visible: effectRoot.shouldEffectsRun
// delegate: Rectangle {
// width: 2
// height: 30 + 30 * Math.random()
// radius: 2
// color: "#0099ff"
// }
//}
ImageParticle {
system: particleSystem
groups: ["rain"]
fade: false
delegate: Rectangle {
width: 2
height: 30 + 30 * Math.random()
radius: 2
source: "qrc:/confettiparticle.svg"
rotationVelocity: 0
rotationVelocityVariation: 0
colorVariation: 0
color: "#0099ff"
entryEffect: ImageParticle.None
xVector: PointDirection {
x: 0.01
y: 0
}
yVector: PointDirection {
x: 0
y: 5
}
}
}
}
}

@ -0,0 +1,356 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "EventDelegateChooser.h"
#include "TimelineModel.h"
#include "Logging.h"
#include <QQmlEngine>
#include <QtGlobal>
#include <ranges>
// privat qt headers to access required properties
#include <QtQml/private/qqmlincubator_p.h>
#include <QtQml/private/qqmlobjectcreator_p.h>
QQmlComponent *
EventDelegateChoice::delegate() const
{
return delegate_;
}
void
EventDelegateChoice::setDelegate(QQmlComponent *delegate)
{
if (delegate != delegate_) {
delegate_ = delegate;
emit delegateChanged();
emit changed();
}
}
QList<int>
EventDelegateChoice::roleValues() const
{
return roleValues_;
}
void
EventDelegateChoice::setRoleValues(const QList<int> &value)
{
if (value != roleValues_) {
roleValues_ = value;
emit roleValuesChanged();
emit changed();
}
}
QQmlListProperty<EventDelegateChoice>
EventDelegateChooser::choices()
{
return QQmlListProperty<EventDelegateChoice>(this,
this,
&EventDelegateChooser::appendChoice,
&EventDelegateChooser::choiceCount,
&EventDelegateChooser::choice,
&EventDelegateChooser::clearChoices);
}
void
EventDelegateChooser::appendChoice(QQmlListProperty<EventDelegateChoice> *p, EventDelegateChoice *c)
{
EventDelegateChooser *dc = static_cast<EventDelegateChooser *>(p->object);
dc->choices_.append(c);
}
qsizetype
EventDelegateChooser::choiceCount(QQmlListProperty<EventDelegateChoice> *p)
{
return static_cast<EventDelegateChooser *>(p->object)->choices_.count();
}
EventDelegateChoice *
EventDelegateChooser::choice(QQmlListProperty<EventDelegateChoice> *p, qsizetype index)
{
return static_cast<EventDelegateChooser *>(p->object)->choices_.at(index);
}
void
EventDelegateChooser::clearChoices(QQmlListProperty<EventDelegateChoice> *p)
{
static_cast<EventDelegateChooser *>(p->object)->choices_.clear();
}
void
EventDelegateChooser::componentComplete()
{
QQuickItem::componentComplete();
eventIncubator.reset(eventId_);
replyIncubator.reset(replyId);
// eventIncubator.forceCompletion();
}
void
EventDelegateChooser::DelegateIncubator::setInitialState(QObject *obj)
{
auto item = qobject_cast<QQuickItem *>(obj);
if (!item)
return;
item->setParentItem(&chooser);
item->setParent(&chooser);
auto roleNames = chooser.room_->roleNames();
QHash<QByteArray, int> nameToRole;
for (const auto &[k, v] : roleNames.asKeyValueRange()) {
nameToRole.insert(v, k);
}
QHash<int, int> roleToPropIdx;
std::vector<QModelRoleData> roles;
// Workaround for https://bugreports.qt.io/browse/QTBUG-98846
QHash<QString, RequiredPropertyKey> requiredProperties;
for (const auto &[propKey, prop] :
QQmlIncubatorPrivate::get(this)->requiredProperties()->asKeyValueRange()) {
requiredProperties.insert(prop.propertyName, propKey);
}
// collect required properties
auto mo = obj->metaObject();
for (int i = 0; i < mo->propertyCount(); i++) {
auto prop = mo->property(i);
// nhlog::ui()->critical("Found prop {}", prop.name());
// See https://bugreports.qt.io/browse/QTBUG-98846
if (!prop.isRequired() && !requiredProperties.contains(prop.name()))
continue;
if (auto role = nameToRole.find(prop.name()); role != nameToRole.end()) {
roleToPropIdx.insert(*role, i);
roles.emplace_back(*role);
// nhlog::ui()->critical("Found prop {}, idx {}, role {}", prop.name(), i, *role);
} else {
nhlog::ui()->critical("Required property {} not found in model!", prop.name());
}
}
// nhlog::ui()->debug("Querying data for id {}", currentId.toStdString());
chooser.room_->multiData(currentId, forReply ? chooser.eventId_ : QString(), roles);
Qt::beginPropertyUpdateGroup();
auto attached = qobject_cast<EventDelegateChooserAttachedType *>(
qmlAttachedPropertiesObject<EventDelegateChooser>(obj));
Q_ASSERT(attached != nullptr);
attached->setIsReply(this->forReply);
for (const auto &role : roles) {
const auto &roleName = roleNames[role.role()];
// nhlog::ui()->critical("Setting role {}, {} to {}",
// role.role(),
// roleName.toStdString(),
// role.data().toString().toStdString());
// nhlog::ui()->critical("Setting {}", mo->property(roleToPropIdx[role.role()]).name());
mo->property(roleToPropIdx[role.role()]).write(obj, role.data());
if (const auto &req = requiredProperties.find(roleName); req != requiredProperties.end())
QQmlIncubatorPrivate::get(this)->requiredProperties()->remove(*req);
}
Qt::endPropertyUpdateGroup();
// setInitialProperties(rolesToSet);
auto update =
[this, obj, roleToPropIdx = std::move(roleToPropIdx)](const QList<int> &changedRoles) {
if (changedRoles.empty() || changedRoles.contains(TimelineModel::Roles::Type)) {
int type = chooser.room_
->dataById(currentId,
TimelineModel::Roles::Type,
forReply ? chooser.eventId_ : QString())
.toInt();
if (type != oldType) {
// nhlog::ui()->debug("Type changed!");
reset(currentId);
return;
}
}
std::vector<QModelRoleData> rolesToRequest;
if (changedRoles.empty()) {
for (const auto role :
std::ranges::subrange(roleToPropIdx.keyBegin(), roleToPropIdx.keyEnd()))
rolesToRequest.emplace_back(role);
} else {
for (auto role : changedRoles) {
if (roleToPropIdx.contains(role)) {
rolesToRequest.emplace_back(role);
}
}
}
if (rolesToRequest.empty())
return;
auto mo = obj->metaObject();
chooser.room_->multiData(
currentId, forReply ? chooser.eventId_ : QString(), rolesToRequest);
Qt::beginPropertyUpdateGroup();
for (const auto &role : rolesToRequest) {
mo->property(roleToPropIdx[role.role()]).write(obj, role.data());
}
Qt::endPropertyUpdateGroup();
};
if (!forReply) {
auto row = chooser.room_->idToIndex(currentId);
auto connection = connect(
chooser.room_,
&QAbstractItemModel::dataChanged,
obj,
[row, update](const QModelIndex &topLeft,
const QModelIndex &bottomRight,
const QList<int> &changedRoles) {
if (row < topLeft.row() || row > bottomRight.row())
return;
update(changedRoles);
},
Qt::QueuedConnection);
connect(&this->chooser, &EventDelegateChooser::destroyed, obj, [connection]() {
QObject::disconnect(connection);
});
}
}
void
EventDelegateChooser::DelegateIncubator::reset(QString id)
{
if (!chooser.room_ || id.isEmpty())
return;
// nhlog::ui()->debug("Reset with id {}, reply {}", id.toStdString(), forReply);
this->currentId = id;
auto role =
chooser.room_
->dataById(id, TimelineModel::Roles::Type, forReply ? chooser.eventId_ : QString())
.toInt();
this->oldType = role;
for (const auto choice : qAsConst(chooser.choices_)) {
const auto &choiceValue = choice->roleValues();
if (choiceValue.contains(role) || choiceValue.empty()) {
// nhlog::ui()->debug(
// "Instantiating type: {}, c {}", (int)role, choiceValue.contains(role));
if (auto child = qobject_cast<QQuickItem *>(object())) {
child->setParentItem(nullptr);
}
choice->delegate()->create(*this, QQmlEngine::contextForObject(&chooser));
return;
}
}
}
void
EventDelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status)
{
if (status == QQmlIncubator::Ready) {
auto child = qobject_cast<QQuickItem *>(object());
if (child == nullptr) {
nhlog::ui()->error("Delegate has to be derived of Item!");
return;
}
child->setParentItem(&chooser);
QQmlEngine::setObjectOwnership(child, QQmlEngine::ObjectOwnership::JavaScriptOwnership);
// connect(child, &QQuickItem::parentChanged, child, [child](QQuickItem *) {
// // QTBUG-115687
// if (child->flags().testFlag(QQuickItem::ItemObservesViewport)) {
// nhlog::ui()->critical("SETTING OBSERVES VIEWPORT");
// // Re-trigger the parent traversal to get subtreeTransformChangedEnabled turned
// on child->setFlag(QQuickItem::ItemObservesViewport);
// }
// });
if (forReply)
emit chooser.replyChanged();
else
emit chooser.mainChanged();
chooser.polish();
} else if (status == QQmlIncubator::Error) {
auto errors_ = errors();
for (const auto &e : qAsConst(errors_))
nhlog::ui()->error("Error instantiating delegate: {}", e.toString().toStdString());
}
}
void
EventDelegateChooser::updatePolish()
{
auto mainChild = qobject_cast<QQuickItem *>(eventIncubator.object());
auto replyChild = qobject_cast<QQuickItem *>(replyIncubator.object());
// nhlog::ui()->trace("POLISHING {}", (void *)this);
auto layoutItem = [this](QQuickItem *item, int inset) {
if (item) {
auto attached = qobject_cast<EventDelegateChooserAttachedType *>(
qmlAttachedPropertiesObject<EventDelegateChooser>(item));
Q_ASSERT(attached != nullptr);
int maxWidth = maxWidth_ - inset;
// in theory we could also reset the width, but that doesn't seem to work nicely for
// text areas because of how they cache it.
if (attached->maxWidth() > 0)
item->setWidth(attached->maxWidth());
else
item->setWidth(maxWidth);
item->ensurePolished();
auto width = item->implicitWidth();
if (width < 1 || width > maxWidth)
width = maxWidth;
if (attached->maxWidth() > 0 && width > attached->maxWidth())
width = attached->maxWidth();
if (attached->keepAspectRatio()) {
auto height = width * attached->aspectRatio();
if (attached->maxHeight() && height > attached->maxHeight()) {
height = attached->maxHeight();
width = height / attached->aspectRatio();
}
item->setHeight(height);
}
item->setWidth(width);
item->ensurePolished();
}
};
layoutItem(mainChild, mainInset_);
layoutItem(replyChild, replyInset_);
}
void
EventDelegateChooserAttachedType::polishChooser()
{
auto p = parent();
if (p) {
auto chooser = qobject_cast<EventDelegateChooser *>(p->parent());
if (chooser) {
chooser->polish();
}
}
}

@ -0,0 +1,276 @@
// SPDX-FileCopyrightText: Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QAbstractItemModel>
#include <QQmlComponent>
#include <QQmlIncubator>
#include <QQmlListProperty>
#include <QQuickItem>
#include <QtCore/QObject>
#include <QtCore/QVariant>
#include "TimelineModel.h"
class EventDelegateChooserAttachedType : public QObject
{
Q_OBJECT
Q_PROPERTY(bool keepAspectRatio READ keepAspectRatio WRITE setKeepAspectRatio NOTIFY
keepAspectRatioChanged)
Q_PROPERTY(double aspectRatio READ aspectRatio WRITE setAspectRatio NOTIFY aspectRatioChanged)
Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged)
Q_PROPERTY(int maxHeight READ maxHeight WRITE setMaxHeight NOTIFY maxHeightChanged)
Q_PROPERTY(bool isReply READ isReply WRITE setIsReply NOTIFY isReplyChanged)
QML_ANONYMOUS
public:
EventDelegateChooserAttachedType(QObject *parent)
: QObject(parent)
{
}
bool keepAspectRatio() const { return keepAspectRatio_; }
void setKeepAspectRatio(bool fill)
{
if (fill != keepAspectRatio_) {
keepAspectRatio_ = fill;
emit keepAspectRatioChanged();
polishChooser();
}
}
double aspectRatio() const { return aspectRatio_; }
void setAspectRatio(double fill)
{
aspectRatio_ = fill;
emit aspectRatioChanged();
polishChooser();
}
int maxWidth() const { return maxWidth_; }
void setMaxWidth(int fill)
{
maxWidth_ = fill;
emit maxWidthChanged();
polishChooser();
}
int maxHeight() const { return maxHeight_; }
void setMaxHeight(int fill)
{
maxHeight_ = fill;
emit maxHeightChanged();
}
bool isReply() const { return isReply_; }
void setIsReply(bool fill)
{
if (fill != isReply_) {
isReply_ = fill;
emit isReplyChanged();
polishChooser();
}
}
signals:
void keepAspectRatioChanged();
void aspectRatioChanged();
void maxWidthChanged();
void maxHeightChanged();
void isReplyChanged();
private:
void polishChooser();
double aspectRatio_ = 1.;
int maxWidth_ = -1;
int maxHeight_ = -1;
bool keepAspectRatio_ = false;
bool isReply_ = false;
};
class EventDelegateChoice : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_CLASSINFO("DefaultProperty", "delegate")
public:
Q_PROPERTY(QList<int> roleValues READ roleValues WRITE setRoleValues NOTIFY roleValuesChanged
REQUIRED FINAL)
Q_PROPERTY(
QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged REQUIRED FINAL)
[[nodiscard]] QQmlComponent *delegate() const;
void setDelegate(QQmlComponent *delegate);
[[nodiscard]] QList<int> roleValues() const;
void setRoleValues(const QList<int> &value);
signals:
void delegateChanged();
void roleValuesChanged();
void changed();
private:
QList<int> roleValues_;
QQmlComponent *delegate_ = nullptr;
};
class EventDelegateChooser : public QQuickItem
{
Q_OBJECT
QML_ELEMENT
Q_CLASSINFO("DefaultProperty", "choices")
QML_ATTACHED(EventDelegateChooserAttachedType)
Q_PROPERTY(QQmlListProperty<EventDelegateChoice> choices READ choices CONSTANT FINAL)
Q_PROPERTY(QQuickItem *main READ main NOTIFY mainChanged FINAL)
Q_PROPERTY(QQuickItem *reply READ reply NOTIFY replyChanged FINAL)
Q_PROPERTY(QString eventId READ eventId WRITE setEventId NOTIFY eventIdChanged REQUIRED FINAL)
Q_PROPERTY(QString replyTo READ replyTo WRITE setReplyTo NOTIFY replyToChanged REQUIRED FINAL)
Q_PROPERTY(TimelineModel *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED FINAL)
Q_PROPERTY(bool sameWidth READ sameWidth WRITE setSameWidth NOTIFY sameWidthChanged)
Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged)
Q_PROPERTY(int replyInset READ replyInset WRITE setReplyInset NOTIFY replyInsetChanged)
Q_PROPERTY(int mainInset READ mainInset WRITE setMainInset NOTIFY mainInsetChanged)
public:
QQmlListProperty<EventDelegateChoice> choices();
[[nodiscard]] QQuickItem *main() const
{
return qobject_cast<QQuickItem *>(eventIncubator.object());
}
[[nodiscard]] QQuickItem *reply() const
{
return qobject_cast<QQuickItem *>(replyIncubator.object());
}
bool sameWidth() const { return sameWidth_; }
void setSameWidth(bool width)
{
sameWidth_ = width;
emit sameWidthChanged();
}
int maxWidth() const { return maxWidth_; }
void setMaxWidth(int width)
{
maxWidth_ = width;
emit maxWidthChanged();
polish();
}
int replyInset() const { return replyInset_; }
void setReplyInset(int width)
{
replyInset_ = width;
emit replyInsetChanged();
polish();
}
int mainInset() const { return mainInset_; }
void setMainInset(int width)
{
mainInset_ = width;
emit mainInsetChanged();
polish();
}
void setRoom(TimelineModel *m)
{
if (m != room_) {
room_ = m;
emit roomChanged();
if (isComponentComplete()) {
eventIncubator.reset(eventId_);
replyIncubator.reset(replyId);
}
}
}
[[nodiscard]] TimelineModel *room() { return room_; }
void setEventId(QString idx)
{
eventId_ = idx;
emit eventIdChanged();
if (isComponentComplete())
eventIncubator.reset(eventId_);
}
[[nodiscard]] QString eventId() const { return eventId_; }
void setReplyTo(QString id)
{
replyId = id;
emit replyToChanged();
if (isComponentComplete())
replyIncubator.reset(replyId);
}
[[nodiscard]] QString replyTo() const { return replyId; }
void componentComplete() override;
static EventDelegateChooserAttachedType *qmlAttachedProperties(QObject *object)
{
return new EventDelegateChooserAttachedType(object);
}
void updatePolish() override;
signals:
void mainChanged();
void replyChanged();
void roomChanged();
void eventIdChanged();
void replyToChanged();
void sameWidthChanged();
void maxWidthChanged();
void replyInsetChanged();
void mainInsetChanged();
private:
struct DelegateIncubator final : public QQmlIncubator
{
DelegateIncubator(EventDelegateChooser &parent, bool forReply)
: QQmlIncubator(QQmlIncubator::AsynchronousIfNested)
, chooser(parent)
, forReply(forReply)
{
}
void setInitialState(QObject *object) override;
void statusChanged(QQmlIncubator::Status status) override;
void reset(QString id);
EventDelegateChooser &chooser;
bool forReply;
QString currentId;
QString instantiatedId;
int instantiatedRole = -1;
QAbstractItemModel *instantiatedModel = nullptr;
int oldType = -1;
};
QVariant roleValue_;
QList<EventDelegateChoice *> choices_;
DelegateIncubator eventIncubator{*this, false};
DelegateIncubator replyIncubator{*this, true};
TimelineModel *room_{nullptr};
QString eventId_;
QString replyId;
bool sameWidth_ = false;
int maxWidth_ = 400;
int replyInset_ = 0;
int mainInset_ = 0;
static void appendChoice(QQmlListProperty<EventDelegateChoice> *, EventDelegateChoice *);
static qsizetype choiceCount(QQmlListProperty<EventDelegateChoice> *);
static EventDelegateChoice *choice(QQmlListProperty<EventDelegateChoice> *, qsizetype index);
static void clearChoices(QQmlListProperty<EventDelegateChoice> *);
};

@ -843,8 +843,8 @@ EventStore::get(const std::string &id,
nhlog::net()->error(
"Failed to retrieve event with id {}, which was "
"requested to show the replyTo for event {}",
relatedTo,
id);
id,
relatedTo);
return;
}
emit eventFetched(id, relatedTo, timeline);

@ -541,7 +541,7 @@ RoomlistModel::sync(const mtx::responses::Sync &sync_)
if (auto t =
std::get_if<mtx::events::EphemeralEvent<mtx::events::ephemeral::Typing>>(
&ev)) {
std::vector<QString> typing;
QStringList typing;
typing.reserve(t->content.user_ids.size());
for (const auto &user : t->content.user_ids) {
if (user != http::client()->user_id().to_string())

@ -532,6 +532,7 @@ TimelineModel::roleNames() const
{IsOnlyEmoji, "isOnlyEmoji"},
{Body, "body"},
{FormattedBody, "formattedBody"},
{FormattedStateEvent, "formattedStateEvent"},
{IsSender, "isSender"},
{UserId, "userId"},
{UserName, "userName"},
@ -560,6 +561,7 @@ TimelineModel::roleNames() const
{ReplyTo, "replyTo"},
{ThreadId, "threadId"},
{Reactions, "reactions"},
{Room, "room"},
{RoomId, "roomId"},
{RoomName, "roomName"},
{RoomTopic, "roomTopic"},
@ -599,12 +601,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
case UserName:
return QVariant(displayName(QString::fromStdString(acc::sender(event))));
case UserPowerlevel: {
return static_cast<qlonglong>(mtx::events::state::PowerLevels{
cache::client()
->getStateEvent<mtx::events::state::PowerLevels>(room_id_.toStdString())
.value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
.content}
.user_level(acc::sender(event)));
return static_cast<qlonglong>(
permissions_.powerlevelEvent().user_level(acc::sender(event)));
}
case Day: {
@ -692,8 +690,90 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
formattedBody_.replace(curImg, imgReplacement);
}
if (auto effectMessage =
std::get_if<mtx::events::RoomEvent<mtx::events::msg::ElementEffect>>(&event)) {
if (effectMessage->content.msgtype == std::string_view("nic.custom.confetti")) {
formattedBody_.append(QUtf8StringView(u8"🎊"));
} else if (effectMessage->content.msgtype ==
std::string_view("io.element.effect.rainfall")) {
formattedBody_.append(QUtf8StringView(u8"🌧"));
}
}
return QVariant(utils::replaceEmoji(utils::linkifyMessage(formattedBody_)));
}
case FormattedStateEvent: {
if (mtx::accessors::is_state_event(event)) {
return std::visit(
[this](const auto &e) {
constexpr auto t = mtx::events::state_content_to_type<decltype(e.content)>;
if constexpr (t == mtx::events::EventType::RoomServerAcl)
return tr("%1 changed which servers are allowed in this room.")
.arg(displayName(QString::fromStdString(e.sender)));
else if constexpr (t == mtx::events::EventType::RoomName) {
if (e.content.name.empty())
return tr("%1 removed the room name.")
.arg(displayName(QString::fromStdString(e.sender)));
else
return tr("%1 changed the room name to: %2")
.arg(displayName(QString::fromStdString(e.sender)))
.arg(QString::fromStdString(e.content.name).toHtmlEscaped());
} else if constexpr (t == mtx::events::EventType::RoomTopic) {
if (e.content.topic.empty())
return tr("%1 removed the topic.")
.arg(displayName(QString::fromStdString(e.sender)));
else
return tr("%1 changed the topic to: %2")
.arg(displayName(QString::fromStdString(e.sender)))
.arg(QString::fromStdString(e.content.topic).toHtmlEscaped());
} else if constexpr (t == mtx::events::EventType::RoomAvatar) {
if (e.content.url.starts_with("mxc://"))
return tr("%1 changed the room avatar to: %2")
.arg(displayName(QString::fromStdString(e.sender)))
.arg(QStringLiteral("<img height=\"32\" src=\"%1\">")
.arg(QUrl::toPercentEncoding(
QString::fromStdString(e.content.url))));
else
return tr("%1 removed the room avatar.")
.arg(displayName(QString::fromStdString(e.sender)));
} else if constexpr (t == mtx::events::EventType::RoomPinnedEvents)
return tr("%1 changed the pinned messages.")
.arg(displayName(QString::fromStdString(e.sender)));
else if constexpr (t == mtx::events::EventType::ImagePackInRoom)
formatImagePackEvent(e);
else if constexpr (t == mtx::events::EventType::RoomCanonicalAlias)
return tr("%1 changed the addresses for this room.")
.arg(displayName(QString::fromStdString(e.sender)));
else if constexpr (t == mtx::events::EventType::SpaceParent)
return tr("%1 changed the parent communities for this room.")
.arg(displayName(QString::fromStdString(e.sender)));
else if constexpr (t == mtx::events::EventType::RoomCreate)
return tr("%1 created and configured room: %2")
.arg(displayName(QString::fromStdString(e.sender)))
.arg(room_id_);
else if constexpr (t == mtx::events::EventType::RoomPowerLevels)
return formatPowerLevelEvent(e);
else if constexpr (t == mtx::events::EventType::PolicyRuleRoom)
return formatPolicyRule(QString::fromStdString(e.event_id));
else if constexpr (t == mtx::events::EventType::PolicyRuleUser)
return formatPolicyRule(QString::fromStdString(e.event_id));
else if constexpr (t == mtx::events::EventType::PolicyRuleServer)
return formatPolicyRule(QString::fromStdString(e.event_id));
else if constexpr (t == mtx::events::EventType::RoomHistoryVisibility)
return formatHistoryVisibilityEvent(e);
else if constexpr (t == mtx::events::EventType::RoomGuestAccess)
return formatGuestAccessEvent(e);
else if constexpr (t == mtx::events::EventType::RoomMember)
return formatMemberEvent(e);
return tr("%1 changed unknown state event %2.")
.arg(displayName(QString::fromStdString(e.sender)))
.arg(QString::fromStdString(to_string(e.type)));
},
event);
}
return QString();
}
case Url:
return QVariant(QString::fromStdString(url(event)));
case ThumbnailUrl:
@ -828,6 +908,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
auto id = relations(event).replaces().value_or(event_id(event));
return QVariant::fromValue(events.reactions(id));
}
case Room:
return QVariant::fromValue(this);
case RoomId:
return QVariant(room_id_);
case RoomName:
@ -926,6 +1008,26 @@ TimelineModel::multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSp
}
}
void
TimelineModel::multiData(const QString &id,
const QString &relatedTo,
QModelRoleDataSpan roleDataSpan) const
{
if (id.isEmpty())
return;
auto event = events.get(id.toStdString(), relatedTo.toStdString());
if (!event)
return;
for (QModelRoleData &roleData : roleDataSpan) {
int role = roleData.role();
roleData.setData(data(*event, role));
}
}
QVariant
TimelineModel::dataById(const QString &id, int role, const QString &relatedTo)
{
@ -2196,7 +2298,7 @@ TimelineModel::markSpecialEffectsDone()
}
QString
TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor &bg)
TimelineModel::formatTypingUsers(const QStringList &users, const QColor &bg)
{
QString temp =
tr("%1 and %2 are typing.",
@ -2243,7 +2345,7 @@ TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor
};
uidWithoutLast.reserve(static_cast<int>(users.size()));
for (size_t i = 0; i + 1 < users.size(); i++) {
for (qsizetype i = 0; i + 1 < users.size(); i++) {
uidWithoutLast.append(formatUser(users[i]));
}
@ -2288,20 +2390,13 @@ TimelineModel::formatJoinRuleEvent(const QString &id)
}
QString
TimelineModel::formatGuestAccessEvent(const QString &id)
TimelineModel::formatGuestAccessEvent(
const mtx::events::StateEvent<mtx::events::state::GuestAccess> &event) const
{
auto e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(e);
if (!event)
return {};
QString user = QString::fromStdString(event->sender);
QString user = QString::fromStdString(event.sender);
QString name = utils::replaceEmoji(displayName(user));
switch (event->content.guest_access) {
switch (event.content.guest_access) {
case mtx::events::state::AccessState::CanJoin:
return tr("%1 made the room open to guests.").arg(name);
case mtx::events::state::AccessState::Forbidden:
@ -2312,21 +2407,13 @@ TimelineModel::formatGuestAccessEvent(const QString &id)
}
QString
TimelineModel::formatHistoryVisibilityEvent(const QString &id)
TimelineModel::formatHistoryVisibilityEvent(
const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const
{
auto e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(e);
if (!event)
return {};
QString user = QString::fromStdString(event->sender);
QString user = QString::fromStdString(event.sender);
QString name = utils::replaceEmoji(displayName(user));
switch (event->content.history_visibility) {
switch (event.content.history_visibility) {
case mtx::events::state::Visibility::WorldReadable:
return tr("%1 made the room history world readable. Events may be now read by "
"non-joined people.")
@ -2344,32 +2431,25 @@ TimelineModel::formatHistoryVisibilityEvent(const QString &id)
}
QString
TimelineModel::formatPowerLevelEvent(const QString &id)
TimelineModel::formatPowerLevelEvent(
const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const
{
auto e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(e);
if (!event)
return QString();
mtx::events::StateEvent<mtx::events::state::PowerLevels> const *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (!event.unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(tempPrevEvent);
}
}
QString user = QString::fromStdString(event->sender);
QString user = QString::fromStdString(event.sender);
QString sender_name = utils::replaceEmoji(displayName(user));
// Get the rooms levels for redactions and powerlevel changes to determine "Administrator" and
// "Moderator" powerlevels.
auto administrator_power_level = event->content.state_level("m.room.power_levels");
auto moderator_power_level = event->content.redact;
auto default_powerlevel = event->content.users_default;
auto administrator_power_level = event.content.state_level("m.room.power_levels");
auto moderator_power_level = event.content.redact;
auto default_powerlevel = event.content.users_default;
if (!prevEvent)
return tr("%1 has changed the room's permissions.").arg(sender_name);
@ -2379,7 +2459,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
auto numberOfAffected = 0;
// We do only compare to people with explicit PL. Usually others are not going to be
// affected either way and this is cheaper to iterate over.
for (auto const &[mxid, currentPowerlevel] : event->content.users) {
for (auto const &[mxid, currentPowerlevel] : event.content.users) {
if (currentPowerlevel == newPowerlevelSetting &&
prevEvent->content.user_level(mxid) < newPowerlevelSetting) {
numberOfAffected++;
@ -2393,16 +2473,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
QStringList resultingMessage{};
// These affect only a few people. Therefor we can print who is affected.
if (event->content.kick != prevEvent->content.kick) {
if (event.content.kick != prevEvent->content.kick) {
auto default_message = tr("%1 has changed the room's kick powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.kick)
.arg(event->content.kick);
.arg(event.content.kick);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.kick > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.kick);
if (event.content.kick > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event.content.kick);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
@ -2424,16 +2504,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
}
}
if (event->content.redact != prevEvent->content.redact) {
if (event.content.redact != prevEvent->content.redact) {
auto default_message = tr("%1 has changed the room's redact powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.redact)
.arg(event->content.redact);
.arg(event.content.redact);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.redact > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.redact);
if (event.content.redact > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event.content.redact);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
@ -2456,16 +2536,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
}
}
if (event->content.ban != prevEvent->content.ban) {
if (event.content.ban != prevEvent->content.ban) {
auto default_message = tr("%1 has changed the room's ban powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.ban)
.arg(event->content.ban);
.arg(event.content.ban);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.ban > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.ban);
if (event.content.ban > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event.content.ban);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
@ -2487,17 +2567,17 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
}
}
if (event->content.state_default != prevEvent->content.state_default) {
if (event.content.state_default != prevEvent->content.state_default) {
auto default_message =
tr("%1 has changed the room's state_default powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.state_default)
.arg(event->content.state_default);
.arg(event.content.state_default);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.state_default > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.kick);
if (event.content.state_default > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event.content.kick);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
@ -2521,42 +2601,42 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
// These affect potentially the whole room. We there for do not calculate who gets affected
// by this to prevent huge lists of people.
if (event->content.invite != prevEvent->content.invite) {
if (event.content.invite != prevEvent->content.invite) {
resultingMessage.append(tr("%1 has changed the room's invite powerlevel from %2 to %3.")
.arg(sender_name,
QString::number(prevEvent->content.invite),
QString::number(event->content.invite)));
QString::number(event.content.invite)));
}
if (event->content.events_default != prevEvent->content.events_default) {
if ((event->content.events_default > default_powerlevel) &&
if (event.content.events_default != prevEvent->content.events_default) {
if ((event.content.events_default > default_powerlevel) &&
prevEvent->content.events_default <= default_powerlevel) {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
"users can now not send any events.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
} else if ((event->content.events_default < prevEvent->content.events_default) &&
(event->content.events_default < default_powerlevel) &&
QString::number(event.content.events_default)));
} else if ((event.content.events_default < prevEvent->content.events_default) &&
(event.content.events_default < default_powerlevel) &&
(prevEvent->content.events_default > default_powerlevel)) {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
"users can now send events that are not otherwise restricted.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
QString::number(event.content.events_default)));
} else {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
QString::number(event.content.events_default)));
}
}
// Compare if a Powerlevel of a user changed
for (auto const &[mxid, powerlevel] : event->content.users) {
for (auto const &[mxid, powerlevel] : event.content.users) {
auto nameOfChangedUser = utils::replaceEmoji(displayName(QString::fromStdString(mxid)));
if (prevEvent->content.user_level(mxid) != powerlevel) {
if (powerlevel >= administrator_power_level) {
@ -2581,7 +2661,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
}
// Handle added/removed/changed event type
for (auto const &[event_type, powerlevel] : event->content.events) {
for (auto const &[event_type, powerlevel] : event.content.events) {
auto prev_not_present =
prevEvent->content.events.find(event_type) == prevEvent->content.events.end();
@ -2620,26 +2700,19 @@ TimelineModel::formatPowerLevelEvent(const QString &id)
}
QString
TimelineModel::formatImagePackEvent(const QString &id)
TimelineModel::formatImagePackEvent(
const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const
{
auto e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(e);
if (!event)
return {};
mtx::events::StateEvent<mtx::events::msc2545::ImagePack> const *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (!event.unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(tempPrevEvent);
}
}
const auto &newImages = event->content.images;
const auto &newImages = event.content.images;
const auto oldImages = prevEvent ? prevEvent->content.images : decltype(newImages){};
auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent();
@ -2662,12 +2735,12 @@ TimelineModel::formatImagePackEvent(const QString &id)
auto added = calcChange(newImages, oldImages);
auto removed = calcChange(oldImages, newImages);
auto sender = utils::replaceEmoji(displayName(QString::fromStdString(event->sender)));
auto sender = utils::replaceEmoji(displayName(QString::fromStdString(event.sender)));
const auto packId = [&event]() -> QString {
if (event->content.pack && !event->content.pack->display_name.empty()) {
return event->content.pack->display_name.c_str();
} else if (!event->state_key.empty()) {
return event->state_key.c_str();
if (event.content.pack && !event.content.pack->display_name.empty()) {
return event.content.pack->display_name.c_str();
} else if (!event.state_key.empty()) {
return event.state_key.c_str();
}
return tr("(empty)");
}();
@ -2692,7 +2765,7 @@ TimelineModel::formatImagePackEvent(const QString &id)
}
QString
TimelineModel::formatPolicyRule(const QString &id)
TimelineModel::formatPolicyRule(const QString &id) const
{
auto idStr = id.toStdString();
auto e = events.get(idStr, "");
@ -2893,34 +2966,27 @@ TimelineModel::joinReplacementRoom(const QString &id)
}
QString
TimelineModel::formatMemberEvent(const QString &id)
TimelineModel::formatMemberEvent(
const mtx::events::StateEvent<mtx::events::state::Member> &event) const
{
auto e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
if (!event)
return {};
mtx::events::StateEvent<mtx::events::state::Member> const *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (!event.unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(tempPrevEvent);
}
}
QString user = QString::fromStdString(event->state_key);
QString user = QString::fromStdString(event.state_key);
QString name = utils::replaceEmoji(displayName(user));
QString rendered;
QString sender = QString::fromStdString(event->sender);
QString sender = QString::fromStdString(event.sender);
QString senderName = utils::replaceEmoji(displayName(sender));
// see table https://matrix.org/docs/spec/client_server/latest#m-room-member
using namespace mtx::events::state;
switch (event->content.membership) {
switch (event.content.membership) {
case Membership::Invite:
rendered = tr("%1 invited %2.").arg(senderName, name);
break;
@ -2929,9 +2995,8 @@ TimelineModel::formatMemberEvent(const QString &id)
QString oldName = utils::replaceEmoji(
QString::fromStdString(prevEvent->content.display_name).toHtmlEscaped());
bool displayNameChanged =
prevEvent->content.display_name != event->content.display_name;
bool avatarChanged = prevEvent->content.avatar_url != event->content.avatar_url;
bool displayNameChanged = prevEvent->content.display_name != event.content.display_name;
bool avatarChanged = prevEvent->content.avatar_url != event.content.avatar_url;
if (displayNameChanged && avatarChanged)
rendered = tr("%1 has changed their avatar and changed their "
@ -2946,30 +3011,30 @@ TimelineModel::formatMemberEvent(const QString &id)
// the case of nothing changed but join follows join shouldn't happen, so
// just show it as join
} else {
if (event->content.join_authorised_via_users_server.empty())
if (event.content.join_authorised_via_users_server.empty())
rendered = tr("%1 joined.").arg(name);
else
rendered =
tr("%1 joined via authorisation from %2's server.")
.arg(name,
QString::fromStdString(event->content.join_authorised_via_users_server));
QString::fromStdString(event.content.join_authorised_via_users_server));
}
break;
case Membership::Leave:
if (!prevEvent || prevEvent->content.membership == Membership::Join) {
if (event->state_key == event->sender)
if (event.state_key == event.sender)
rendered = tr("%1 left the room.").arg(name);
else
rendered = tr("%2 kicked %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Invite) {
if (event->state_key == event->sender)
if (event.state_key == event.sender)
rendered = tr("%1 rejected their invite.").arg(name);
else
rendered = tr("%2 revoked the invite to %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Ban) {
rendered = tr("%2 unbanned %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Knock) {
if (event->state_key == event->sender)
if (event.state_key == event.sender)
rendered = tr("%1 redacted their knock.").arg(name);
else
rendered = tr("%2 rejected the knock from %1.").arg(name, senderName);
@ -2988,8 +3053,8 @@ TimelineModel::formatMemberEvent(const QString &id)
break;
}
if (event->content.reason != "") {
rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event->content.reason));
if (event.content.reason != "") {
rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event.content.reason));
}
return rendered;

@ -199,8 +199,8 @@ class TimelineModel final : public QAbstractListModel
QML_UNCREATABLE("")
Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged)
Q_PROPERTY(std::vector<QString> typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY
typingUsersChanged)
Q_PROPERTY(
QStringList typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY typingUsersChanged)
Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged)
Q_PROPERTY(QString reply READ reply WRITE setReply NOTIFY replyChanged RESET resetReply)
Q_PROPERTY(QString edit READ edit WRITE setEdit NOTIFY editChanged RESET resetEdit)
@ -238,6 +238,7 @@ public:
IsOnlyEmoji,
Body,
FormattedBody,
FormattedStateEvent,
IsSender,
UserId,
UserName,
@ -266,6 +267,7 @@ public:
ReplyTo,
ThreadId,
Reactions,
Room,
RoomId,
RoomName,
RoomTopic,
@ -286,6 +288,8 @@ public:
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
void multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSpan) const override;
void
multiData(const QString &id, const QString &relatedTo, QModelRoleDataSpan roleDataSpan) const;
QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const;
Q_INVOKABLE QVariant dataById(const QString &id, int role, const QString &relatedTo);
Q_INVOKABLE QVariant dataByIndex(int i, int role = Qt::DisplayRole) const
@ -302,17 +306,22 @@ public:
Q_INVOKABLE QString displayName(const QString &id) const;
Q_INVOKABLE QString avatarUrl(const QString &id) const;
Q_INVOKABLE QString formatDateSeparator(QDate date) const;
Q_INVOKABLE QString formatTypingUsers(const std::vector<QString> &users, const QColor &bg);
Q_INVOKABLE QString formatTypingUsers(const QStringList &users, const QColor &bg);
Q_INVOKABLE bool showAcceptKnockButton(const QString &id);
Q_INVOKABLE void acceptKnock(const QString &id);
Q_INVOKABLE void joinReplacementRoom(const QString &id);
Q_INVOKABLE QString formatMemberEvent(const QString &id);
Q_INVOKABLE QString
formatMemberEvent(const mtx::events::StateEvent<mtx::events::state::Member> &event) const;
Q_INVOKABLE QString formatJoinRuleEvent(const QString &id);
Q_INVOKABLE QString formatHistoryVisibilityEvent(const QString &id);
Q_INVOKABLE QString formatGuestAccessEvent(const QString &id);
Q_INVOKABLE QString formatPowerLevelEvent(const QString &id);
Q_INVOKABLE QString formatImagePackEvent(const QString &id);
Q_INVOKABLE QString formatPolicyRule(const QString &id);
QString formatHistoryVisibilityEvent(
const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const;
QString
formatGuestAccessEvent(const mtx::events::StateEvent<mtx::events::state::GuestAccess> &) const;
QString formatPowerLevelEvent(
const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const;
QString formatImagePackEvent(
const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const;
Q_INVOKABLE QString formatPolicyRule(const QString &id) const;
Q_INVOKABLE QVariantMap formatRedactedEvent(const QString &id);
Q_INVOKABLE void viewRawMessage(const QString &id);
@ -396,14 +405,14 @@ public slots:
void lastReadIdOnWindowFocus();
void checkAfterFetch();
QVariantMap getDump(const QString &eventId, const QString &relatedTo) const;
void updateTypingUsers(const std::vector<QString> &users)
void updateTypingUsers(const QStringList &users)
{
if (this->typingUsers_ != users) {
this->typingUsers_ = users;
emit typingUsersChanged(typingUsers_);
}
}
std::vector<QString> typingUsers() const { return typingUsers_; }
QStringList typingUsers() const { return typingUsers_; }
bool paginationInProgress() const { return m_paginationInProgress; }
QString reply() const { return reply_; }
void setReply(const QString &newReply);
@ -461,7 +470,7 @@ signals:
void redactionFailed(QString id);
void mediaCached(QString mxcUrl, QString cacheUrl);
void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo);
void typingUsersChanged(std::vector<QString> users);
void typingUsersChanged(QStringList users);
void replyChanged(QString reply);
void editChanged(QString reply);
void threadChanged(QString id);
@ -519,7 +528,7 @@ private:
QString currentId, currentReadId;
QString reply_, edit_, thread_;
QString textBeforeEdit, replyBeforeEdit;
std::vector<QString> typingUsers_;
QStringList typingUsers_;
TimelineViewManager *manager_;

@ -102,10 +102,12 @@ MxcAnimatedImage::startDownload()
if (buffer.bytesAvailable() <
4LL * 1024 * 1024 * 1024) // cache images smaller than 4MB in RAM
movie.setCacheMode(QMovie::CacheAll);
if (play_)
if (play_ && movie.frameCount() > 1)
movie.start();
else
else {
movie.jumpToFrame(0);
movie.setPaused(true);
}
emit loadedChanged();
update();
});
@ -173,6 +175,9 @@ MxcAnimatedImage::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeD
if (!imageDirty)
return oldNode;
if (clipRect().isEmpty())
return oldNode;
imageDirty = false;
QSGImageNode *n = static_cast<QSGImageNode *>(oldNode);
if (!n) {

@ -29,6 +29,7 @@ public:
connect(this, &MxcAnimatedImage::roomChanged, &MxcAnimatedImage::startDownload);
connect(&movie, &QMovie::frameChanged, this, &MxcAnimatedImage::newFrame);
setFlag(QQuickItem::ItemHasContents);
setFlag(QQuickItem::ItemObservesViewport);
// setAcceptHoverEvents(true);
}
@ -55,7 +56,12 @@ public:
{
if (play_ != newPlay) {
play_ = newPlay;
if (movie.frameCount() > 1)
movie.setPaused(!play_);
else {
movie.jumpToFrame(0);
movie.setPaused(true);
}
emit playChanged();
}
}
@ -77,6 +83,7 @@ private slots:
{
currentFrame = frame;
imageDirty = true;
if (!clipRect().isEmpty())
update();
}

@ -92,6 +92,7 @@ CallManager::CallManager(QObject *parent)
if (QGuiApplication::platformName() != QStringLiteral("wayland")) {
// Selected by default
screenShareType_ = ScreenShareType::X11;
if (screenShareTypes_.size() >= 2)
std::swap(screenShareTypes_[0], screenShareTypes_[1]);
}
}

Loading…
Cancel
Save