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