Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85832442
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
22 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/contents/ui/EventView.qml b/src/contents/ui/EventView.qml
index c06f1d0..ccb9c0b 100644
--- a/src/contents/ui/EventView.qml
+++ b/src/contents/ui/EventView.qml
@@ -1,274 +1,276 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.2
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Qt.labs.qmlmodels 1.0
import org.kde.kirigami 2.13 as Kirigami
import 'event-types' as Types
Item {
id: eventView
property var event
+ property var prevEvent
+ property var prevEventType: prevEvent ? getMessageType(prevEvent) : 'ignore'
property var sender
property var stateKeyUser
property var messageType: getMessageType(event)
property var iconSize: Kirigami.Units.iconSizes.large
property var inlineBadgeSize: Kirigami.Units.iconSizes.smallMedium
property var senderNameSize: Kirigami.Units.gridUnit * 1.2
property var redactPromise: null
property var minHeight: Kirigami.Units.gridUnit * 2
property var isGapped: false
property var compactMode: false
property var calculatedHeight: (gappedLabel.visible ? gappedLabel.height : 0) + (loader.item ? loader.item.implicitHeight : 0)
height: calculatedHeight
implicitHeight: calculatedHeight
Layout.minimumHeight: calculatedHeight
Layout.preferredHeight: calculatedHeight
Component {
id: textEV
Types.Text {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: emoteEV
Types.Emote {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: noticeEV
Types.Notice {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: stateEV
Types.State {
event: eventView.event
sender: eventView.sender
stateKeyUser: eventView.stateKeyUser
}
}
Component {
id: imageEV
Types.Image {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: fileEV
Types.File {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: videoEV
Types.Video {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: audioEV
Types.Audio {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: unknownEV
Types.Fallback {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: redactedEV
Types.Redacted {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: reactionEV
Types.Reaction {
event: eventView.event
sender: eventView.sender
}
}
Component {
id: ignoreEV
Item {
}
}
ToolButton {
id: gappedLabel
visible: eventView.isGapped && !eventView.compactMode
icon.name: 'content-loading-symbolic'
text: l10n.get('room-timeline-load-more-action')
anchors.top: eventView.top
anchors.bottom: loader.top
anchors.left: eventView.left
anchors.right: eventView.right
onClicked: eventView.paginateBack()
}
Loader {
sourceComponent: getSource(messageType)
id: loader
anchors.left: eventView.left
anchors.right: eventView.right
anchors.bottom: eventView.bottom
}
function getSource(t) {
switch (t) {
case 'text':
return textEV;
case 'emote':
return emoteEV;
case 'notice':
return noticeEV;
case 'state':
return stateEV;
case 'image':
return imageEV;
case 'file':
return fileEV;
case 'video':
return videoEV;
case 'audio':
return audioEV;
case 'redacted':
return redactedEV;
case 'reaction':
return reactionEV;
case 'ignore':
return ignoreEV;
default:
return unknownEV;
}
}
function getMessageType(e) {
if (e.redacted) {
return 'redacted';
}
if (e.isState) {
return 'state';
}
switch (e.type) {
case 'm.room.message':
switch (e.content.msgtype) {
case 'm.text':
return 'text';
case 'm.emote':
return 'emote';
case 'm.notice':
return 'notice';
case 'm.image':
return 'image';
case 'm.file':
return 'file';
case 'm.audio':
return 'audio';
case 'm.video':
return 'video';
case 'm.location':
return 'location';
default:
return 'unknown';
}
case 'm.sticker':
return 'image';
case 'm.room.redaction':
return 'ignore';
case 'm.reaction':
return 'reaction';
default:
return 'unknown';
}
}
function redactSelf(reason) {
if (eventView.event.eventId) {
eventView.redactPromise = room.redactEvent(eventView.event.eventId, reason);
} else {
// this is a local echo
eventView.redactPromise = room.removeLocalEcho(eventView.event.txnId);
}
}
Connections {
target: eventView.redactPromise
function onResolved(success, data) {
if (!success) {
showPassiveNotification(l10n.get('event-delete-failed', { errorMsg: data.error, errorCode: data.errorCode }));
}
eventView.redactPromise = null;
}
}
function paginateBack() {
room.paginateBackFrom(event.eventId);
}
function reactWith(reactionText) {
room.sendReaction(reactionText, event.eventId);
}
}
diff --git a/src/contents/ui/RoomTimelineView.qml b/src/contents/ui/RoomTimelineView.qml
index e6b10c9..6ca4fb0 100644
--- a/src/contents/ui/RoomTimelineView.qml
+++ b/src/contents/ui/RoomTimelineView.qml
@@ -1,33 +1,34 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.2
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import org.kde.kirigami 2.13 as Kirigami
ListView {
id: roomTimelineView
property var timeline
property var eventIds: timeline.eventIds
property string selectedEventId
spacing: Kirigami.Units.largeSpacing
model: timeline
currentIndex: -1
verticalLayoutDirection: ListView.BottomToTop
delegate: EventViewWrapper {
+ prevEvent: index < timeline.count - 1 ? timeline.at(index + 1) : undefined
event: timeline.at(index)
width: ListView.view.width
}
}
diff --git a/src/contents/ui/event-types/Simple.qml b/src/contents/ui/event-types/Simple.qml
index f9d95ff..614168b 100644
--- a/src/contents/ui/event-types/Simple.qml
+++ b/src/contents/ui/event-types/Simple.qml
@@ -1,93 +1,114 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2021 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import org.kde.kirigami 2.13 as Kirigami
ColumnLayout {
property var event
property var sender
default property var children
property var contentMaxWidth: {
(parent.width
// avatar size and margins
- iconSize
- 2 * Kirigami.Units.largeSpacing
- 2 * Kirigami.Units.largeSpacing
)
}
+ property var shouldCollapseSender: (
+ !compactMode
+ && prevEventType
+ && prevEventType !== 'ignore'
+ && (
+ event.sender === prevEvent.sender
+ // local echo does not have a sender, will use the current user
+ || (!!event.isLocalEcho && !!prevEvent.isLocalEcho)
+ || (!!event.isLocalEcho && prevEvent.sender === matrixSdk.userId)
+ )
+ )
id: layout
RowLayout {
// width: parent.width
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
- Layout.topMargin: Kirigami.Units.largeSpacing
+ Layout.topMargin: shouldCollapseSender ? 0 : Kirigami.Units.largeSpacing
+ Layout.bottomMargin: 0
Kirigami.Avatar {
id: avatar
+ objectName: 'senderAvatar'
Layout.alignment: Qt.AlignTop
sourceSize.width: iconSize
sourceSize.height: iconSize
source: sender.avatarMxcUri ? matrixSdk.mxcUriToHttp(sender.avatarMxcUri) : ''
name: sender.name || sender.userId
- visible: !compactMode
+ visible: !compactMode && !shouldCollapseSender
TapHandler {
onTapped: {
activateUserPage(sender, room);
}
}
}
+ Item {
+ objectName: 'senderCollapsedPlaceholder'
+ visible: shouldCollapseSender
+ Layout.preferredWidth: iconSize
+ Layout.preferredHeight: 1
+ }
+
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
RowLayout {
+ visible: !shouldCollapseSender
Kirigami.Avatar {
id: avatarCompact
Layout.preferredWidth: Kirigami.Units.iconSizes.sizeForLabels
Layout.preferredHeight: Kirigami.Units.iconSizes.sizeForLabels
sourceSize.width: Kirigami.Units.iconSizes.sizeForLabels
sourceSize.height: Kirigami.Units.iconSizes.sizeForLabels
source: sender.avatarMxcUri ? matrixSdk.mxcUriToHttp(sender.avatarMxcUri) : ''
name: sender.name || sender.userId
visible: compactMode
}
Label {
id: userNicknameText
text: sender.name || sender.userId
Layout.fillWidth: true
property var reasonableWidth: Math.max(contentWidth, Kirigami.Units.gridUnit)
TapHandler {
enabled: !compactMode
onTapped: {
if (point.position.x <= userNicknameText.reasonableWidth) {
mentionUserRequested(sender.userId);
}
}
}
}
}
RowLayout {
id: container
Layout.fillWidth: true
data: layout.children
}
}
}
}
diff --git a/src/tests/quick-tests/tst_EventView.qml b/src/tests/quick-tests/tst_EventView.qml
index 3344b65..a8180d5 100644
--- a/src/tests/quick-tests/tst_EventView.qml
+++ b/src/tests/quick-tests/tst_EventView.qml
@@ -1,347 +1,426 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.3
import QtQuick.Layouts 1.15
import QtTest 1.0
import '../../contents/ui' as Kazv
import 'test-helpers.js' as Helpers
import 'test-helpers' as TestHelpers
Item {
id: item
width: 800
height: 600
property var timeline: ({
gaps: [],
})
property var room: ({
test_resendMessageCalled: 0,
test_resendMessageLastTxnId: '',
test_removeLocalEchoCalled: 0,
test_removeLocalEchoLastTxnId: '',
resendMessage: (txnId) => {
++item.room.test_resendMessageCalled;
item.room.test_resendMessageLastTxnId = txnId;
},
removeLocalEcho: (txnId) => {
++item.room.test_removeLocalEchoCalled;
item.room.test_removeLocalEchoLastTxnId = txnId;
},
messageById: (_id) => item.textEvent,
member: (_id) => ({}),
})
property var l10n: Helpers.fluentMock
- property var matrixSdk: TestHelpers.MatrixSdkMock {}
+ property var matrixSdk: TestHelpers.MatrixSdkMock {
+ property var userId: '@foo:tusooa.xyz'
+ }
property var kazvIOManager: TestHelpers.KazvIOManagerMock {}
property var localEcho: ({
eventId: '',
sender: '',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
encrypted: false,
isState: false,
unsignedData: {},
isLocalEcho: true,
isSending: true,
isFailed: false,
txnId: 'some-txn-id',
})
property var failedLocalEcho: Object.assign({}, item.localEcho, { isSending: false, isFailed: true, })
property var textEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
formattedTime: '4:06 P.M.',
})
property var htmlEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: '**some body**',
format: 'org.matrix.custom.html',
formatted_body: '<strong>some body</strong>',
},
formattedTime: '4:06 P.M.',
})
property var replyHtmlEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
replyingToEventId: '$xxx',
content: {
msgtype: 'm.text',
body: '> some quote\n**some body**',
format: 'org.matrix.custom.html',
formatted_body: '<mx-reply><blockquote>some quote</blockquote></mx-reply><strong>some body</strong>',
},
formattedTime: '4:06 P.M.',
})
property var replyNoQuoteHtmlEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
replyingToEventId: '$xxx',
content: {
msgtype: 'm.text',
body: '**some body**',
format: 'org.matrix.custom.html',
formatted_body: '<strong>some body</strong>',
},
formattedTime: '4:06 P.M.',
})
property var redactedEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {},
redacted: true,
formattedTime: '4:06 P.M.',
})
property var imageEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.image',
body: 'some body',
},
formattedTime: '4:06 P.M.',
})
+ property var eventBySomeoneElse: ({
+ eventId: '',
+ sender: '@bar:tusooa.xyz',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.image',
+ body: 'some body',
+ },
+ formattedTime: '4:06 P.M.',
+ })
+
+ property var ignoredEvent: ({
+ eventId: '',
+ sender: '@foo:tusooa.xyz',
+ type: 'm.room.message',
+ relationType: 'm.replace',
+ stateKey: '',
+ content: {
+ msgtype: 'm.image',
+ body: 'some body',
+ },
+ formattedTime: '4:06 P.M.',
+ })
+
property var unknownTypeEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'moe.kazv.mxc.unknown.event',
stateKey: '',
content: {
msgtype: 'm.image',
body: 'some body',
},
})
property var unknownMsgtypeEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'moe.kazv.mxc.unknown.msgtype',
body: 'some body',
},
})
property var cannotDecryptEvent: ({
eventId: '',
- sender: '',
+ sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'moe.kazv.mxc.cannot.decrypt',
body: 'some body',
},
})
property var sender: ({
membership: 'join',
userId: '@foo:tusooa.xyz',
name: 'foo',
avatarMxcUri: '',
})
ColumnLayout {
anchors.fill: parent
Kazv.EventView {
Layout.fillWidth: true
id: eventView
event: item.localEcho
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewFailed
event: item.failedLocalEcho
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewText
event: item.textEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewHtml
event: item.htmlEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewHtmlReply
event: item.replyHtmlEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewHtmlReplyNoQuote
event: item.replyNoQuoteHtmlEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewRedacted
event: item.redactedEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewUnknownType
event: item.unknownTypeEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewUnknownMsgtype
event: item.unknownMsgtypeEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewCannotDecrypt
event: item.cannotDecryptEvent
sender: item.sender
}
+
+ Kazv.EventView {
+ id: eventViewCollapseSameSender
+ event: item.imageEvent
+ sender: item.sender
+ prevEvent: item.redactedEvent
+ }
+
+ Kazv.EventView {
+ id: eventViewCollapseLocalEcho
+ event: item.localEcho
+ sender: item.sender
+ prevEvent: item.redactedEvent
+ }
+
+ Kazv.EventView {
+ id: eventViewDontCollapseDifferentSender
+ event: item.imageEvent
+ sender: item.sender
+ prevEvent: item.eventBySomeoneElse
+ }
+
+ Kazv.EventView {
+ id: eventViewDontCollapseLocalEchoDifferentSender
+ event: item.localEcho
+ sender: item.sender
+ prevEvent: item.eventBySomeoneElse
+ }
+
+ Kazv.EventView {
+ id: eventViewDontCollapseIgnoredEvent
+ event: item.imageEvent
+ sender: item.sender
+ prevEvent: item.ignoredEvent
+ }
}
TestCase {
id: eventViewTest
name: 'EventViewTest'
when: windowShown
function init() {
eventView.event = item.localEcho;
item.room.test_resendMessageCalled = 0;
item.room.test_resendMessageLastTxnId = '';
item.room.test_removeLocalEchoCalled = 0;
item.room.test_removeLocalEchoLastTxnId = '';
}
function test_localEcho() {
const indicator = findChild(eventView, 'localEchoIndicator');
verify(indicator);
verify(indicator.source === 'state-sync');
verify(indicator.Accessible.name === l10n.get('event-sending'));
const resendButton = findChild(eventView, 'resendEventButton');
verify(resendButton);
verify(!resendButton.enabled);
verify(!findChild(eventView, 'timeIndicator').visible);
}
function test_localEchoFailedRemove() {
const indicator = findChild(eventViewFailed, 'localEchoIndicator');
verify(indicator);
verify(indicator.visible);
verify(indicator.source === 'state-warning');
verify(indicator.Accessible.name === l10n.get('event-resend'));
const deleteAction = findChild(eventViewFailed, 'deleteMenuItem');
deleteAction.trigger();
tryVerify(() => item.room.test_removeLocalEchoCalled === 1);
tryVerify(() => item.room.test_removeLocalEchoLastTxnId === 'some-txn-id');
verify(item.room.test_resendMessageCalled === 0);
verify(item.room.test_resendMessageLastTxnId === '');
}
function test_sentMessage() {
const indicator = findChild(eventViewText, 'localEchoIndicator');
verify(indicator);
verify(!indicator.visible);
verify(findChild(eventViewText, 'timeIndicator').visible);
}
function test_htmlMessage() {
const text = findChild(eventViewHtml, 'textEventContent');
verify(text.textFormat === TextEdit.RichText);
verify(text.text.includes('some body'));
}
function test_htmlMessageReply() {
const text = findChild(eventViewHtmlReply, 'textEventContent');
verify(text.textFormat === TextEdit.RichText);
verify(!text.text.includes('<blockquote>'));
}
function test_htmlMessageReplyNoQuote() {
const text = findChild(eventViewHtmlReplyNoQuote, 'textEventContent');
verify(text.textFormat === TextEdit.RichText);
verify(!text.text.includes('<blockquote>'));
verify(text.text.includes('some body'));
}
function test_menuText() {
const menu = findChild(eventViewText, 'bubbleContextMenu');
verify(menu);
tryVerify(() => findChild(menu, 'replyToMenuItem').enabled);
tryVerify(() => findChild(menu, 'deleteMenuItem').enabled);
}
function test_menuRedacted() {
const menu = findChild(eventViewRedacted, 'bubbleContextMenu');
verify(menu);
tryVerify(() => !findChild(menu, 'replyToMenuItem').enabled);
tryVerify(() => !findChild(menu, 'deleteMenuItem').enabled);
}
function test_fallback() {
verify(findChild(eventViewUnknownType, 'fallbackIcon'));
verify(findChild(eventViewUnknownType, 'textEventContent').text ===
l10n.get('event-fallback-text', { type: unknownTypeEvent.type }));
verify(findChild(eventViewUnknownMsgtype, 'fallbackIcon'));
verify(findChild(eventViewUnknownMsgtype, 'textEventContent').text ===
l10n.get('event-msgtype-fallback-text', { msgtype: unknownMsgtypeEvent.content.msgtype }));
verify(findChild(eventViewCannotDecrypt, 'fallbackIcon'));
verify(findChild(eventViewCannotDecrypt, 'textEventContent').text ===
l10n.get('event-cannot-decrypt-text'));
}
+
+ function test_collapseSender() {
+ verify(!findChild(eventViewCollapseSameSender, 'senderAvatar').visible);
+ verify(findChild(eventViewCollapseSameSender, 'senderCollapsedPlaceholder').visible);
+
+ verify(!findChild(eventViewCollapseLocalEcho, 'senderAvatar').visible);
+ verify(findChild(eventViewCollapseLocalEcho, 'senderCollapsedPlaceholder').visible);
+
+ verify(findChild(eventViewDontCollapseDifferentSender, 'senderAvatar').visible);
+ verify(!findChild(eventViewDontCollapseDifferentSender, 'senderCollapsedPlaceholder').visible);
+
+ verify(findChild(eventViewDontCollapseLocalEchoDifferentSender, 'senderAvatar').visible);
+ verify(!findChild(eventViewDontCollapseLocalEchoDifferentSender, 'senderCollapsedPlaceholder').visible);
+
+ verify(findChild(eventViewDontCollapseIgnoredEvent, 'senderAvatar').visible);
+ verify(!findChild(eventViewDontCollapseIgnoredEvent, 'senderCollapsedPlaceholder').visible);
+ }
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Nov 2, 8:22 AM (17 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803808
Default Alt Text
(22 KB)
Attached To
Mode
rK kazv
Attached
Detach File
Event Timeline