1// SPDX-FileCopyrightText: 2021 Nheko Contributors 2// 3// SPDX-License-Identifier: GPL-3.0-or-later 4 5import "./delegates" 6import "./emoji" 7import "./ui" 8import Qt.labs.platform 1.1 as Platform 9import QtQuick 2.15 10import QtQuick.Controls 2.15 11import QtQuick.Layouts 1.2 12import QtQuick.Window 2.13 13import im.nheko 1.0 14 15ScrollView { 16 clip: false 17 palette: Nheko.colors 18 padding: 8 19 ScrollBar.horizontal.visible: false 20 21 ListView { 22 id: chat 23 24 property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < parent.availableWidth) ? Settings.timelineMaxWidth : parent.availableWidth) - parent.padding * 2 25 26 displayMarginBeginning: height / 2 27 displayMarginEnd: height / 2 28 model: room 29 // reuseItems still has a few bugs, see https://bugreports.qt.io/browse/QTBUG-95105 https://bugreports.qt.io/browse/QTBUG-95107 30 //onModelChanged: if (room) room.sendReset() 31 //reuseItems: true 32 boundsBehavior: Flickable.StopAtBounds 33 pixelAligned: true 34 spacing: 4 35 verticalLayoutDirection: ListView.BottomToTop 36 onCountChanged: { 37 // Mark timeline as read 38 if (atYEnd && room) 39 model.currentIndex = 0; 40 41 } 42 43 Rectangle { 44 //closePolicy: Popup.NoAutoClose 45 46 id: messageActions 47 48 property Item attached: null 49 property alias model: row.model 50 // use comma to update on scroll 51 property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null 52 readonly property int padding: 4 53 54 visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || messageActionHover.hovered) 55 x: attached ? attachedPos.x : 0 56 y: attached ? attachedPos.y : 0 57 z: 10 58 height: row.implicitHeight + padding * 2 59 width: row.implicitWidth + padding * 2 60 color: Nheko.colors.window 61 border.color: Nheko.colors.buttonText 62 border.width: 1 63 radius: padding 64 65 HoverHandler { 66 id: messageActionHover 67 68 grabPermissions: PointerHandler.CanTakeOverFromAnything 69 } 70 71 Row { 72 id: row 73 74 property var model 75 76 anchors.centerIn: parent 77 spacing: messageActions.padding 78 79 ImageButton { 80 id: editButton 81 82 visible: !!row.model && row.model.isEditable 83 buttonTextColor: Nheko.colors.buttonText 84 width: 16 85 hoverEnabled: true 86 image: ":/icons/icons/ui/edit.svg" 87 ToolTip.visible: hovered 88 ToolTip.text: qsTr("Edit") 89 onClicked: { 90 if (row.model.isEditable) 91 chat.model.editAction(row.model.eventId); 92 93 } 94 } 95 96 ImageButton { 97 id: reactButton 98 99 visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false 100 width: 16 101 hoverEnabled: true 102 image: ":/icons/icons/ui/smile.svg" 103 ToolTip.visible: hovered 104 ToolTip.text: qsTr("React") 105 onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(reactButton, function(emoji) { 106 var event_id = row.model ? row.model.eventId : ""; 107 room.input.reaction(event_id, emoji); 108 TimelineManager.focusMessageInput(); 109 }) 110 } 111 112 ImageButton { 113 id: replyButton 114 115 visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false 116 width: 16 117 hoverEnabled: true 118 image: ":/icons/icons/ui/reply.svg" 119 ToolTip.visible: hovered 120 ToolTip.text: qsTr("Reply") 121 onClicked: chat.model.replyAction(row.model.eventId) 122 } 123 124 ImageButton { 125 id: optionsButton 126 127 width: 16 128 hoverEnabled: true 129 image: ":/icons/icons/ui/options.svg" 130 ToolTip.visible: hovered 131 ToolTip.text: qsTr("Options") 132 onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) 133 } 134 135 } 136 137 } 138 139 ScrollHelper { 140 flickable: parent 141 anchors.fill: parent 142 enabled: !Settings.mobileMode 143 } 144 145 Shortcut { 146 sequence: StandardKey.MoveToPreviousPage 147 onActivated: { 148 chat.contentY = chat.contentY - chat.height / 2; 149 chat.returnToBounds(); 150 } 151 } 152 153 Shortcut { 154 sequence: StandardKey.MoveToNextPage 155 onActivated: { 156 chat.contentY = chat.contentY + chat.height / 2; 157 chat.returnToBounds(); 158 } 159 } 160 161 Shortcut { 162 sequence: StandardKey.Cancel 163 onActivated: { 164 if (chat.model.reply) 165 chat.model.reply = undefined; 166 else 167 chat.model.edit = undefined; 168 } 169 } 170 171 Shortcut { 172 sequence: "Alt+Up" 173 onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0) 174 } 175 176 Shortcut { 177 sequence: "Alt+Down" 178 onActivated: { 179 var idx = chat.model.reply ? chat.model.idToIndex(chat.model.reply) - 1 : -1; 180 chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : null; 181 } 182 } 183 184 Shortcut { 185 sequence: "Alt+F" 186 onActivated: { 187 if (chat.model.reply) { 188 var forwardMess = forwardCompleterComponent.createObject(timelineRoot); 189 forwardMess.setMessageEventId(chat.model.reply); 190 forwardMess.open(); 191 chat.model.reply = null; 192 } 193 } 194 } 195 196 Shortcut { 197 sequence: "Ctrl+E" 198 onActivated: { 199 chat.model.edit = chat.model.reply; 200 } 201 } 202 203 Connections { 204 function onFocusChanged() { 205 readTimer.running = TimelineManager.isWindowFocused; 206 } 207 208 target: TimelineManager 209 } 210 211 Timer { 212 id: readTimer 213 214 // force current read index to update 215 onTriggered: { 216 if (chat.model) 217 chat.model.setCurrentIndex(chat.model.currentIndex); 218 219 } 220 interval: 1000 221 } 222 223 Component { 224 id: sectionHeader 225 226 Column { 227 topPadding: 4 228 bottomPadding: 4 229 spacing: 8 230 visible: (previousMessageUserId !== userId || previousMessageDay !== day) 231 width: parentWidth 232 height: ((previousMessageDay !== day) ? dateBubble.height + 8 + userName.height : userName.height) + 8 233 234 Label { 235 id: dateBubble 236 237 anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined 238 visible: room && previousMessageDay !== day 239 text: room ? room.formatDateSeparator(timestamp) : "" 240 color: Nheko.colors.text 241 height: Math.round(fontMetrics.height * 1.4) 242 width: contentWidth * 1.2 243 horizontalAlignment: Text.AlignHCenter 244 verticalAlignment: Text.AlignVCenter 245 246 background: Rectangle { 247 radius: parent.height / 2 248 color: Nheko.colors.window 249 } 250 251 } 252 253 Row { 254 height: userName_.height 255 spacing: 8 256 257 Avatar { 258 id: messageUserAvatar 259 260 width: Nheko.avatarSize 261 height: Nheko.avatarSize 262 url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") 263 displayName: userName 264 userid: userId 265 onClicked: room.openUserProfile(userId) 266 ToolTip.visible: avatarHover.hovered 267 ToolTip.text: userid 268 269 HoverHandler { 270 id: avatarHover 271 } 272 273 } 274 275 Connections { 276 function onRoomAvatarUrlChanged() { 277 messageUserAvatar.url = chat.model.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); 278 } 279 280 function onScrollToIndex(index) { 281 chat.positionViewAtIndex(index, ListView.Center); 282 } 283 284 target: chat.model 285 } 286 287 Label { 288 id: userName_ 289 290 text: TimelineManager.escapeEmoji(userName) 291 color: TimelineManager.userColor(userId, Nheko.colors.window) 292 textFormat: Text.RichText 293 ToolTip.visible: displayNameHover.hovered 294 ToolTip.text: userId 295 296 TapHandler { 297 onSingleTapped: chat.model.openUserProfile(userId) 298 } 299 300 CursorShape { 301 anchors.fill: parent 302 cursorShape: Qt.PointingHandCursor 303 } 304 305 HoverHandler { 306 id: displayNameHover 307 } 308 309 } 310 311 Label { 312 color: Nheko.colors.buttonText 313 text: TimelineManager.userStatus(userId) 314 textFormat: Text.PlainText 315 elide: Text.ElideRight 316 width: chat.delegateMaxWidth - parent.spacing * 2 - userName.implicitWidth - Nheko.avatarSize 317 font.italic: true 318 } 319 320 } 321 322 } 323 324 } 325 326 delegate: Item { 327 id: wrapper 328 329 required property double proportionalHeight 330 required property int type 331 required property string typeString 332 required property int originalWidth 333 required property string blurhash 334 required property string body 335 required property string formattedBody 336 required property string eventId 337 required property string filename 338 required property string filesize 339 required property string url 340 required property string thumbnailUrl 341 required property bool isOnlyEmoji 342 required property bool isSender 343 required property bool isEncrypted 344 required property bool isEditable 345 required property bool isEdited 346 required property string replyTo 347 required property string userId 348 required property string roomTopic 349 required property string roomName 350 required property string callType 351 required property var reactions 352 required property int trustlevel 353 required property int encryptionError 354 required property var timestamp 355 required property int status 356 required property int index 357 required property int relatedEventCacheBuster 358 required property string previousMessageUserId 359 required property string day 360 required property string previousMessageDay 361 required property string userName 362 property bool scrolledToThis: eventId === chat.model.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) 363 364 anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined 365 width: chat.delegateMaxWidth 366 height: section.active ? section.height + timelinerow.height : timelinerow.height 367 368 Rectangle { 369 id: scrollHighlight 370 371 opacity: 0 372 visible: true 373 anchors.fill: timelinerow 374 color: Nheko.colors.highlight 375 376 states: State { 377 name: "revealed" 378 when: wrapper.scrolledToThis 379 } 380 381 transitions: Transition { 382 from: "" 383 to: "revealed" 384 385 SequentialAnimation { 386 PropertyAnimation { 387 target: scrollHighlight 388 properties: "opacity" 389 easing.type: Easing.InOutQuad 390 from: 0 391 to: 1 392 duration: 500 393 } 394 395 PropertyAnimation { 396 target: scrollHighlight 397 properties: "opacity" 398 easing.type: Easing.InOutQuad 399 from: 1 400 to: 0 401 duration: 500 402 } 403 404 ScriptAction { 405 script: chat.model.eventShown() 406 } 407 408 } 409 410 } 411 412 } 413 414 Loader { 415 id: section 416 417 property int parentWidth: parent.width 418 property string userId: wrapper.userId 419 property string previousMessageUserId: wrapper.previousMessageUserId 420 property string day: wrapper.day 421 property string previousMessageDay: wrapper.previousMessageDay 422 property string userName: wrapper.userName 423 property date timestamp: wrapper.timestamp 424 425 z: 4 426 active: previousMessageUserId !== undefined && previousMessageUserId !== userId || previousMessageDay !== day 427 //asynchronous: true 428 sourceComponent: sectionHeader 429 visible: status == Loader.Ready 430 } 431 432 TimelineRow { 433 id: timelinerow 434 435 property alias hovered: hoverHandler.hovered 436 437 proportionalHeight: wrapper.proportionalHeight 438 type: chat.model, wrapper.type 439 typeString: wrapper.typeString 440 originalWidth: wrapper.originalWidth 441 blurhash: wrapper.blurhash 442 body: wrapper.body 443 formattedBody: wrapper.formattedBody 444 eventId: chat.model, wrapper.eventId 445 filename: wrapper.filename 446 filesize: wrapper.filesize 447 url: wrapper.url 448 thumbnailUrl: wrapper.thumbnailUrl 449 isOnlyEmoji: wrapper.isOnlyEmoji 450 isSender: wrapper.isSender 451 isEncrypted: wrapper.isEncrypted 452 isEditable: wrapper.isEditable 453 isEdited: wrapper.isEdited 454 replyTo: wrapper.replyTo 455 userId: wrapper.userId 456 userName: wrapper.userName 457 roomTopic: wrapper.roomTopic 458 roomName: wrapper.roomName 459 callType: wrapper.callType 460 reactions: wrapper.reactions 461 trustlevel: wrapper.trustlevel 462 encryptionError: wrapper.encryptionError 463 timestamp: wrapper.timestamp 464 status: wrapper.status 465 relatedEventCacheBuster: wrapper.relatedEventCacheBuster 466 y: section.visible && section.active ? section.y + section.height : 0 467 468 HoverHandler { 469 id: hoverHandler 470 471 enabled: !Settings.mobileMode 472 onHoveredChanged: { 473 if (hovered) { 474 if (!messageActionHover.hovered) { 475 messageActions.attached = timelinerow; 476 messageActions.model = timelinerow; 477 } 478 } 479 } 480 } 481 482 } 483 484 Connections { 485 function onMovementEnded() { 486 if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) 487 chat.model.currentIndex = index; 488 489 } 490 491 target: chat 492 } 493 494 } 495 496 footer: Item { 497 anchors.horizontalCenter: parent.horizontalCenter 498 anchors.margins: Nheko.paddingLarge 499 visible: chat.model && chat.model.paginationInProgress 500 // hacky, but works 501 height: loadingSpinner.height + 2 * Nheko.paddingLarge 502 503 Spinner { 504 id: loadingSpinner 505 506 anchors.centerIn: parent 507 anchors.margins: Nheko.paddingLarge 508 running: chat.model && chat.model.paginationInProgress 509 foreground: Nheko.colors.mid 510 z: 3 511 } 512 513 } 514 515 } 516 517 Platform.Menu { 518 id: messageContextMenu 519 520 property string eventId 521 property string link 522 property string text 523 property int eventType 524 property bool isEncrypted 525 property bool isEditable 526 property bool isSender 527 528 function show(eventId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) { 529 eventId = eventId_; 530 eventType = eventType_; 531 isEncrypted = isEncrypted_; 532 isEditable = isEditable_; 533 isSender = isSender_; 534 if (text_) 535 text = text_; 536 else 537 text = ""; 538 if (link_) 539 link = link_; 540 else 541 link = ""; 542 if (showAt_) 543 open(showAt_); 544 else 545 open(); 546 } 547 548 Platform.MenuItem { 549 visible: messageContextMenu.text 550 enabled: visible 551 text: qsTr("&Copy") 552 onTriggered: Clipboard.text = messageContextMenu.text 553 } 554 555 Platform.MenuItem { 556 visible: messageContextMenu.link 557 enabled: visible 558 text: qsTr("Copy &link location") 559 onTriggered: Clipboard.text = messageContextMenu.link 560 } 561 562 Platform.MenuItem { 563 id: reactionOption 564 565 visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false 566 text: qsTr("Re&act") 567 onTriggered: emojiPopup.show(null, function(emoji) { 568 room.input.reaction(messageContextMenu.eventId, emoji); 569 }) 570 } 571 572 Platform.MenuItem { 573 visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false 574 text: qsTr("Repl&y") 575 onTriggered: room.replyAction(messageContextMenu.eventId) 576 } 577 578 Platform.MenuItem { 579 visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) 580 enabled: visible 581 text: qsTr("&Edit") 582 onTriggered: room.editAction(messageContextMenu.eventId) 583 } 584 585 Platform.MenuItem { 586 text: qsTr("Read receip&ts") 587 onTriggered: room.showReadReceipts(messageContextMenu.eventId) 588 } 589 590 Platform.MenuItem { 591 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 592 text: qsTr("&Forward") 593 onTriggered: { 594 var forwardMess = forwardCompleterComponent.createObject(timelineRoot); 595 forwardMess.setMessageEventId(messageContextMenu.eventId); 596 forwardMess.open(); 597 } 598 } 599 600 Platform.MenuItem { 601 text: qsTr("&Mark as read") 602 } 603 604 Platform.MenuItem { 605 text: qsTr("View raw message") 606 onTriggered: room.viewRawMessage(messageContextMenu.eventId) 607 } 608 609 Platform.MenuItem { 610 // TODO(Nico): Fix this still being iterated over, when using keyboard to select options 611 visible: messageContextMenu.isEncrypted 612 enabled: visible 613 text: qsTr("View decrypted raw message") 614 onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId) 615 } 616 617 Platform.MenuItem { 618 visible: (room ? room.permissions.canRedact() : false) || messageContextMenu.isSender 619 text: qsTr("Remo&ve message") 620 onTriggered: room.redactEvent(messageContextMenu.eventId) 621 } 622 623 Platform.MenuItem { 624 visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker 625 enabled: visible 626 text: qsTr("&Save as") 627 onTriggered: room.saveMedia(messageContextMenu.eventId) 628 } 629 630 Platform.MenuItem { 631 visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker 632 enabled: visible 633 text: qsTr("&Open in external program") 634 onTriggered: room.openMedia(messageContextMenu.eventId) 635 } 636 637 Platform.MenuItem { 638 visible: messageContextMenu.eventId 639 enabled: visible 640 text: qsTr("Copy link to eve&nt") 641 onTriggered: room.copyLinkToEvent(messageContextMenu.eventId) 642 } 643 644 } 645 646 Component { 647 id: forwardCompleterComponent 648 649 ForwardCompleter { 650 } 651 652 } 653 654 Platform.Menu { 655 id: replyContextMenu 656 657 property string text 658 property string link 659 660 function show(text_, link_) { 661 text = text_; 662 link = link_; 663 open(); 664 } 665 666 Platform.MenuItem { 667 visible: replyContextMenu.text 668 enabled: visible 669 text: qsTr("&Copy") 670 onTriggered: Clipboard.text = replyContextMenu.text 671 } 672 673 Platform.MenuItem { 674 visible: replyContextMenu.link 675 enabled: visible 676 text: qsTr("Copy &link location") 677 onTriggered: Clipboard.text = replyContextMenu.link 678 } 679 680 Platform.MenuItem { 681 visible: true 682 enabled: visible 683 text: qsTr("&Go to quoted message") 684 onTriggered: chat.model.showEvent(eventId) 685 } 686 687 } 688 689} 690