Page MenuHomePhorge

No OneTemporary

Size
167 KB
Referenced Files
None
Subscribers
None
diff --git a/src/contents/ui/Bubble.qml b/src/contents/ui/Bubble.qml
index 96d844f..be83bbf 100644
--- a/src/contents/ui/Bubble.qml
+++ b/src/contents/ui/Bubble.qml
@@ -1,271 +1,270 @@
/*
* 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.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import QtQuick.Window 2.15
import org.kde.kirigami 2.13 as Kirigami
import '.' as Kazv
import 'matrix-helpers.js' as Helpers
ItemDelegate {
id: upper
objectName: 'bubble'
default property var children
property var currentEvent: event
property var menuContent: []
property var shouldDisplayTime: displayTime || (!compactMode && !event.isLocalEcho)
property var eventPinned: event.eventId && pinnedEvents.eventIds.hasOwnProperty(event.eventId)
readonly property var eventReactions: event.reactions()
topPadding: 0
bottomPadding: 0
readonly property var bubbleSpacing: leftPadding + rightPadding
readonly property double replyMaxHeight: Kirigami.Units.gridUnit * 7
Layout.fillWidth: true
property var backgroundItem: Rectangle {
anchors.fill: parent
color: (hovered || isSelected) ? Kirigami.Theme.activeBackgroundColor : Kirigami.Theme.backgroundColor
}
background: compactMode ? null : backgroundItem
property var eventSourcePopupComp: Component {
Kazv.SelfDestroyableOverlaySheet {
shouldSelfDestroy: true
title: l10n.get('event-source-popup-title')
Kazv.EventSourceView {
Layout.preferredWidth: Math.min(Kirigami.Units.gridUnit * 40, Window.width)
event: upper.currentEvent
}
}
}
property var reactionPopupComp: Component {
Kazv.ReactToEventPopup {
shouldSelfDestroy: true
onAccepted: eventView.reactWith(text)
}
}
function getIsEditable(event) {
return event.sender === matrixSession.userId
&& event.type === 'm.room.message'
&& event.content.msgtype === 'm.text';
}
property var menuComp: Component {
Menu {
objectName: 'bubbleContextMenu'
property list<QtObject> staticItems: [
Kirigami.Action {
objectName: 'replyToMenuItem'
text: l10n.get('event-reply-action')
onTriggered: setDraftRelation('m.in_reply_to', currentEvent.eventId)
enabled: event && !event.redacted
},
Kirigami.Action {
objectName: 'editMenuItem'
text: l10n.get('event-edit-action')
onTriggered: {
setDraftRelation('m.replace', currentEvent.eventId, Helpers.getEventBodyForEditing(event));
}
enabled: event && !event.redacted && !event.isLocalEcho && getIsEditable(event)
},
Kirigami.Action {
objectName: 'deleteMenuItem'
text: l10n.get('event-delete')
onTriggered: deleteEventRequested(event.isLocalEcho ? event.txnId : event.eventId, event.isLocalEcho)
enabled: event && !event.redacted
},
Kirigami.Action {
objectName: 'reactMenuItem'
text: l10n.get('event-react-action')
onTriggered: reactionPopupComp.createObject(applicationWindow().overlay).open()
enabled: event && !event.redacted
},
Kirigami.Action {
objectName: 'pinUnpinMenuItem'
enabled: !event.isLocalEcho
text: eventPinned ? l10n.get('event-unpin-action') : l10n.get('event-pin-action')
onTriggered: eventPinned ? unpinEventRequested(event.eventId) : pinEventRequested(event.eventId)
},
MenuSeparator {},
Kirigami.Action {
text: l10n.get('event-view-source')
onTriggered: eventSourcePopupComp.createObject(applicationWindow().overlay).open()
},
Kirigami.Action {
objectName: 'viewHistoryMenuItem'
text: l10n.get('event-view-history')
enabled: event && !!event.isEdited
onTriggered: viewEventHistoryRequested(event.eventId)
}
]
contentData: Array.from(staticItems).concat(upper.menuContent)
onClosed: destroy()
}
}
function shouldPopupMenu() {
return !compactMode
}
function maybePopupMenu() {
if (shouldPopupMenu()) {
menuComp.createObject(parent).popup(parent)
}
}
contentItem: RowLayout {
id: bubbleRootLayout
property var replyToOrAnnotatedEventId: currentEvent.replyingToEventId || (currentEvent.relationType === 'm.annotation' ? currentEvent.relatedEventId : '')
ColumnLayout {
Layout.fillWidth: true
MouseArea {
visible: bubbleRootLayout.replyToOrAnnotatedEventId && !compactMode
Layout.fillWidth: true
Layout.margins: 0
Layout.preferredHeight: inReplyToLayout.implicitHeight
Layout.maximumHeight: upper.replyMaxHeight
clip: true
onClicked: eventListView.goToEvent(bubbleRootLayout.replyToOrAnnotatedEventId)
RowLayout {
id: inReplyToLayout
anchors {
top: parent.top
left: parent.left
right: parent.right
}
Rectangle {
Layout.fillHeight: true
Layout.preferredWidth: Kirigami.Units.smallSpacing
color: Kirigami.Theme.activeTextColor
clip: true
}
Loader {
id: inReplyToLoader
clip: true
- source: Qt.resolvedUrl('EventViewCompact.qml')
- active: bubbleRootLayout.replyToOrAnnotatedEventId && !compactMode
+ source: (bubbleRootLayout.replyToOrAnnotatedEventId && !compactMode) ? Qt.resolvedUrl('EventViewCompact.qml') : undefined
asynchronous: true
Layout.fillWidth: true
// BlockQuote is usable in Qt 6.9 and later
Accessible.role: 'BlockQuote' in Accessible ? Accessible.BlockQuote : Accessible.Grouping
property var event: room.messageById(bubbleRootLayout.replyToOrAnnotatedEventId)
property var props: ({
event,
sender: room.member(event.sender || matrixSession.userId),
stateKeyUser: event.stateKey ? room.member(event.stateKey) : {},
isGapped: timeline.gaps.hasOwnProperty(event.eventId),
})
}
}
}
ColumnLayout {
id: children
data: upper.children
}
Kazv.EventReactions {
objectName: 'eventReactions'
visible: !compactMode
Layout.fillWidth: true
reactions: upper.eventReactions
}
}
property var popupAction: Kirigami.Action {
id: popupAction
text: l10n.get('event-popup-action')
icon.name: 'view-more-horizontal-symbolic'
onTriggered: maybePopupMenu()
}
RowLayout {
Layout.alignment: Qt.AlignBottom
Kirigami.Icon {
source: /* iconName: */ 'emblem-encrypted-locked'
visible: !compactMode && event && event.encrypted
Layout.preferredHeight: inlineBadgeSize
Layout.preferredWidth: inlineBadgeSize
Accessible.role: Accessible.StaticText
Accessible.name: l10n.get('event-encrypted')
ToolTip.text: l10n.get('event-encrypted')
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.timeout: Helpers.toolTipTimeout
ToolTip.visible: hoverHandlerEncIcon.hovered
HoverHandler { id: hoverHandlerEncIcon }
}
ToolButton {
objectName: 'localEchoIndicator'
visible: !!event.isLocalEcho
hoverEnabled: true
enabled: event.isFailed
icon.name: event.isSending ? 'state-sync' : 'state-warning'
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Accessible.role: event.isSending ? Accessible.StaticText : Accessible.Button
Accessible.name: event.isSending ? l10n.get('event-sending') : l10n.get('event-resend')
ToolTip.text: event.isSending ? l10n.get('event-sending') : l10n.get('event-send-failed')
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.timeout: Helpers.toolTipTimeout
ToolTip.visible: hovered
onClicked: {
room.resendMessage(event.txnId);
}
}
Label {
objectName: 'timeIndicator'
visible: shouldDisplayTime
text: event.formattedTime
ToolTip.text: event.formattedDateTime
ToolTip.delay: Kirigami.Units.toolTipDelay
ToolTip.timeout: Helpers.toolTipTimeout
ToolTip.visible: hoverHandlerTime.hovered
HoverHandler { id: hoverHandlerTime }
font: Kirigami.Theme.smallFont
}
Label {
objectName: 'editedIndicator'
visible: !compactMode && event.isEdited
text: l10n.get('event-edited-indicator')
font: Kirigami.Theme.smallFont
}
Kazv.EventReadIndicator {
objectName: 'eventReadIndicator'
shouldShow: !compactMode && !event.isLocalEcho
model: event.readers()
}
ToolButton {
id: moreButton
objectName: 'moreButton'
visible: !compactMode
action: popupAction
display: AbstractButton.IconOnly
}
}
}
}
diff --git a/src/contents/ui/EventView.qml b/src/contents/ui/EventView.qml
index 8c1f917..616d76d 100644
--- a/src/contents/ui/EventView.qml
+++ b/src/contents/ui/EventView.qml
@@ -1,288 +1,301 @@
/*
* 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 isSelected: false
property var displayTime
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 isGapped: false
property var compactMode: false
property var calculatedHeight: (loader.item ? loader.item.implicitHeight : 0)
height: calculatedHeight
implicitHeight: calculatedHeight
Layout.minimumHeight: calculatedHeight
Layout.preferredHeight: calculatedHeight
signal downloadStarted
Component {
id: textEV
Types.Text {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: emoteEV
Types.Emote {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: noticeEV
Types.Notice {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: stateEV
Types.State {
event: eventView.event
sender: eventView.sender
stateKeyUser: eventView.stateKeyUser
isSelected: eventView.isSelected
}
}
Component {
id: imageEV
Types.Image {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: fileEV
Types.File {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: videoEV
Types.Video {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: audioEV
Types.Audio {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: unknownEV
Types.Fallback {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: redactedEV
Types.Redacted {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: reactionEV
Types.Reaction {
event: eventView.event
sender: eventView.sender
isSelected: eventView.isSelected
}
}
Component {
id: ignoreEV
Item {
}
}
Loader {
sourceComponent: getSource(messageType)
id: loader
objectName: 'loader'
anchors.left: eventView.left
anchors.right: eventView.right
anchors.top: eventView.top
}
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';
}
// ignore edits of other events
if (e.relationType === 'm.replace') {
return 'ignore';
}
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':
// Only display a reaction if it is a failed local echo
// (otherwise it will be stuck forever and we have no way to delete it)
return e.isFailed ? 'reaction' : 'ignore';
default:
return 'unknown';
}
}
function paginateBack() {
paginateBackRequested(event.eventId);
}
function backfill() {
backfillRequested(event.eventId);
}
function reactWith(reactionText) {
room.sendReaction(reactionText, event.eventId);
}
onIsGappedChanged: maybePaginateBack()
- onEventChanged: maybePaginateBack()
- Component.onCompleted: maybePaginateBack()
+ onEventChanged: {
+ maybePaginateBack();
+ ensureEvent();
+ }
+ Component.onCompleted: {
+ maybePaginateBack();
+ ensureEvent();
+ }
Connections {
target: eventView.event
function onEventIdChanged() {
eventView.maybePaginateBack();
+ ensureEvent();
}
}
function maybePaginateBack() {
if (isGapped) {
paginateBack();
} else if (ListView.view && index === ListView.view.timeline.count - 1) {
backfill();
}
}
+
+ function ensureEvent() {
+ if (!event.isAvailable) {
+ loadEventRequested(event.eventId);
+ }
+ }
}
diff --git a/src/contents/ui/RoomPage.qml b/src/contents/ui/RoomPage.qml
index dbac79e..d9c8248 100644
--- a/src/contents/ui/RoomPage.qml
+++ b/src/contents/ui/RoomPage.qml
@@ -1,339 +1,340 @@
/*
* 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 QtQuick.Window 2.15
import Qt.labs.platform as Platform
import org.kde.kirigami 2.13 as Kirigami
import moe.kazv.mxc.kazv 0.0 as MK
import '.' as Kazv
import 'matrix-helpers.js' as Helpers
Kazv.ScrollablePageAdapter {
id: roomPage
property string roomId: ''
property var room: sdkVars.roomList.room(roomId)
property var roomNameProvider: Kazv.RoomNameProvider {
room: roomPage.room
}
property var roomTimeline: room.timeline()
property var pinnedEvents: room.pinnedEventsTimeline()
property var lastReceiptableEventId: getLastReceiptableEventId(roomTimeline, roomTimeline.count)
property var paginationRequests: ({})
property var refreshRoomStateRequest: null
property var fileDialog: Kazv.FileDialogAdapter {
id: fileDialog
property var eventContent
property var eventId
property var jobManager: kazvIOManager
property var session: matrixSession
property var fileHandler: Kazv.FileHandler {
id: fileHandler
autoCache: false
eventContent: fileDialog.eventContent
kazvIOManager: fileDialog.jobManager
matrixSession: fileDialog.session
}
function saveAs(eventContent, eventId) {
fileDialog.eventContent = eventContent;
fileDialog.eventId = eventId;
fileDialog.open();
}
folder: Platform.StandardPaths.writableLocation(Platform.StandardPaths.DownloadLocation)
onAccepted: {
fileDialog.fileHandler.downloadFile(fileUrl);
// A newly created eventView will automatically look for its KazvIOJob.
// Here we just need to find those eventViews that have been created and notify them that the download has started.
for (const eventView of roomTimelineView.contentItem.children) {
if (eventView.event.eventId === fileDialog.eventId) {
eventView.downloadStarted();
}
}
}
Component.onCompleted: {
if (MK.KazvUtil.kfQtMajorVersion === 5) {
fileDialog.selectExisting = false;
} else {
fileDialog.fileMode = Kazv.FileDialogAdapter.SaveFile;
}
}
}
signal mentionUserRequested(string userId)
signal replaceDraftRequested(string newDraft)
signal paginateBackRequested(string eventId)
signal backfillRequested(string eventId)
signal viewEventHistoryRequested(string eventId)
title: roomNameProvider.name
padding: 0
property var isInvite: room.membership === MK.MatrixRoom.Invite
property var isJoin: room.membership === MK.MatrixRoom.Join
property var eventHistoryPopupComp: Component {
Kazv.SelfDestroyableOverlaySheet {
id: historyPopup
property var eventId
property var event: room.messageById(eventId)
shouldSelfDestroy: true
title: l10n.get('event-history-popup-title')
Kazv.EventHistoryView {
Layout.preferredWidth: Math.min(Kirigami.Units.gridUnit * 40, Window.width)
history: historyPopup.event.history()
}
}
}
actions: [
Kirigami.Action {
id: acceptInviteAction
icon.name: 'checkmark'
text: l10n.get('room-invite-accept-action')
visible: isInvite
onTriggered: joinRoomHandler.call()
},
Kirigami.Action {
id: rejectInviteAction
icon.name: 'im-ban-kick-user'
text: l10n.get('room-invite-reject-action')
visible: isInvite
onTriggered: leaveRoomHandler.call()
},
Kirigami.Action {
id: pinnedEventsAction
objectName: 'pinnedEventsAction'
icon.name: 'pin'
visible: roomPage.pinnedEvents.count
enabled: roomPage.pinnedEvents.count
text: l10n.get('room-pinned-events-action', { count: roomPage.pinnedEvents.count })
onTriggered: activateRoomPinnedEventsPage(room)
},
Kirigami.Action {
id: scrollToLatestAction
objectName: 'scrollToLatestAction'
icon.name: "go-down"
enabled: !roomTimelineView.atYEnd
text: l10n.get("room-timeline-scroll-to-latest-action")
onTriggered: {
roomTimelineView.positionViewAtBeginning();
}
},
Kirigami.Action {
id: roomSettingsAction
icon.name: 'settings-configure'
text: l10n.get('room-settings-action')
onTriggered: activateRoomSettingsPage(room)
}
]
property var inviteOverlay: Kirigami.OverlaySheet {
id: inviteOverlay
title: l10n.get('room-invite-popup-title')
parent: roomPage.overlay
property var self: room.member(matrixSession.userId)
property var inviteEvent: self.toEvent()
property var inviter: room.member(inviteEvent.sender)
property var inviterName: Helpers.userNameWithId(inviter, l10n)
ColumnLayout {
Label {
text: {
inviteOverlay.inviterName
? l10n.get('room-invite-popup-text-with-inviter', { inviterName: inviteOverlay.inviterName })
: l10n.get('room-invite-popup-text')
}
}
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
Button {
action: acceptInviteAction
}
Button {
action: rejectInviteAction
}
}
}
}
Component.onCompleted: {
if (isInvite) {
inviteOverlay.open();
}
}
RoomTimelineView {
id: roomTimelineView
objectName: 'roomTimelineView'
+ room: roomPage.room
timeline: roomPage.roomTimeline
}
onRoomChanged: {
kazvIOManager.deleteModelIfEmpty(roomId)
uploadList.model = kazvIOManager.getUploadJobs(roomId)
}
footer: Item {
height: childrenRect.height
width: parent.width
ListView {
id: uploadList
width: parent.width
height: contentHeight
model: kazvIOManager.getUploadJobs(roomId)
delegate: Kazv.KazvIOMenu {
width: parent.width
jobId: roomId
isUpload: true
}
}
Kazv.TypingIndicator {
id: typingIndicator
typingUsers: roomPage.room.typingUsers()
width: parent.width
anchors.top: uploadList.bottom
}
Kazv.SendMessageBox {
id: sendMessageBox
objectName: 'sendMessageBox'
width: parent.width
room: roomPage.room
anchors.top: typingIndicator.bottom
enabled: isJoin
}
}
function setDraftRelation(relType, eventId, newDraft) {
sendMessageBox.setDraftRelation(relType, eventId, newDraft);
}
property var joinRoomHandler: Kazv.AsyncHandler {
trigger: () => matrixSession.joinRoom(roomId, [])
onResolved: {
if (success) {
showPassiveNotification(l10n.get('join-room-page-success-prompt', { room: roomId }));
inviteOverlay.close();
} else {
showPassiveNotification(l10n.get('join-room-page-failed-prompt', { room: roomId, errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
property var leaveRoomHandler: Kazv.AsyncHandler {
trigger: () => room.leaveRoom()
onResolved: {
if (success) {
showPassiveNotification(l10n.get('leave-room-success-prompt', { room: roomId }));
inviteOverlay.close();
} else {
showPassiveNotification(l10n.get('leave-room-failed-prompt', { room: roomId, errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
onMentionUserRequested: (userId) => {
sendMessageBox.mentionUser(userId);
sendMessageBox.focusInput();
}
onReplaceDraftRequested: (newDraft) => {
sendMessageBox.replaceDraft(newDraft);
}
function getLastReceiptableEventId(timeline, timelineCount) {
for (let i = 0; i < timelineCount; ++i) {
const event = timeline.at(i);
if (!event.isLocalEcho && event.sender !== matrixSession.userId) {
return event.eventId;
}
}
}
onLastReceiptableEventIdChanged: maybePostReadReceipt()
Window.onActiveChanged: maybePostReadReceipt()
onVisibleChanged: maybePostReadReceipt()
function maybePostReadReceipt() {
if (!Window.active || !roomPage.visible) {
return;
}
const eventId = roomPage.lastReceiptableEventId;
if (!eventId) {
return;
}
const event = roomPage.room.messageById(eventId);
const readByMe = Helpers.isEventReadBy(event, matrixSession.userId);
if (!readByMe) {
roomPage.room.postReadReceipt(eventId);
}
}
onPaginateBackRequested: (eventId) => {
if (!paginationRequests[eventId]) {
console.debug('paginating back from', eventId);
paginationRequests[eventId] = room.paginateBackFrom(eventId);
paginationRequests[eventId].resolved.connect(() => {
console.debug('finished paginating back from', eventId);
delete paginationRequests[eventId];
});
} else {
console.debug('pagination request of', eventId, 'is already under way');
}
}
onBackfillRequested: (eventId) => {
if (!paginationRequests[eventId]) {
paginationRequests[eventId] = matrixSession.backfillRoomFromEvent(roomId, eventId);
paginationRequests[eventId].resolved.connect((isSuccess, data) => {
console.debug(
'finished backfill from', eventId,
'success=', isSuccess, JSON.stringify(data));
delete paginationRequests[eventId];
});
}
}
onViewEventHistoryRequested: (eventId) => {
eventHistoryPopupComp.createObject(applicationWindow().overlay, { eventId }).open();
}
Timer {
id: goToEventTimer
property var eventId
onTriggered: {
console.log('goto event');
roomTimelineView.goToEvent(eventId);
}
}
function delayGoToEvent(eventId, time) {
goToEventTimer.interval = time;
goToEventTimer.eventId = eventId
goToEventTimer.start();
}
}
diff --git a/src/contents/ui/RoomPinnedEventsPage.qml b/src/contents/ui/RoomPinnedEventsPage.qml
index 9ac9a8e..3b1c6de 100644
--- a/src/contents/ui/RoomPinnedEventsPage.qml
+++ b/src/contents/ui/RoomPinnedEventsPage.qml
@@ -1,27 +1,28 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import '.' as Kazv
Kazv.ClosableScrollablePage {
id: roomPinnedEventsPage
property var room
property var roomNameProvider: Kazv.RoomNameProvider {
room: roomPinnedEventsPage.room
}
property var pinnedEvents: room.pinnedEventsTimeline()
title: l10n.get('room-pinned-events-page-title', { room: roomNameProvider.name })
RoomTimelineView {
+ room: roomPinnedEventsPage.room
timeline: roomPinnedEventsPage.pinnedEvents
}
}
diff --git a/src/contents/ui/RoomTimelineView.qml b/src/contents/ui/RoomTimelineView.qml
index 8e51a07..16a10fb 100644
--- a/src/contents/ui/RoomTimelineView.qml
+++ b/src/contents/ui/RoomTimelineView.qml
@@ -1,186 +1,203 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import org.kde.kirigami as Kirigami
import '.' as Kazv
ListView {
id: roomTimelineView
objectName: 'roomTimelineView'
Accessible.role: Accessible.List
- property var timeline
+ required property var room
+ required property var timeline
+ property var loadEventRequests: ({})
property string selectedEventId
signal pinEventRequested(string eventId)
signal unpinEventRequested(string eventId)
signal deleteEventRequested(eventIdOrTxnId: string, isLocalEcho: bool)
signal deleteEventRequestedNoConfirm(eventIdOrTxnId: string, isLocalEcho: bool)
+ signal loadEventRequested(string eventId)
property var pinEvent: Kazv.AsyncHandler {
property var eventIds
trigger: () => room.pinEvents(eventIds)
onResolved: (success, data) => {
if (success) {
showPassiveNotification(l10n.get('event-pin-success-prompt'));
} else {
showPassiveNotification(l10n.get('event-pin-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
property var unpinEvent: Kazv.AsyncHandler {
property var eventIds
trigger: () => room.unpinEvents(eventIds)
onResolved: (success, data) => {
if (success) {
showPassiveNotification(l10n.get('event-unpin-success-prompt'));
} else {
showPassiveNotification(l10n.get('event-unpin-failed-prompt', { errorCode: data.errorCode, errorMsg: data.error }));
}
}
}
property var deleteEvent: Kazv.AsyncHandler {
property var eventId
property var reason
trigger: () => room.redactEvent(eventId, reason)
onResolved: (success, data) => {
if (!success) {
showPassiveNotification(l10n.get('event-delete-failed', { errorMsg: data.error, errorCode: data.errorCode }));
}
}
}
property var deleteLocalEcho: Kazv.AsyncHandler {
property var txnId
trigger: () => room.removeLocalEcho(txnId)
onResolved: (success, data) => {
if (!success) {
showPassiveNotification(l10n.get('event-delete-failed', { errorMsg: data.error, errorCode: data.errorCode }));
}
}
}
onPinEventRequested: (eventId) => {
pinEventPopupComp.createObject(Overlay.overlay, { eventId }).open()
}
onUnpinEventRequested: (eventId) => {
unpinEventPopupComp.createObject(Overlay.overlay, { eventId }).open()
}
onDeleteEventRequested: (eventIdOrTxnId, isLocalEcho) => {
confirmDeletionPopupComp.createObject(Overlay.overlay, { eventIdOrTxnId, isLocalEcho }).open()
}
onDeleteEventRequestedNoConfirm: (eventIdOrTxnId, isLocalEcho) => {
doDeleteEvent(eventIdOrTxnId, isLocalEcho);
}
+ onLoadEventRequested: (eventId) => {
+ if (!(eventId in loadEventRequests)) {
+ loadEventRequests[eventId] = matrixSession.loadEvent(room.roomId, eventId);
+ loadEventRequests[eventId].resolved.connect((isSuccess) => {
+ if (isSuccess) {
+ delete loadEventRequests[eventId];
+ } else {
+ loadEventRequests[eventId] = undefined;
+ }
+ });
+ }
+ }
+
function doDeleteEvent(eventIdOrTxnId, isLocalEcho) {
if (isLocalEcho) {
deleteLocalEcho.txnId = eventIdOrTxnId;
deleteLocalEcho.call();
} else {
deleteEvent.eventId = eventIdOrTxnId;
deleteEvent.call();
}
}
property var pinEventPopupComp: Component {
Kazv.ConfirmationOverlay {
objectName: 'pinEventPopup'
property var eventId
shouldSelfDestroy: true
title: l10n.get('event-pin-confirmation-title')
message: l10n.get('event-pin-confirmation')
confirmActionText: l10n.get('event-pin-confirm-action')
cancelActionText: l10n.get('event-pin-cancel-action')
onAccepted: {
pinEvent.eventIds = [eventId];
pinEvent.call();
}
EventViewWrapper {
Layout.fillWidth: true
event: room.messageById(eventId)
compactMode: true
}
}
}
property var unpinEventPopupComp: Component {
Kazv.ConfirmationOverlay {
objectName: 'unpinEventPopup'
property var eventId
shouldSelfDestroy: true
title: l10n.get('event-unpin-confirmation-title')
message: l10n.get('event-unpin-confirmation')
confirmActionText: l10n.get('event-unpin-confirm-action')
cancelActionText: l10n.get('event-unpin-cancel-action')
onAccepted: {
unpinEvent.eventIds = [eventId];
unpinEvent.call();
}
EventViewWrapper {
Layout.fillWidth: true
event: room.messageById(eventId)
compactMode: true
}
}
}
property var confirmDeletionPopupComp: Component {
Kazv.ConfirmationOverlay {
objectName: 'confirmDeletionPopup'
property string eventIdOrTxnId
property bool isLocalEcho
shouldSelfDestroy: true
title: l10n.get('confirm-deletion-popup-title')
message: l10n.get('confirm-deletion-popup-message')
confirmActionText: l10n.get('confirm-deletion-popup-confirm-action')
cancelActionText: l10n.get('confirm-deletion-popup-cancel-action')
onAccepted: doDeleteEvent(eventIdOrTxnId, isLocalEcho)
EventViewWrapper {
id: eventView
Layout.fillWidth: true
event: isLocalEcho ? room.localEchoById(eventIdOrTxnId) : room.messageById(eventIdOrTxnId)
compactMode: true
}
}
}
spacing: 0
model: timeline
currentIndex: -1
verticalLayoutDirection: ListView.BottomToTop
delegate: EventViewWrapper {
property var eventListView: ListView.view
+ objectName: `event-${event.eventId || event.txnId}`
prevEvent: index < timeline.count - 1 ? timeline.at(index + 1) : undefined
event: timeline.at(index)
width: ListView.view.width
isSelected: roomTimelineView.selectedEventId && event.eventId === roomTimelineView.selectedEventId
Accessible.role: Accessible.ListItem
}
function goToEvent(eventId) {
selectedEventId = eventId;
const index = timeline.indexOfEvent(eventId);
positionViewAtIndex(index, ListView.Center);
}
}
diff --git a/src/contents/ui/SelfDestroyableOverlaySheet.qml b/src/contents/ui/SelfDestroyableOverlaySheet.qml
index f884dab..cfdc5dd 100644
--- a/src/contents/ui/SelfDestroyableOverlaySheet.qml
+++ b/src/contents/ui/SelfDestroyableOverlaySheet.qml
@@ -1,35 +1,26 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2024 tusooa <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
Kirigami.OverlaySheet {
id: selfDestroyableOverlaySheet
property var shouldSelfDestroy: false
property var selfDestroyConn: Connections {
target: selfDestroyableOverlaySheet
- // for KF5
- function onSheetOpenChanged() {
- if (!selfDestroyableOverlaySheet.sheetOpen
- && selfDestroyableOverlaySheet.shouldSelfDestroy) {
- selfDestroyableOverlaySheet.destroy();
- }
- }
-
- // for KF6
function onClosed() {
if (selfDestroyableOverlaySheet.shouldSelfDestroy) {
selfDestroyableOverlaySheet.destroy();
}
}
}
}
diff --git a/src/matrix-event.cpp b/src/matrix-event.cpp
index e93b556..55c6bae 100644
--- a/src/matrix-event.cpp
+++ b/src/matrix-event.cpp
@@ -1,225 +1,236 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include "matrix-event.hpp"
#include "matrix-event-reader-list-model.hpp"
#include "matrix-event-reaction-list-model.hpp"
#include "matrix-event-list.hpp"
#include "helper.hpp"
#include "kazv-log.hpp"
using namespace Kazv;
-static std::optional<LocalEchoDesc> getLocalEcho(std::variant<Event, LocalEchoDesc> event)
+static std::optional<LocalEchoDesc> getLocalEcho(std::variant<Event, LocalEchoDesc, std::string> event)
{
- if (std::holds_alternative<LocalEchoDesc>(event)) {
- return std::get<LocalEchoDesc>(event);
- } else {
- return std::nullopt;
- }
+ using R = std::optional<LocalEchoDesc>;
+ return lager::match(std::move(event))(
+ [](const LocalEchoDesc &l) -> R { return std::optional(l); },
+ [](auto) -> R { return std::nullopt; }
+ );
}
-static Event getEvent(std::variant<Event, LocalEchoDesc> event)
+static Event getEvent(std::variant<Event, LocalEchoDesc, std::string> event)
{
- if (std::holds_alternative<LocalEchoDesc>(event)) {
- return std::get<LocalEchoDesc>(event).event;
- } else {
- return std::get<Event>(event);
- }
+ return lager::match(std::move(event))(
+ [](const LocalEchoDesc &l) -> Event { return l.event; },
+ [](const Event &e) -> Event { return e; },
+ [](const std::string &id) -> Event {
+ return Event(json{{"event_id", id}});
+ }
+ );
+}
+
+static bool getAvailable(std::variant<Event, LocalEchoDesc, std::string> event)
+{
+ return lager::match(std::move(event))(
+ [](const std::string &) { return false; },
+ [](auto) { return true; }
+ );
}
-MatrixEvent::MatrixEvent(lager::reader<std::variant<Event, LocalEchoDesc>> event, std::optional<Room> room, QObject *parent)
+MatrixEvent::MatrixEvent(lager::reader<std::variant<Event, LocalEchoDesc, std::string>> event, std::optional<Room> room, QObject *parent)
: QObject(parent)
, m_localEcho(event.map(getLocalEcho))
, m_event(event.map(getEvent))
, m_room(room)
, m_eventIdStd(m_event.map(&Event::id))
, m_senderStd(m_event.map(&Event::sender))
, m_originalContent(m_event.map([](Event e) { return e.content().get().get<QJsonObject>(); }))
, m_edits(m_room.has_value() ? lager::reader<EventList>(lager::with(
m_event,
m_room->relatedEvents(m_eventIdStd, "m.replace")
).map([](const Event &origEvent, const EventList &edits) -> EventList {
// https://spec.matrix.org/v1.10/client-server-api/#event-replacements
if (!(
// (3) no state key
!origEvent.isState()
// (4) original event is not an edit
&& origEvent.relationship().first != "m.replace"
)) {
return EventList{};
}
auto origType = origEvent.type();
auto origSender = origEvent.sender();
return intoImmer(
EventList{},
zug::filter([&origType, &origSender](const Event &ev) {
return
// (0) same room id requirement is implicit.
// (1) same sender
origSender == ev.sender()
// (2) same type
&& origType == ev.type()
// (3) no state key
&& !ev.isState()
// (4) original event is not an edit is checked above
// (5) replaced event content has `m.new_content`
&& ev.content().get().contains("m.new_content");
}),
edits
);
})) : lager::make_constant(EventList{}))
, LAGER_QT(eventId)(m_eventIdStd.xform(strToQt))
, LAGER_QT(sender)(m_senderStd.xform(strToQt))
, LAGER_QT(type)(m_event.xform(zug::map([](Event e) { return e.type(); }) | strToQt))
, LAGER_QT(stateKey)(m_event.xform(zug::map([](Event e) { return e.stateKey(); }) | strToQt))
, LAGER_QT(content)(lager::with(m_originalContent, m_edits).map([](const QJsonObject &origContent, const EventList &edits) {
if (edits.empty()) {
return origContent;
} else {
return edits.at(edits.size() - 1).content().get().at("m.new_content").get<QJsonObject>();
}
}))
, LAGER_QT(encrypted)(m_event.xform(zug::map([](Event e) { return e.encrypted(); })))
, LAGER_QT(decrypted)(m_event.map([](Event e) { return e.decrypted(); }))
, LAGER_QT(isState)(m_event.map([](Event e) { return e.isState(); }))
, LAGER_QT(unsignedData)(m_event.map([](Event e) {
auto j = e.raw();
if (j.get().contains("unsigned")) {
return j.get()["unsigned"].template get<QJsonObject>();
} else {
return QJsonObject();
}
}))
, LAGER_QT(isLocalEcho)(m_localEcho.map([](const auto &maybe) { return maybe.has_value(); }))
, LAGER_QT(isSending)(m_localEcho.map([](const auto &maybe) {
return maybe.has_value() && maybe.value().status == LocalEchoDesc::Sending;
}))
, LAGER_QT(isFailed)(m_localEcho.map([](const auto &maybe) {
return maybe.has_value() && maybe.value().status == LocalEchoDesc::Failed;
}))
, LAGER_QT(txnId)(m_localEcho.map([](const auto &maybe) {
return maybe.has_value() ? QString::fromStdString(maybe.value().txnId) : QStringLiteral("");
}))
, LAGER_QT(redacted)(m_event.map([](const auto &e) {
return e.redacted();
}))
, LAGER_QT(originalSource)(m_event.map([](Event e) {
return e.originalJson().get().template get<QJsonObject>();
}))
, LAGER_QT(decryptedSource)(m_event.map([](Event e) {
return e.encrypted() ? e.decryptedJson().get().template get<QJsonObject>() : QJsonObject();
}))
, LAGER_QT(replyingToEventId)(m_event.map([](Event e) {
return e.replyingTo();
}).xform(strToQt))
, LAGER_QT(relationType)(m_event.map([](Event e) {
return e.relationship().first;
}).xform(strToQt))
, LAGER_QT(relatedEventId)(m_event.map([](Event e) {
return e.relationship().second;
}).xform(strToQt))
, LAGER_QT(formattedTime)(m_event
.map(&Event::originServerTs)
.map([](Timestamp ts) {
auto locale = QLocale::system();
return locale.toString(
QDateTime::fromMSecsSinceEpoch(ts).time(),
QLocale::ShortFormat
);
}))
, LAGER_QT(formattedDateTime)(m_event
.map(&Event::originServerTs)
.map([](Timestamp ts) {
auto locale = QLocale::system();
return locale.toString(
QDateTime::fromMSecsSinceEpoch(ts),
QLocale::ShortFormat
);
}))
, LAGER_QT(isEdited)(m_edits.map([](const auto &edits) { return !edits.empty(); }))
+ , LAGER_QT(isAvailable)(event.map(getAvailable))
{
}
MatrixEvent::MatrixEvent(lager::reader<Event> event, std::optional<Kazv::Room> room, QObject *parent)
- : MatrixEvent(event.map([](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc> { return e; }), room, parent)
+ : MatrixEvent(event.map([](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> { return e; }), room, parent)
{
}
MatrixEvent::~MatrixEvent() = default;
MatrixEventReaderListModel *MatrixEvent::readers() const
{
if (!m_room.has_value()) {
qCDebug(kazvLog) << "readers(): no room!";
return new MatrixEventReaderListModel(lager::make_constant(immer::flex_vector<std::tuple<Kazv::Event, Kazv::Timestamp>>{}));
}
auto eventReaders = lager::with(
m_room.value().eventReaders(m_event.map(&Event::id)),
m_room.value().stateEvents()
).map([](const auto &eventReaders, const auto &states) {
return intoImmer(
immer::flex_vector<std::tuple<Kazv::Event, Kazv::Timestamp>>{},
zug::map([states](const auto &item) -> std::tuple<Kazv::Event, Kazv::Timestamp> {
auto [userId, ts] = item;
qCDebug(kazvLog) << "readers(): read by" << QString::fromStdString(userId);
auto memberEvent = states.count({"m.room.member", userId})
? states[{"m.room.member", userId}]
: Event{json{
{"content", json::object()},
{"state_key", userId},
{"type", "m.room.member"},
}};
return {memberEvent, ts};
}),
eventReaders
);
}).make();
return new MatrixEventReaderListModel(eventReaders);
}
Kazv::Event MatrixEvent::underlyingEvent() const
{
return m_event.get();
}
MatrixEventList *MatrixEvent::history() const
{
return new MatrixEventList(lager::with(m_event, m_edits)
.map([](const auto &event, const auto &edits) {
return intoImmer(
EventList{event},
zug::map([event](const auto &e) {
auto j = event.originalJson().get();
auto ret = j;
ret["type"] = e.type();
ret["content"] = e.content().get().at("m.new_content");
ret["event_id"] = e.id();
ret["origin_server_ts"] = e.originServerTs();
if (j.contains("/content/m.relates_to"_json_pointer)) {
ret["content"]["m.relates_to"] = j["content"]["m.relates_to"];
}
return Event(ret);
}),
edits
);
}));
}
MatrixEventReactionListModel *MatrixEvent::reactions() const
{
if (!m_room) {
return nullptr;
}
return new MatrixEventReactionListModel(
m_room.value(),
m_eventIdStd
);
}
diff --git a/src/matrix-event.hpp b/src/matrix-event.hpp
index 2b928d2..34aa090 100644
--- a/src/matrix-event.hpp
+++ b/src/matrix-event.hpp
@@ -1,76 +1,77 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <kazv-defs.hpp>
#include <QObject>
#include <QQmlEngine>
#include <lager/extra/qt.hpp>
#include <client/room/room.hpp>
#include "qt-json.hpp"
Q_MOC_INCLUDE("matrix-event-list.hpp")
Q_MOC_INCLUDE("matrix-event-reader-list-model.hpp")
Q_MOC_INCLUDE("matrix-event-reaction-list-model.hpp")
class MatrixEventReaderListModel;
class MatrixEventList;
class MatrixEventReactionListModel;
class MatrixEvent : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
lager::reader<std::optional<Kazv::LocalEchoDesc>> m_localEcho;
lager::reader<Kazv::Event> m_event;
std::optional<Kazv::Room> m_room;
lager::reader<std::string> m_eventIdStd;
lager::reader<std::string> m_senderStd;
/// the unedited content of this event
lager::reader<QJsonObject> m_originalContent;
lager::reader<Kazv::EventList> m_edits;
public:
- explicit MatrixEvent(lager::reader<std::variant<Kazv::Event, Kazv::LocalEchoDesc>> event, std::optional<Kazv::Room> room = std::nullopt, QObject *parent = 0);
+ explicit MatrixEvent(lager::reader<std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string>> event, std::optional<Kazv::Room> room = std::nullopt, QObject *parent = 0);
explicit MatrixEvent(lager::reader<Kazv::Event> event, std::optional<Kazv::Room> room = std::nullopt, QObject *parent = 0);
~MatrixEvent() override;
LAGER_QT_READER(QString, eventId);
LAGER_QT_READER(QString, sender);
LAGER_QT_READER(QString, type);
LAGER_QT_READER(QString, stateKey);
LAGER_QT_READER(QJsonObject, content);
LAGER_QT_READER(bool, encrypted);
LAGER_QT_READER(bool, decrypted);
LAGER_QT_READER(bool, isState);
LAGER_QT_READER(QJsonObject, unsignedData);
LAGER_QT_READER(bool, isLocalEcho);
LAGER_QT_READER(bool, isSending);
LAGER_QT_READER(bool, isFailed);
LAGER_QT_READER(QString, txnId);
LAGER_QT_READER(bool, redacted);
LAGER_QT_READER(QJsonObject, originalSource);
LAGER_QT_READER(QJsonObject, decryptedSource);
LAGER_QT_READER(QString, replyingToEventId);
LAGER_QT_READER(QString, relationType);
LAGER_QT_READER(QString, relatedEventId);
LAGER_QT_READER(QString, formattedTime);
LAGER_QT_READER(QString, formattedDateTime);
LAGER_QT_READER(bool, isEdited);
+ LAGER_QT_READER(bool, isAvailable);
Q_INVOKABLE MatrixEventReaderListModel *readers() const;
Kazv::Event underlyingEvent() const;
Q_INVOKABLE MatrixEventList *history() const;
Q_INVOKABLE MatrixEventReactionListModel *reactions() const;
};
diff --git a/src/matrix-room-timeline.cpp b/src/matrix-room-timeline.cpp
index e7ff630..40f55ed 100644
--- a/src/matrix-room-timeline.cpp
+++ b/src/matrix-room-timeline.cpp
@@ -1,106 +1,106 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include <cmath>
#include <lager/lenses/optional.hpp>
#include <QTimer>
#include <QJsonValue>
#include <cursorutil.hpp>
#include "matrix-room-timeline.hpp"
#include "matrix-event.hpp"
#include "helper.hpp"
#include "kazv-log.hpp"
using namespace Kazv;
MatrixRoomTimeline::MatrixRoomTimeline(lager::reader<immer::flex_vector<std::string>> eventIds, lager::reader<immer::map<std::string, Kazv::Event>> messagesMap, lager::reader<immer::flex_vector<LocalEchoDesc>> localEchoes, lager::reader<immer::map<std::string, std::string>> timelineGaps, Kazv::Room room, QObject *parent)
: KazvAbstractListModel(parent)
, m_room(room)
, m_timelineEventIds(eventIds)
, m_messagesMap(messagesMap)
, m_localEchoes(localEchoes)
, m_timelineGaps(timelineGaps)
, LAGER_QT(gaps)(m_timelineGaps.map([](auto g) {
QJsonObject res;
for (const auto &[k, _v] : g) {
res.insert(QString::fromStdString(k), QJsonValue(true));
}
return res;
}))
{
initCountCursor(lager::with(
m_timelineEventIds.xform(containerSize),
m_localEchoes.xform(containerSize)
).map([](const auto &tlSize, const auto &localSize) { return tlSize + localSize; }), InitLater);
}
MatrixRoomTimeline::MatrixRoomTimeline(Kazv::Room room, QObject *parent)
: MatrixRoomTimeline(
room.timelineEventIds(),
room.messagesMap(),
room.localEchoes(),
room.timelineGaps(),
room,
parent
)
{
}
MatrixRoomTimeline::~MatrixRoomTimeline() = default;
MatrixEvent *MatrixRoomTimeline::at(int index) const
{
return new MatrixEvent(lager::with(m_timelineEventIds, m_messagesMap, m_localEchoes, LAGER_QT(count))
- .map([index](const auto &tlEventIds, const auto &messagesMap, const auto &localEchoes, const auto &count) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc> {
+ .map([index](const auto &tlEventIds, const auto &messagesMap, const auto &localEchoes, const auto &count) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> {
auto rIndex = count - index - 1;
if (static_cast<unsigned int>(rIndex) < tlEventIds.size()) {
return messagesMap[tlEventIds[rIndex]];
} else {
auto remainingIndex = rIndex - tlEventIds.size();
if (remainingIndex < localEchoes.size()) {
return localEchoes[remainingIndex];
} else {
return Event();
}
}
}), m_room);
}
int MatrixRoomTimeline::indexOfEvent(const QString &eventId) const
{
auto eventMap = m_messagesMap.get();
auto eventIdStd = eventId.toStdString();
if (eventMap.count(eventIdStd) == 0) {
return -1;
}
auto eventIds = m_timelineEventIds.get();
auto it = std::lower_bound(eventIds.begin(), eventIds.end(), eventIdStd,
[eventMap](const auto &idA, const auto &idB) {
auto a = std::make_pair(eventMap[idA].originServerTs(), idA);
auto b = std::make_pair(eventMap[idB].originServerTs(), idB);
return a < b;
}
);
if (it == eventIds.end()) {
return -1;
} else {
// suppose there are A events and B local echoes
// forward index:
// [0, 1, ..., A-1] [A, A+1, ..., A+B-1]
// reverse index:
// [A+B-1, A+B-2, ..., B] [B-1, B-2 , 0]
// forward = it.index()
// forward + reverse = A+B-1 = count() - 1
return count() - it.index() - 1;
}
}
diff --git a/src/matrix-room.cpp b/src/matrix-room.cpp
index aa46496..cc895c9 100644
--- a/src/matrix-room.cpp
+++ b/src/matrix-room.cpp
@@ -1,648 +1,656 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include <string>
#include <lager/setter.hpp>
#include <nlohmann/json.hpp>
#include <event.hpp>
#include <QFile>
#include <QMimeDatabase>
#include "kazv-log.hpp"
#include "matrix-room.hpp"
#include "matrix-room-timeline.hpp"
#include "matrix-room-pinned-events-timeline.hpp"
#include "matrix-room-member.hpp"
#include "matrix-room-member-list-model.hpp"
#include "matrix-room-state.hpp"
#include "matrix-promise.hpp"
#include "matrix-event.hpp"
#include "matrix-sticker-pack-list.hpp"
#include "kazv-markdown.hpp"
#include "qt-json.hpp"
#include "qfunctionutils.hpp"
#include "helper.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
using namespace std::string_literals;
static const int typingDebounceMs = 500;
static const int saveDraftDebounceMs = 5000;
static const int typingTimeoutMs = 1000;
MatrixRoom::MatrixRoom(Kazv::Room room, lager::reader<std::string> selfUserId, lager::reader<Kazv::Event> userGivenNicknameEvent, QObject *parent)
: QObject(parent)
, m_room(room)
+ , m_messages(room.messagesMap())
, m_userGivenNicknameEvent(userGivenNicknameEvent)
, m_selfUserId(selfUserId)
, m_memberNames(m_room.members())
, m_powerLevels(m_room.powerLevels())
, LAGER_QT(roomId)(m_room.roomId().xform(strToQt))
, LAGER_QT(name)(m_room.nameOpt()[lager::lenses::or_default].xform(strToQt))
, LAGER_QT(topic)(m_room.topic().xform(strToQt))
, LAGER_QT(heroNames)(m_room.heroDisplayNames().xform(strListToQt))
, LAGER_QT(heroEvents)(m_room.heroMemberEvents()
.map([](const auto &events) {
auto res = QList<QVariant>{};
res.reserve(events.size());
std::transform(events.begin(), events.end(),
std::back_inserter(res),
[](const auto &event) -> QVariant {
return event.originalJson().get().template get<QJsonObject>();
});
return QVariant(res);
}))
, LAGER_QT(avatarMxcUri)(m_room.avatarMxcUri().xform(strToQt))
, LAGER_QT(roomOrHeroAvatarMxcUri)(lager::with(m_room.heroMemberEvents(), m_room.avatarMxcUri())
.map([](const auto &heroes, const auto &roomAvatar) {
if (!roomAvatar.empty()) {
return roomAvatar;
}
if (heroes.size() == 1) {
return zug::reduce(roomMemberToAvatar(zug::last), std::string(), heroes);
}
return std::string();
})
.xform(strToQt))
, LAGER_QT(localDraft)(m_room.localDrafts().map(
[](immer::flex_vector<Kazv::LocalDraft> drafts) {
if (drafts.empty()) {
return std::string{""};
}
return drafts.front().text;
}).xform(strToQt))
, LAGER_QT(prevDraft)(m_room.localDrafts().map(
[](immer::flex_vector<Kazv::LocalDraft> drafts) {
if (drafts.size() <= 1) {
return std::string{""};
}
return drafts[1].text;
}).xform(strToQt))
, LAGER_QT(draftRelType)(m_room.localDrafts().map(
[](immer::flex_vector<Kazv::LocalDraft> drafts) {
if (drafts.empty()) {
return std::string{""};
}
return drafts.front().relType;
}).xform(strToQt))
, LAGER_QT(draftRelTo)(m_room.localDrafts().map(
[](immer::flex_vector<Kazv::LocalDraft> drafts) {
if (drafts.empty()) {
return std::string{""};
}
return drafts.front().relatedEventId;
}).xform(strToQt))
, LAGER_QT(encrypted)(m_room.encrypted())
, LAGER_QT(tombstoned)(m_room.stateOpt({"m.room.tombstone"s, ""s}).map(
[](auto c) { return c.has_value(); }))
, LAGER_QT(memberNames)(m_memberNames.xform(strListToQt))
, LAGER_QT(tagIds)(m_room.tags().map([](const auto &tagsMap) {
return zug::into(
QStringList(),
zug::map([](const auto pair) {
return QString::fromStdString(pair.first);
}),
tagsMap);
}))
, LAGER_QT(membership)(m_room.membership().map([](const auto &membership) { return static_cast<Membership>(membership); }))
, LAGER_QT(unreadNotificationCount)(m_room.unreadNotificationEventIds().map([](const auto &cont) -> int { return cont.size(); }))
, LAGER_QT(localReadMarker)(m_room.localReadMarker().xform(strToQt))
, m_setTypingThrottled(QFunctionUtils::Throttle([self=QPointer<MatrixRoom>(this)]() {
if (self) {
self->setTypingImpl();
}
}, typingDebounceMs))
, m_updateLocalDraftDebounced(QFunctionUtils::Debounce([self=QPointer<MatrixRoom>(this)]() {
if (self) {
self->updateLocalDraftTextNow();
}
}, saveDraftDebounceMs))
, m_internalLocalDraft(std::nullopt)
{
lager::watch(m_powerLevels, [this](auto &&) {
powerLevelsChanged();
});
}
MatrixRoom::~MatrixRoom() {
updateLocalDraftTextNow();
}
MatrixRoomTimeline *MatrixRoom::timeline() const
{
return new MatrixRoomTimeline(m_room);
}
MatrixRoomPinnedEventsTimeline *MatrixRoom::pinnedEventsTimeline() const
{
return new MatrixRoomPinnedEventsTimeline(m_room);
}
MatrixPromise *MatrixRoom::pinEvents(const QStringList &eventIds) const
{
return new MatrixPromise(m_room.pinEvents(intoImmer(
immer::flex_vector<std::string>(),
qStringToStd,
eventIds)));
}
MatrixPromise *MatrixRoom::unpinEvents(const QStringList &eventIds) const
{
return new MatrixPromise(m_room.unpinEvents(intoImmer(
immer::flex_vector<std::string>(),
qStringToStd,
eventIds)));
}
static void maybeAddRelations(nlohmann::json &msg, const QString &relType, const QString &relatedTo)
{
if (relatedTo.isEmpty()) {
return;
}
if (relType == u"m.in_reply_to"_s) {
msg["content"]["m.relates_to"] = nlohmann::json{
{"m.in_reply_to", {
{"event_id", relatedTo},
}},
};
} else {
msg["content"]["m.relates_to"] = nlohmann::json{
{"rel_type", relType},
{"event_id", relatedTo},
};
}
}
static const std::string HTML_FORMAT = "org.matrix.custom.html";
nlohmann::json makeTextMessageJson(const QString &text, const QString &relType, const QString &relatedTo, Event replyToEvent)
{
auto msg = nlohmann::json{
{"type", "m.room.message"},
{"content", {
{"msgtype", "m.text"},
{"body", text},
}},
};
std::string replyToBody;
if (relType == u"m.in_reply_to"_s && !relatedTo.isEmpty()) {
auto replyToContent = replyToEvent.content().get();
if (replyToContent.contains("format")
&& replyToContent["format"] == HTML_FORMAT
&& replyToContent.contains("formatted_body")
&& replyToContent["formatted_body"].is_string()) {
replyToBody = replyToContent["formatted_body"].template get<std::string>();
auto replyPos = replyToBody.find("</mx-reply>");
if (replyPos != std::string::npos) {
replyToBody.erase(0, replyPos + std::string("</mx-reply>").size());
}
} else if (replyToContent.contains("body") && replyToContent["body"].is_string()) {
replyToBody = markdownToHtml(replyToContent["body"].template get<std::string>()).html;
}
if (!replyToBody.empty()) {
replyToBody = "<mx-reply><blockquote>" + replyToBody + "</blockquote></mx-reply>";
}
}
auto richText = markdownToHtml(text.toStdString());
msg["content"]["format"] = HTML_FORMAT;
msg["content"]["formatted_body"] = replyToBody + richText.html;
auto mentions = richText.mentions;
if (!replyToEvent.sender().empty()
&& std::find(mentions.begin(), mentions.end(), replyToEvent.sender()) == mentions.end()) {
mentions = std::move(mentions).push_back(replyToEvent.sender());
}
if (!mentions.empty()) {
msg["content"]["m.mentions"] = {
{"user_ids", mentions},
};
}
if (relType == u"m.replace"_s && !relatedTo.isEmpty()) {
msg["content"]["m.new_content"] = msg["content"];
}
maybeAddRelations(msg, relType, relatedTo);
return msg;
}
void MatrixRoom::sendMessage(const QJsonObject &eventJson, const QString &relType, const QString &relatedTo) const
{
auto msg = nlohmann::json(eventJson);
maybeAddRelations(msg, relType, relatedTo);
m_room.sendMessage(Event(msg));
}
void MatrixRoom::sendTextMessage(QString text, const QString &relType, QString relatedTo) const
{
Event replyToEvent = (relType == u"m.in_reply_to"_s && !relatedTo.isEmpty())
? m_room.message(lager::make_constant(relatedTo.toStdString())).make().get()
: Event();
auto j = makeTextMessageJson(text, relType, relatedTo, replyToEvent);
m_room.sendMessage(Event(j));
}
void MatrixRoom::sendMediaFileMessage(
QString fileName, QString mimeType, qint64 fileSize, QString mxcUri,
const QString &relType, const QString &relatedTo) const
{
auto j = makeMediaFileMessageJson(
fileName, mimeType, fileSize, mxcUri, relType, relatedTo);
Kazv::Event e{j};
m_room.sendMessage(e);
}
void MatrixRoom::sendEncryptedFileMessage(const QString &fileName, const QString &mimeType,
const qint64 fileSize, const QString &mxcUri,
const QString &key, const QString &iv, const QByteArray &hash,
const QString &relType, const QString &relatedTo) const
{
auto j = makeEncryptedFileMessageJson(
fileName, mimeType, fileSize, mxcUri,
key, iv, hash, relType, relatedTo);
Kazv::Event e{j};
m_room.sendMessage(e);
}
static nlohmann::json makeReactionJson(QString text, QString relatedTo)
{
auto msg = nlohmann::json{
{"type", "m.reaction"},
{"content", {
{"m.relates_to", {
{"rel_type", "m.annotation"},
{"event_id", relatedTo.toStdString()},
{"key", text.toStdString()},
}},
}},
};
return msg;
}
void MatrixRoom::sendReaction(QString text, QString relatedTo) const
{
auto j = makeReactionJson(text, relatedTo);
m_room.sendMessage(Event(j));
}
void MatrixRoom::resendMessage(QString txnId) const
{
m_room.resendMessage(txnId.toStdString());
}
MatrixPromise *MatrixRoom::sendStateEvent(const QJsonObject &eventJson) const
{
return new MatrixPromise(m_room.sendStateEvent(Event(json(eventJson))));
}
MatrixPromise *MatrixRoom::setSelfName(const QString &name) const
{
auto eventJson = m_room.state({"m.room.member", m_selfUserId.get()}).make().get().originalJson().get();
eventJson.merge_patch(json{
{"content", {
{"displayname", name.toStdString()},
}},
});
return new MatrixPromise(m_room.sendStateEvent(Event(json(eventJson))));
}
nlohmann::json MatrixRoom::makeMediaFileMessageJson(
QString fileName, QString mimeType, qint64 fileSize, QString mxcUri,
const QString &relType, const QString &relatedTo) const
{
static auto available_msgtype = std::array<std::string, 3>{"m.audio", "m.video", "m.image"};
auto try_msgtype = std::find(available_msgtype.begin(), available_msgtype.end(),
u"m."_s.append(mimeType.split(u'/')[0]).toStdString());
std::string msgtype;
if (try_msgtype == available_msgtype.end()) {
msgtype = "m.file";
} else {
msgtype = *try_msgtype;
}
auto msg = nlohmann::json {
{"type", "m.room.message"},
{"content", {
{"msgtype", msgtype},
{"body", fileName.toStdString()},
{"url", mxcUri.toStdString()},
{"info", {
{"size", fileSize},
{"mimetype", mimeType.toStdString()}
}}
}}
};
maybeAddRelations(msg, relType, relatedTo);
return msg;
}
nlohmann::json MatrixRoom::makeEncryptedFileMessageJson(const QString &fileName, const QString &mimeType,
const qint64 fileSize, const QString &mxcUri,
const QString &key, const QString &iv, const QByteArray &hash,
const QString &relType, const QString &relatedTo) const
{
static auto available_msgtype = std::array<std::string, 3>{"m.audio", "m.video", "m.image"};
auto try_msgtype = std::find(available_msgtype.begin(), available_msgtype.end(),
u"m."_s.append(mimeType.split(u'/')[0]).toStdString());
std::string msgtype;
if (try_msgtype == available_msgtype.end()) {
msgtype = "m.file";
} else {
msgtype = *try_msgtype;
}
auto msg = nlohmann::json {
{"type", "m.room.message"},
{"content", {
{"msgtype", msgtype},
{"body", fileName.toStdString()},
{"file", {
{"url", mxcUri.toStdString()},
{"key", {
{"kty", "oct"},
{"key_ops", nlohmann::json::array({"encrypt", "decrypt"})},
{"alg", "A256CTR"},
{"k", key.toStdString()},
{"ext", true}
}},
{"iv", iv.toStdString()},
{"hashes", {
{"sha256", hash.toStdString()}
}},
{"v", "v2"}
}},
{"info", {
{"size", fileSize},
{"mimetype", mimeType.toStdString()}
}}
}}
};
maybeAddRelations(msg, relType, relatedTo);
return msg;
}
MatrixPromise *MatrixRoom::redactEvent(QString eventId, QString reason) const
{
qCInfo(kazvLog) << "redactEvent(" << eventId << ", " << reason << ")";
return new MatrixPromise(m_room.redactEvent(
eventId.toStdString(),
reason.isEmpty() ? std::nullopt : std::optional<std::string>(reason.toStdString())
));
}
MatrixPromise *MatrixRoom::removeLocalEcho(QString txnId) const
{
return new MatrixPromise(m_room.removeLocalEcho(txnId.toStdString()));
}
MatrixPromise *MatrixRoom::addOrSetTag(QString tagId) const
{
return new MatrixPromise(m_room.addOrSetTag(tagId.toStdString()));
}
MatrixPromise *MatrixRoom::removeTag(QString tagId) const
{
return new MatrixPromise(m_room.removeTag(tagId.toStdString()));
}
MatrixPromise *MatrixRoom::paginateBackFrom(QString eventId) const
{
return new MatrixPromise(m_room.paginateBackFromEvent(eventId.toStdString()));
}
MatrixPromise *MatrixRoom::leaveRoom() const
{
return new MatrixPromise(m_room.leave());
}
MatrixPromise *MatrixRoom::forgetRoom() const
{
return new MatrixPromise(m_room.forget());
}
MatrixRoomMember *MatrixRoom::memberAt(int index) const
{
return new MatrixRoomMember(m_room.memberEventByCursor(
m_memberNames[index][lager::lenses::or_default]));
}
MatrixRoomMember *MatrixRoom::member(QString userId) const
{
return new MatrixRoomMember(m_room.memberEventFor(userId.toStdString()));
}
MatrixRoomState *MatrixRoom::allState() const
{
return new MatrixRoomState(m_room.stateEvents());
}
MatrixEvent *MatrixRoom::state(const QString &type, const QString &stateKey) const
{
return new MatrixEvent(m_room.state({type.toStdString(), stateKey.toStdString()}));
}
MatrixEvent *MatrixRoom::messageById(QString eventId) const
{
+ auto eventIdStd = eventId.toStdString();
return new MatrixEvent(
- m_room.message(lager::make_constant(eventId.toStdString()))
- .map([](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc> { return e; }),
+ m_messages[eventIdStd]
+ .map([eventIdStd](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> {
+ if (e.has_value()) {
+ return e.value();
+ } else {
+ return eventIdStd;
+ }
+ }),
m_room
);
}
MatrixEvent *MatrixRoom::localEchoById(QString txnId) const
{
return new MatrixEvent(
m_room.localEcho(lager::make_constant(txnId.toStdString()))
- .map([](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc> { return e; }),
+ .map([](const auto &e) -> std::variant<Kazv::Event, Kazv::LocalEchoDesc, std::string> { return e; }),
m_room
);
}
MatrixRoomMemberListModel *MatrixRoom::typingUsers() const
{
return new MatrixRoomMemberListModel(
m_room.typingMemberEvents()
.xform(containerMap(EventList{}, zug::filter([self=m_selfUserId.make().get()](const auto &e) {
return e.stateKey() != self;
})))
);
}
void MatrixRoom::setTyping(bool typing)
{
if (typing) {
m_setTypingThrottled();
} else {
m_room.setTyping(false, std::nullopt);
}
}
void MatrixRoom::setTypingImpl()
{
m_room.setTyping(true, typingTimeoutMs);
}
void MatrixRoom::setLocalDraftText(QString localDraft)
{
// To avoid heavy computations when updating the local draft again
// and again, we only set our internal state. **Assume only
// one MatrixRoom can deal with the local draft** at the same time,
// it is safe to update it debounced and when we are destructed.
// See also the destructor.
m_internalLocalDraft = localDraft;
m_updateLocalDraftDebounced();
}
void MatrixRoom::updateLocalDraftTextNow()
{
if (m_internalLocalDraft.has_value()) {
m_room.setLocalDraft(m_internalLocalDraft.value().toStdString());
m_internalLocalDraft = std::nullopt;
}
}
MatrixPromise *MatrixRoom::pushLocalDraft(const QString &localDraft,
const QString &draftRelType, const QString &draftRelTo)
{
updateLocalDraftTextNow();
return new MatrixPromise(m_room.pushLocalDraft(localDraft.toStdString(),
draftRelType.toStdString(), draftRelTo.toStdString()));
}
MatrixPromise *MatrixRoom::popLocalDraft()
{
return new MatrixPromise(m_room.popLocalDraft());
}
MatrixPromise *MatrixRoom::setLocalDraft(const QString &localDraft,
const QString &draftRelType, const QString &draftRelTo)
{
return new MatrixPromise(m_room.setLocalDraft(localDraft.toStdString(),
draftRelType.toStdString(), draftRelTo.toStdString()));
}
MatrixPromise *MatrixRoom::setLocalDraftRel(
const QString draftRelType, const QString draftRelTo)
{
return new MatrixPromise(m_room.setLocalDraftRel(draftRelType.toStdString(),
draftRelTo.toStdString()));
}
MatrixRoomMemberListModel *MatrixRoom::members() const
{
return new MatrixRoomMemberListModel(
m_room.joinedMemberEvents(),
QString(),
m_userGivenNicknameEvent
);
}
MatrixRoomMemberListModel *MatrixRoom::bannedMembers() const
{
return new MatrixRoomMemberListModel(
m_room.bannedMemberEvents(),
QString(),
m_userGivenNicknameEvent
);
}
qint64 MatrixRoom::userPowerLevel(const QString &userId) const
{
auto e = m_powerLevels.get().normalizedEvent().content().get();
auto userIdStd = userId.toStdString();
return e.at("users").contains(userIdStd)
? e.at("users")[userIdStd].template get<Kazv::PowerLevel>()
: e.at("users_default").template get<Kazv::PowerLevel>();
}
MatrixPromise *MatrixRoom::setUserPowerLevel(const QString &userId, qint64 powerLevel) const
{
auto next = m_powerLevels.get().setUser(userId.toStdString(), powerLevel);
return new MatrixPromise(m_room.sendStateEvent(next.originalEvent()));
}
MatrixPromise *MatrixRoom::unsetUserPowerLevel(const QString &userId) const
{
auto next = m_powerLevels.get().setUser(userId.toStdString(), std::nullopt);
return new MatrixPromise(m_room.sendStateEvent(next.originalEvent()));
}
MatrixPromise *MatrixRoom::getStateEvent(const QString &type, const QString &stateKey) const
{
return new MatrixPromise(m_room.getStateEvent(
type.toStdString(), stateKey.toStdString()
));
}
MatrixPromise *MatrixRoom::ensureStateEvent(const QString &type, const QString &stateKey) const
{
if (m_room
.stateOpt({type.toStdString(), stateKey.toStdString()})
.make()
.get()
.has_value()) {
return MatrixPromise::createResolved(true, QJsonObject());
} else {
return getStateEvent(type, stateKey);
}
}
MatrixPromise *MatrixRoom::refreshState() const
{
return new MatrixPromise(m_room.refreshRoomState());
}
MatrixPromise *MatrixRoom::kickUser(const QString &userId, const QString &reason) const
{
return new MatrixPromise(m_room.kick(userId.toStdString(), reason.toStdString()));
}
MatrixPromise *MatrixRoom::banUser(const QString &userId, const QString &reason) const
{
return new MatrixPromise(m_room.ban(userId.toStdString(), reason.toStdString()));
}
MatrixPromise *MatrixRoom::unbanUser(const QString &userId) const
{
return new MatrixPromise(m_room.unban(userId.toStdString()));
}
MatrixPromise *MatrixRoom::inviteUser(const QString &userId) const
{
return new MatrixPromise(m_room.invite(userId.toStdString()));
}
MatrixPromise *MatrixRoom::postReadReceipt(const QString &eventId) const
{
return new MatrixPromise(m_room.postReceipt(eventId.toStdString()));
}
MatrixPromise *MatrixRoom::setName(const QString &newName) const
{
return new MatrixPromise(m_room.setName(newName.toStdString()));
}
MatrixPromise *MatrixRoom::setTopic(const QString &newTopic) const
{
return new MatrixPromise(m_room.setTopic(newTopic.toStdString()));
}
MatrixStickerPackList *MatrixRoom::stickerPackList() const
{
return new MatrixStickerPackList(m_room);
}
diff --git a/src/matrix-room.hpp b/src/matrix-room.hpp
index 188685b..2200582 100644
--- a/src/matrix-room.hpp
+++ b/src/matrix-room.hpp
@@ -1,207 +1,208 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <kazv-defs.hpp>
#include <QObject>
#include <QQmlEngine>
#include <QUrl>
#include <QTimer>
#include <lager/extra/qt.hpp>
#include <client/room/room.hpp>
Q_MOC_INCLUDE("matrix-room-timeline.hpp")
Q_MOC_INCLUDE("matrix-room-pinned-events-timeline.hpp")
Q_MOC_INCLUDE("matrix-sticker-pack-list.hpp")
Q_MOC_INCLUDE("matrix-room-state.hpp")
class MatrixRoomTimeline;
class MatrixRoomPinnedEventsTimeline;
class MatrixRoomMember;
class MatrixPromise;
class MatrixRoomMemberListModel;
class MatrixEvent;
class MatrixStickerPackList;
class MatrixRoomState;
nlohmann::json makeTextMessageJson(const QString &text, const QString &relType, const QString &relatedTo, Kazv::Event replyToEvent);
class MatrixRoom : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
Kazv::Room m_room;
+ lager::reader<immer::map<std::string, Kazv::Event>> m_messages;
lager::reader<Kazv::Event> m_userGivenNicknameEvent;
lager::reader<std::string> m_selfUserId;
lager::reader<immer::flex_vector<std::string>> m_memberNames;
lager::reader<Kazv::PowerLevelsDesc> m_powerLevels;
public:
enum Membership {
Invite = Kazv::RoomMembership::Invite,
Join = Kazv::RoomMembership::Join,
Leave = Kazv::RoomMembership::Leave,
};
Q_ENUM(Membership);
explicit MatrixRoom(Kazv::Room room, lager::reader<std::string> selfUserId, lager::reader<Kazv::Event> userGivenNicknameEvent = lager::make_constant(Kazv::Event()), QObject *parent = 0);
~MatrixRoom() override;
LAGER_QT_READER(QString, roomId);
LAGER_QT_READER(QString, name);
LAGER_QT_READER(QString, topic);
LAGER_QT_READER(QStringList, heroNames);
LAGER_QT_READER(QVariant, heroEvents);
LAGER_QT_READER(QString, avatarMxcUri);
LAGER_QT_READER(QString, roomOrHeroAvatarMxcUri);
LAGER_QT_READER(QString, localDraft);
LAGER_QT_READER(QString, prevDraft);
LAGER_QT_READER(QString, draftRelType);
LAGER_QT_READER(QString, draftRelTo);
LAGER_QT_READER(bool, encrypted);
LAGER_QT_READER(bool, tombstoned);
LAGER_QT_READER(QStringList, memberNames);
LAGER_QT_READER(QStringList, tagIds);
LAGER_QT_READER(Membership, membership);
LAGER_QT_READER(int, unreadNotificationCount);
LAGER_QT_READER(QString, localReadMarker);
Q_INVOKABLE MatrixRoomMember *memberAt(int index) const;
Q_INVOKABLE MatrixRoomMember *member(QString userId) const;
Q_INVOKABLE MatrixRoomState *allState() const;
Q_INVOKABLE MatrixEvent *state(const QString &type, const QString &stateKey) const;
Q_INVOKABLE MatrixRoomTimeline *timeline() const;
Q_INVOKABLE MatrixRoomPinnedEventsTimeline *pinnedEventsTimeline() const;
Q_INVOKABLE MatrixPromise *pinEvents(const QStringList &eventIds) const;
Q_INVOKABLE MatrixPromise *unpinEvents(const QStringList &eventIds) const;
Q_INVOKABLE MatrixEvent *messageById(QString eventId) const;
Q_INVOKABLE MatrixEvent *localEchoById(QString txnId) const;
Q_INVOKABLE void sendMessage(const QJsonObject &eventJson, const QString &relType, const QString &relatedTo) const;
Q_INVOKABLE void sendTextMessage(QString text, const QString &relType, QString relatedTo) const;
Q_INVOKABLE void sendMediaFileMessage(QString fileName, QString mimeType,
qint64 fileSize, QString mxcUri,
const QString &relType, const QString &relatedTo) const;
Q_INVOKABLE void sendEncryptedFileMessage(const QString &fileName, const QString& mimeType,
const qint64 fileSize, const QString& mxcUri,
const QString &key, const QString &iv, const QByteArray &hash,
const QString &relType, const QString &relatedTo) const;
Q_INVOKABLE void sendReaction(QString text, QString relatedTo) const;
Q_INVOKABLE void resendMessage(QString txnId) const;
Q_INVOKABLE MatrixPromise *sendStateEvent(const QJsonObject &eventJson) const;
Q_INVOKABLE MatrixPromise *setSelfName(const QString &name) const;
Q_INVOKABLE MatrixPromise *redactEvent(QString eventId, QString reason) const;
Q_INVOKABLE MatrixPromise *removeLocalEcho(QString txnId) const;
Q_INVOKABLE MatrixRoomMemberListModel *typingUsers() const;
Q_INVOKABLE void setTyping(bool typing);
Q_INVOKABLE void setLocalDraftText(QString localDraft);
Q_INVOKABLE void updateLocalDraftTextNow();
Q_INVOKABLE MatrixPromise *pushLocalDraft(const QString &localDraft,
const QString &draftRelType, const QString &draftRelTo);
Q_INVOKABLE MatrixPromise *popLocalDraft();
Q_INVOKABLE MatrixPromise *setLocalDraft(const QString &localDraft,
const QString &draftRelType, const QString &draftRelTo);
Q_INVOKABLE MatrixPromise *setLocalDraftRel(
const QString draftRelType, const QString draftRelTo);
Q_INVOKABLE MatrixPromise *addOrSetTag(QString tagId) const;
Q_INVOKABLE MatrixPromise *removeTag(QString tagId) const;
Q_INVOKABLE MatrixPromise *paginateBackFrom(QString eventId) const;
Q_INVOKABLE MatrixPromise *leaveRoom() const;
Q_INVOKABLE MatrixPromise *forgetRoom() const;
Q_INVOKABLE MatrixRoomMemberListModel *members() const;
Q_INVOKABLE MatrixRoomMemberListModel *bannedMembers() const;
Q_INVOKABLE qint64 userPowerLevel(const QString &userId) const;
Q_INVOKABLE MatrixPromise *setUserPowerLevel(const QString &userId, qint64 powerLevel) const;
Q_INVOKABLE MatrixPromise *unsetUserPowerLevel(const QString &userId) const;
Q_INVOKABLE MatrixPromise *getStateEvent(const QString &type, const QString &stateKey) const;
Q_INVOKABLE MatrixPromise *ensureStateEvent(const QString &type, const QString &stateKey) const;
Q_INVOKABLE MatrixPromise *refreshState() const;
Q_INVOKABLE MatrixPromise *kickUser(const QString &userId, const QString &reason) const;
Q_INVOKABLE MatrixPromise *banUser(const QString &userId, const QString &reason = QStringLiteral()) const;
Q_INVOKABLE MatrixPromise *unbanUser(const QString &userId) const;
Q_INVOKABLE MatrixPromise *inviteUser(const QString &userId) const;
Q_INVOKABLE MatrixPromise *postReadReceipt(const QString &eventId) const;
Q_INVOKABLE MatrixPromise *setName(const QString &newName) const;
Q_INVOKABLE MatrixPromise *setTopic(const QString &newTopic) const;
/**
* Get the sticker pack list defined in this room.
*
* @return A list of sticker packs defined in this room.
*/
Q_INVOKABLE MatrixStickerPackList *stickerPackList() const;
Q_SIGNALS:
void powerLevelsChanged();
protected:
nlohmann::json makeMediaFileMessageJson(QString fileName, QString mimeType,
qint64 fileSize, QString mxcUri,
const QString &relType, const QString &relatedTo) const;
nlohmann::json makeEncryptedFileMessageJson(const QString &fileName, const QString &mimeType,
const qint64 fileSize, const QString &mxcUri,
const QString &key, const QString &iv, const QByteArray &hash,
const QString &relType, const QString &relatedTo) const;
private Q_SLOTS:
void setTypingImpl();
private:
std::function<void()> m_setTypingThrottled;
std::function<void()> m_updateLocalDraftDebounced;
std::optional<QString> m_internalLocalDraft;
};
diff --git a/src/matrix-session.cpp b/src/matrix-session.cpp
index ec6f859..04f638f 100644
--- a/src/matrix-session.cpp
+++ b/src/matrix-session.cpp
@@ -1,599 +1,631 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include "matrix-session.hpp"
#include "matrix-utils.hpp"
#include "matrix-room-list.hpp"
#include "matrix-promise.hpp"
#include "matrix-event.hpp"
#include "device-mgmt/matrix-device-list.hpp"
#include "matrix-sticker-pack-list.hpp"
#include "matrix-sticker-pack-list-p.hpp"
#include "matrix-user-given-attrs-map.hpp"
#include "matrix-verification-list.hpp"
#include "sso-login-process.hpp"
#include "db-store.hpp"
#include "helper.hpp"
#include "kazv-log.hpp"
#include <csapi/login.hpp>
#include <csapi/directory.hpp>
#include <client/alias.hpp>
#include <QFile>
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
static const std::string clientName = "kazv";
MatrixSession::MatrixSession(
MatrixSessionContextT context,
Client client,
LagerStoreEventEmitter::Watchable watchable,
lager::reader<VerificationTrackerModel> verificationTrackerState,
std::function<DbStore &()> dbStoreGetter,
QObject *parent
)
: QObject(parent)
, m_context(std::move(context))
, m_clientOnSecondaryRoot(std::move(client))
, m_watchable(std::move(watchable))
, m_notificationHandler(m_clientOnSecondaryRoot.notificationHandler())
, m_verificationTrackerState(std::move(verificationTrackerState))
, m_ssoLoginProcess()
, m_dbStoreGetter(std::move(dbStoreGetter))
, LAGER_QT(serverUrl)(m_clientOnSecondaryRoot.serverUrl().xform(strToQt))
, LAGER_QT(userId)(m_clientOnSecondaryRoot.userId().xform(strToQt))
, LAGER_QT(token)(m_clientOnSecondaryRoot.token().xform(strToQt))
, LAGER_QT(deviceId)(m_clientOnSecondaryRoot.deviceId().xform(strToQt))
, LAGER_QT(specVersions)(m_clientOnSecondaryRoot.supportVersions())
{
m_watchable.afterAll(
[this](KazvTrigger e) {
Q_EMIT this->trigger(e);
});
m_watchable.after<LoginSuccessful>(
[this](LoginSuccessful e) {
Q_EMIT this->loginSuccessful(e);
});
m_watchable.after<LoginFailed>(
[this](LoginFailed e) {
Q_EMIT this->loginFailed(
QString::fromStdString(e.errorCode),
QString::fromStdString(e.error)
);
});
m_watchable.after<ReceivingRoomTimelineEvent>(
[this](ReceivingRoomTimelineEvent e) {
Q_EMIT this->receivedMessage(
QString::fromStdString(e.roomId),
QString::fromStdString(e.event.id())
);
});
}
MatrixSession::~MatrixSession() = default;
QString MatrixSession::mxcUriToHttp(QString mxcUri) const
{
return QString::fromStdString(m_clientOnSecondaryRoot.mxcUriToHttp(mxcUri.toStdString()));
}
QString MatrixSession::mxcUriToHttpAuthenticatedV1(QString mxcUri) const
{
return QString::fromStdString(m_clientOnSecondaryRoot.mxcUriToHttpV1(mxcUri.toStdString()));
}
MatrixDeviceList *MatrixSession::devicesOfUser(QString userId) const
{
return new MatrixDeviceList(m_clientOnSecondaryRoot.devicesOfUser(userId.toStdString()));
}
bool isIllFormatSpecVersion(const QString &version)
{
const bool isLegacy = version.startsWith(u"r"_s);
if (isLegacy && version.split(u'.').size() == 3) {
return false;
}
if (!isLegacy && version.split(u'.').size() == 2) {
return false;
}
return true;
}
// Return true if v1 is at least as new as v2, false if v1 is older than v2
// Return false if v1 or v2 is ill-format
bool compareSpecVersion(QString v1, QString v2)
{
// Check parameters format
if (isIllFormatSpecVersion(v1) || isIllFormatSpecVersion(v2)) {
return false;
}
const bool v1IsLegacy = v1.startsWith(u"r"_s);
const bool v2IsLegacy = v2.startsWith(u"r"_s);
if (v1IsLegacy != v2IsLegacy) {
return v2IsLegacy;
}
v1.remove(0, 1);
v2.remove(0, 1);
auto v1VersionNumbers = v1.split(u'.');
auto v2VersionNumbers = v2.split(u'.');
for (int i = 0; i < v1VersionNumbers.size(); i++) {
auto v1VerNum = v1VersionNumbers[i].toInt();
auto v2VerNum = v2VersionNumbers[i].toInt();
if (v1VerNum != v2VerNum) {
return v1VerNum > v2VerNum;
}
}
// v1 is equal to v2
return true;
}
// Return true if version in the range [minVer, maxVer]
// Return false if any parameter is ill-format
bool compareSpecVersionRange(const QString &version,
const QString &minVer, const QString &maxVer)
{
if (isIllFormatSpecVersion(version)
|| isIllFormatSpecVersion(minVer)
|| isIllFormatSpecVersion(maxVer)) {
return false;
}
if (compareSpecVersion(version, minVer) && compareSpecVersion(maxVer, version)) {
return true;
}
return false;
}
bool MatrixSession::checkSpecVersion(QString version) const
{
return std::find_if(specVersions().begin(), specVersions().end(),
[&version](auto v) {
return compareSpecVersion(QString::fromStdString(v), version);
}) != specVersions().end();
}
bool MatrixSession::checkSpecVersionRange(QString minVer, QString maxVer) const
{
return std::find_if(specVersions().begin(), specVersions().end(),
[&minVer, maxVer](auto v) {
return compareSpecVersionRange(QString::fromStdString(v), minVer, maxVer);
}) != specVersions().end();
}
QStringList MatrixSession::directRoomIds(QString userId) const
{
auto content = m_clientOnSecondaryRoot.accountData().get()["m.direct"].content().get();
auto roomIds = QStringList{};
for (auto i : content[userId.toStdString()]) {
roomIds.push_back(QString::fromStdString(i.get<std::string>()));
}
return roomIds;
}
MatrixVerificationList *MatrixSession::verificationList() const
{
return new MatrixVerificationList(m_clientOnSecondaryRoot, m_verificationTrackerState);
}
std::string MatrixSession::validateHomeserverUrl(const QString &url)
{
if (url.isEmpty()) {
return std::string();
}
auto u = QUrl::fromUserInput(url);
if (!u.isValid()) {
return std::string();
}
if (u.scheme() == u"http"_s) {
qCInfo(kazvLog) << "url" << u << "is http. Force switching to https.";
u.setScheme(u"https"_s);
} else if (u.scheme() != u"https"_s) {
qCWarning(kazvLog) << "url" << u << "is not http/https.";
return std::string();
}
return u.toString().toStdString();
}
void MatrixSession::login(const QString &userId, const QString &password, const QString &homeserverUrl)
{
auto loginFunc = [userId, password](const Client &client, const std::string &serverUrl) {
client.passwordLogin(
serverUrl,
userId.toStdString(),
password.toStdString(),
clientName,
/* startSyncingOnSuccess = */ true
);
};
auto validated = validateHomeserverUrl(homeserverUrl);
if (!validated.empty()) {
loginFunc(m_clientOnSecondaryRoot, validated);
} else {
m_clientOnSecondaryRoot
.autoDiscover(userId.toStdString()) // autoDiscover() will dispatch GetVersionAction to get supported versions of the server
.then([
this,
client=m_clientOnSecondaryRoot.toEventLoop(),
userId,
password,
loginFunc
](auto res) {
if (!res.success()) {
// FIXME use real error codes and msgs when available in libkazv
Q_EMIT this->discoverFailed(u""_s, u""_s);
return res;
}
auto serverUrl = res.dataStr("homeserverUrl");
loginFunc(client, serverUrl);
return res;
});
}
}
void MatrixSession::discoverAndGetLoginFlows(const QString &userId, const QString &homeserverUrl)
{
auto validated = validateHomeserverUrl(homeserverUrl);
if (!validated.empty()) {
getLoginFlows(homeserverUrl);
} else {
m_clientOnSecondaryRoot
.autoDiscover(userId.toStdString())
.then([
this
](const EffectStatus &res) {
if (!res.success()) {
Q_EMIT discoverFailed(
QString::fromStdString(res.dataStr("errorCode")),
QString::fromStdString(res.dataStr("error"))
);
return;
}
auto serverUrl = QString::fromStdString(res.dataStr("homeserverUrl"));
Q_EMIT discoverSuccessful(serverUrl);
getLoginFlows(serverUrl);
});
}
}
void MatrixSession::getLoginFlows(const QString &serverUrl)
{
lager::get<JobInterface &>(m_context).submit(
Api::GetLoginFlowsJob(serverUrl.toStdString()),
[this](Api::GetLoginFlowsResponse r) {
if (!r.success()) {
Q_EMIT getLoginFlowsFailed(
QString::fromStdString(r.errorCode()),
QString::fromStdString(r.errorMessage())
);
return;
}
auto v = std::move(r).jsonBody().get().at("flows").template get<QJsonValue>();
Q_EMIT getLoginFlowsSuccessful(v);
}
);
}
QUrl MatrixSession::ssoLoginStart(const QString &homeserverUrl)
{
if (m_ssoLoginProcess) {
m_ssoLoginProcess.reset();
}
m_ssoLoginProcess.reset(new SsoLoginProcess());
if (!m_ssoLoginProcess->startServer()) {
return QUrl();
}
auto link = m_ssoLoginProcess->getSsoLink(homeserverUrl);
connect(m_ssoLoginProcess.get(), &SsoLoginProcess::loginTokenAvailable,
this, [this, homeserverUrl](const QString &loginToken) {
Q_EMIT ssoLoginTokenAvailable();
qCInfo(kazvLog) << "Got SSO login token";
m_ssoLoginProcess.reset();
m_clientOnSecondaryRoot.mLoginTokenLogin(
homeserverUrl.toStdString(),
loginToken.toStdString(),
clientName,
/* startSyncingOnSuccess = */ true
);
});
return link;
}
void MatrixSession::logout()
{
m_clientOnSecondaryRoot.logout()
.then([&] (EffectStatus stat) {
if (stat.success()) {
Q_EMIT this->logoutSuccessful();
} else {
Q_EMIT this->logoutFailed(QString::fromStdString(stat.dataStr("errorCode")), QString::fromStdString(stat.dataStr("error")));
}
});
}
MatrixRoomList *MatrixSession::roomList() const
{
return new MatrixRoomList(m_clientOnSecondaryRoot);
}
static std::optional<std::string> optMaybe(QString s)
{
if (s.isEmpty()) {
return std::nullopt;
} else {
return s.toStdString();
}
}
MatrixPromise *MatrixSession::createRoom(
bool isPrivate,
const QString &name,
const QString &alias,
const QStringList &invite,
bool isDirect,
bool allowFederate,
const QString &topic,
const QJsonValue &powerLevelContentOverride,
Constants::CreateRoomPreset preset,
bool encrypted
)
{
immer::array<Event> initialState;
if (encrypted) {
initialState = {Event{json{
{"type", "m.room.encryption"},
{"state_key", ""},
{"content", {
{"algorithm", "m.megolm.v1.aes-sha2"},
}},
}}};
}
return new MatrixPromise(m_clientOnSecondaryRoot.createRoom(
isPrivate ? Kazv::RoomVisibility::Private : Kazv::RoomVisibility::Public,
optMaybe(name),
optMaybe(alias),
qStringListToStdF(invite),
isDirect,
allowFederate,
optMaybe(topic),
nlohmann::json(powerLevelContentOverride),
static_cast<Kazv::CreateRoomPreset>(preset),
initialState
));
}
MatrixPromise *MatrixSession::joinRoom(const QString &idOrAlias, const QStringList &servers)
{
return new MatrixPromise(m_clientOnSecondaryRoot.joinRoom(
idOrAlias.toStdString(),
qStringListToStdF(servers)
));
}
MatrixPromise *MatrixSession::setDeviceTrustLevel(QString userId, QString deviceId, QString trustLevel)
{
return new MatrixPromise(
m_clientOnSecondaryRoot.setDeviceTrustLevel(
userId.toStdString(),
deviceId.toStdString(),
qStringToTrustLevelFunc(trustLevel)
)
);
}
MatrixPromise *MatrixSession::getSelfProfile()
{
return new MatrixPromise(
m_clientOnSecondaryRoot.getProfile(userId().toStdString())
);
}
MatrixPromise *MatrixSession::setDisplayName(QString displayName)
{
return new MatrixPromise(
m_clientOnSecondaryRoot.setDisplayName(
displayName.isEmpty() ? std::nullopt : std::optional<std::string>(displayName.toStdString())
)
);
}
MatrixPromise *MatrixSession::setAvatarUrl(QString avatarUrl)
{
return new MatrixPromise(
m_clientOnSecondaryRoot.setAvatarUrl(
avatarUrl.isEmpty() ? std::nullopt : std::optional<std::string>(avatarUrl.toStdString())
)
);
}
bool MatrixSession::shouldNotify(MatrixEvent *event) const
{
// Do not notify own event
if (event->sender() == userId()) {
return false;
}
return m_notificationHandler.handleNotification(event->underlyingEvent()).shouldNotify;
}
bool MatrixSession::shouldPlaySound(MatrixEvent *event) const
{
return m_notificationHandler.handleNotification(event->underlyingEvent()).sound.has_value();
}
MatrixStickerPackList *MatrixSession::stickerPackList() const
{
return new MatrixStickerPackList(m_clientOnSecondaryRoot);
}
MatrixEvent *MatrixSession::stickerRoomsEvent() const
{
return new MatrixEvent(m_clientOnSecondaryRoot.accountData()[imagePackRoomsEventType][lager::lenses::or_default]);
}
MatrixPromise *MatrixSession::updateStickerPack(MatrixStickerPackSource source)
{
if (source.source == MatrixStickerPackSource::AccountData) {
auto eventJson = std::move(source.event).raw().get();
eventJson["type"] = source.eventType;
return sendAccountDataImpl(Event(std::move(eventJson)));
} else if (source.source == MatrixStickerPackSource::RoomState) {
auto eventJson = std::move(source.event).raw().get();
eventJson["type"] = source.eventType;
eventJson["state_key"] = source.stateKey;
return new MatrixPromise(
m_clientOnSecondaryRoot
.room(source.roomId)
.sendStateEvent(Event(std::move(eventJson))));
} else {
return 0;
}
}
MatrixUserGivenAttrsMap *MatrixSession::userGivenNicknameMap() const
{
return new MatrixUserGivenAttrsMap(
userGivenNicknameMapFor(m_clientOnSecondaryRoot),
[client=m_clientOnSecondaryRoot](json content) {
return client.setAccountData(json{
{"type", USER_GIVEN_NICKNAME_EVENT_TYPES[0]},
{"content", std::move(content)},
});
}
);
}
MatrixPromise *MatrixSession::sendAccountData(const QString &type, const QJsonObject &content)
{
Event e = json{
{"type", type.toStdString()},
{"content", content},
};
return sendAccountDataImpl(std::move(e));
}
MatrixPromise *MatrixSession::sendAccountDataImpl(Event event)
{
return new MatrixPromise(m_clientOnSecondaryRoot.setAccountData(event));
}
MatrixPromise *MatrixSession::getSpecVersions()
{
return new MatrixPromise(m_clientOnSecondaryRoot.getVersions(LAGER_QT(serverUrl).get().toStdString()));
}
MatrixPromise *MatrixSession::addDirectRoom(const QString &userId, const QString &roomId)
{
return new MatrixPromise(m_clientOnSecondaryRoot.addDirectRoom(userId.toStdString(), roomId.toStdString()));
}
MatrixPromise *MatrixSession::getRoomIdByAlias(const QString &roomAlias)
{
auto job = m_clientOnSecondaryRoot
.getRoomIdByAliasJob(roomAlias.toStdString());
return new MatrixPromise(
m_context.createPromise([ctx=m_context, job](auto resolve) {
lager::get<JobInterface &>(ctx).submit(job, [resolve](GetRoomIdByAliasResponse r) {
resolve(parseGetRoomIdByAliasResponse(r));
});
}));
}
inline constexpr std::size_t purgeEventsKeepNumber = 5;
MatrixPromise *MatrixSession::purgeEventsExceptRooms(const QStringList &roomIds)
{
return new MatrixPromise(
m_context.createResolvedPromise({})
.then([client=m_clientOnSecondaryRoot.toEventLoop(), roomIds=qStringListToStdF(roomIds)
]([[maybe_unused]] auto &&stat) {
auto map = intoImmer(immer::map<std::string, std::size_t>{},
zug::filter([&roomIds](const std::string &roomId) {
return std::find(roomIds.begin(), roomIds.end(), roomId) == roomIds.end();
})
| zug::map([](const std::string &roomId) {
return std::make_pair(roomId, purgeEventsKeepNumber);
}),
client.roomIds().make().get()
);
return client.purgeRoomEvents(map);
}).then([](const auto &stat) {
qCDebug(kazvLog) << "Purge room events stat:" << !!stat;
return stat;
})
);
}
MatrixPromise *MatrixSession::backfillRoomFromEvent(const QString &roomId, const QString &eventId)
{
return new MatrixPromise(
m_context.createPromise([
dbStoreGetter=m_dbStoreGetter,
client=m_clientOnSecondaryRoot.toEventLoop(),
roomId,
eventId
](auto resolve) {
dbStoreGetter().getEventsBefore(roomId, eventId).then([client, resolve, roomId](auto &&res) {
auto [timelineEvents, relatedEvents] = std::move(res);
auto loadedCount = timelineEvents[roomId.toStdString()].size();
client.loadEventsFromStorage(timelineEvents, relatedEvents)
.then([resolve, loadedCount](auto &&) {
resolve(EffectStatus(
!!loadedCount,
json{{"loadedCount", loadedCount}}
));
});
});
})
);
}
+MatrixPromise *MatrixSession::loadEvent(const QString &roomId, const QString &eventId)
+{
+ return new MatrixPromise(
+ m_context.createPromise([
+ dbStoreGetter=m_dbStoreGetter,
+ client=m_clientOnSecondaryRoot.toEventLoop(),
+ roomId,
+ eventId
+ ](auto resolve) {
+ dbStoreGetter().getEventById(roomId, eventId).then([client, resolve, roomId](auto &&res) {
+ if (!res.has_value()) {
+ resolve(EffectStatus(/* succ = */ false));
+ return;
+ }
+ auto [event, _isInTimeline] = std::move(res).value();
+ // Disregard isInTimeline because when we load one single event,
+ // we do not want to add it to the timeline. If we add it,
+ // we lose info about the which events are before/after it,
+ // causing the remaining timeline to be gapped without a way
+ // to refill it.
+ immer::map<std::string, Kazv::EventList> relatedEvents = {
+ {roomId.toStdString(), {event}},
+ };
+ client.loadEventsFromStorage({}, relatedEvents)
+ .then([resolve](auto &&) {
+ resolve(EffectStatus(/* succ = */ true));
+ });
+ });
+ })
+ );
+}
+
MatrixPromise *MatrixSession::importFromKeyBackupFile(QUrl fileUrl, QString password)
{
if (!fileUrl.isLocalFile()) {
return new MatrixPromise(m_context.createResolvedPromise({
/* succ = */ false,
json{{"error", "Not a local file"}, {"errorCode", "NOT_LOCAL_FILE"}},
}));
}
auto filename = fileUrl.toLocalFile();
auto f = QFile(filename);
if (!f.open(QFile::ReadOnly)) {
return new MatrixPromise(m_context.createResolvedPromise({
/* succ = */ false,
json{{"error", "File open failed"}, {"errorCode", "FILE_OPEN_FAILED"}},
}));
}
auto ba = f.readAll();
auto content = std::string(ba.begin(), ba.end());
auto passwordStd = std::move(password).toStdString();
return new MatrixPromise(m_clientOnSecondaryRoot.importFromKeyBackupFile(
std::move(content), std::move(passwordStd)
));
}
MatrixPromise *MatrixSession::requestVerifyDevice(QString userId, QString deviceId)
{
return new MatrixPromise(m_clientOnSecondaryRoot.requestOutgoingToDeviceVerification(
std::move(userId).toStdString(),
std::move(deviceId).toStdString()
));
}
diff --git a/src/matrix-session.hpp b/src/matrix-session.hpp
index 69fb4fb..8d55095 100644
--- a/src/matrix-session.hpp
+++ b/src/matrix-session.hpp
@@ -1,301 +1,311 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2020-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <kazv-defs.hpp>
#include "helper.hpp"
#include "meta-types.hpp"
#include "matrix-session-types.hpp"
#include "constants.hpp"
#include <client.hpp>
#include <notification-handler.hpp>
#include <lagerstoreeventemitter.hpp>
#include <lager/extra/qt.hpp>
#include <QObject>
#include <QQmlEngine>
Q_MOC_INCLUDE("matrix-room-list.hpp")
Q_MOC_INCLUDE("matrix-device-list.hpp")
Q_MOC_INCLUDE("matrix-promise.hpp")
Q_MOC_INCLUDE("matrix-event.hpp")
Q_MOC_INCLUDE("matrix-sticker-pack-list.hpp")
Q_MOC_INCLUDE("matrix-user-given-attrs-map.hpp")
Q_MOC_INCLUDE("matrix-verification-list.hpp")
class MatrixRoomList;
class MatrixDeviceList;
class MatrixPromise;
class MatrixEvent;
class MatrixStickerPackList;
class MatrixUserGivenAttrsMap;
class MatrixVerificationList;
class SsoLoginProcess;
class DbStore;
/**
* Represent all operations that can be taken within a Matrix session.
*/
class MatrixSession : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
MatrixSessionContextT m_context;
Kazv::Client m_clientOnSecondaryRoot;
Kazv::LagerStoreEventEmitter::Watchable m_watchable;
Kazv::NotificationHandler m_notificationHandler;
lager::reader<Kazv::VerificationTrackerModel> m_verificationTrackerState;
UniquePtrDL<SsoLoginProcess> m_ssoLoginProcess;
std::function<DbStore &()> m_dbStoreGetter;
public:
MatrixSession(
MatrixSessionContextT context,
Kazv::Client client,
Kazv::LagerStoreEventEmitter::Watchable watchable,
lager::reader<Kazv::VerificationTrackerModel> verificationTrackerState,
std::function<DbStore &()> dbStoreGetter,
QObject *parent = nullptr
);
~MatrixSession() override;
static std::string validateHomeserverUrl(const QString &url);
LAGER_QT_READER(QString, serverUrl);
LAGER_QT_READER(QString, userId);
LAGER_QT_READER(QString, token);
LAGER_QT_READER(QString, deviceId);
LAGER_QT_READER(immer::array<std::string>, specVersions); // The versions of the Matrix Spec supported by the server.
Q_INVOKABLE MatrixRoomList *roomList() const;
Q_INVOKABLE QString mxcUriToHttp(QString mxcUri) const;
Q_INVOKABLE QString mxcUriToHttpAuthenticatedV1(QString mxcUri) const;
Q_INVOKABLE MatrixDeviceList *devicesOfUser(QString userId) const;
// Return true if version is at least as new as the spec version of server
Q_INVOKABLE bool checkSpecVersion(QString version) const;
// Return true if the spec version of server in the range [minVer, maxVer]
Q_INVOKABLE bool checkSpecVersionRange(QString minVer, QString maxVer) const;
Q_INVOKABLE QStringList directRoomIds(QString userId) const;
Q_INVOKABLE MatrixVerificationList *verificationList() const;
Q_SIGNALS:
void trigger(Kazv::KazvTrigger e);
void loginSuccessful(Kazv::KazvTrigger e);
void loginFailed(QString errorCode, QString errorMsg);
void discoverFailed(QString errorCode, QString errorMsg);
void discoverSuccessful(QString serverUrl);
void getLoginFlowsFailed(QString errorCode, QString errorMsg);
void getLoginFlowsSuccessful(QJsonValue flows);
void ssoLoginTokenAvailable();
void logoutSuccessful();
void logoutFailed(QString errorCode, QString errorMsg);
void receivedMessage(QString roomId, QString eventId);
public Q_SLOTS:
void login(const QString &userId, const QString &password, const QString &homeserverUrl);
/**
* Auto-discover the server url and then get the login flows from the server.
*
* If homeserverUrl is not provided, try to get it from auto-discovery.
* If discovery is successful, or it is already provided, get the login flows
* by calling getLoginFlows.
*/
void discoverAndGetLoginFlows(const QString &userId, const QString &homeserverUrl);
void getLoginFlows(const QString &serverUrl);
/**
* Start SSO login flow.
*
* It will start an http server on a local port and pass it as
* the redirect url to the SSO login link.
*
* When the user completes the SSO login flow, we get the login token
* for us to login via the token flow.
*
* @return The SSO login link for the user to open.
*/
QUrl ssoLoginStart(const QString &homeserverUrl);
void logout();
/**
* Create a new room.
*
* @param isPrivate Whether the room is private.
* @param name The room's name.
* @param alias The alias of the room.
* @param invite List of matrix ids of users to invite.
* @param isDirect Whether it is a direct message room.
* @param allowFederate Whether to allow users on other servers to join.
* @param topic The topic of the room.
* @param powerLevelContentOverride The content to override m.room.power_levels event.
* @param preset The preset to create the room with.
* @param encrypted Whether to enable encryption for this room.
*/
MatrixPromise *createRoom(
bool isPrivate,
const QString &name,
const QString &alias,
const QStringList &invite,
bool isDirect,
bool allowFederate,
const QString &topic,
const QJsonValue &powerLevelContentOverride,
Constants::CreateRoomPreset preset,
bool encrypted
);
/**
* Join a room.
* @param idOrAlias The id or alias of the room to join.
* @param servers The servers to use when joining the room.
*/
MatrixPromise *joinRoom(
const QString &idOrAlias,
const QStringList &servers
);
/**
* Change the trust level of a device.
*
* @param userId The user id that owns the device.
* @param deviceId The device id to set the trust level.
* @param trustLevel The trust level.
*
* @return A MatrixPromise representing the progress.
*/
MatrixPromise *setDeviceTrustLevel(QString userId, QString deviceId, QString trustLevel);
/**
* Get the profile of the current user.
*
* @return A MatrixPromise representing the progress.
*/
MatrixPromise *getSelfProfile();
/**
* Set the display name of the current user.
*
* @return A MatrixPromise representing the progress.
*/
MatrixPromise *setDisplayName(QString displayName);
/**
* Set the avatar url of the current user.
*
* @return A MatrixPromise representing the progress.
*/
MatrixPromise *setAvatarUrl(QString avatarUrl);
/**
* Check if an event should be notified.
*
* @param event The event to check.
* @return Whether `event` should be notified.
*/
bool shouldNotify(MatrixEvent *event) const;
/**
* Check if an event should be notified with sound.
*
* You should only call this method when `shouldNotify(event)`
* returns true.
*
* @param event The event to check.
* @return Whether `event` should be notified with sound.
*/
bool shouldPlaySound(MatrixEvent *event) const;
/**
* Get the sticker pack list for the current account.
*
* @return A list of sticker packs associated with the current account.
*/
MatrixStickerPackList *stickerPackList() const;
/**
* Get the sticker rooms account data event for the current account.
*
* @return A MatrixEvent representing the sticker rooms account data event.
*/
MatrixEvent *stickerRoomsEvent() const;
/**
* Update the sticker pack from source.
*
* @param source The source of the sticker pack to update.
* @return A promise that resolves when the sticker pack is updated,
* or when there is an error.
*/
MatrixPromise *updateStickerPack(MatrixStickerPackSource source);
MatrixUserGivenAttrsMap *userGivenNicknameMap() const;
MatrixPromise *sendAccountData(const QString &type, const QJsonObject &content);
/**
* Get all Matrix Spec versions supported by the server.
* Use MatrixSdk::supportSpecVersion() to check if a version is supported.
*/
MatrixPromise *getSpecVersions();
MatrixPromise *addDirectRoom(const QString &userId, const QString &roomId);
MatrixPromise *getRoomIdByAlias(const QString &roomAlias);
/**
* Purge events in all rooms except those specified in roomIds
*
* @param roomIds The ids for rooms to NOT be purged. This allows
* keeping the rooms that are already open intact.
* @return A MatrixPromise that resolves when the events are purged.
*/
MatrixPromise *purgeEventsExceptRooms(const QStringList &roomIds);
/**
* Backfill from the event storage from an existing event in a room.
*
* @param roomId The id of the room to operate on.
* @param eventId The id of the event to backfill from.
* @return A MatrixPromise that resolves when the events are backfilled.
* The Promise is considered successful if and only if at least one event
* is loaded.
*/
MatrixPromise *backfillRoomFromEvent(const QString &roomId, const QString &eventId);
+ /**
+ * Load one event from the store.
+ *
+ * @param roomId The id of the room to operate on.
+ * @param eventId The id of the event to load.
+ * @return A MatrixPromise that resolves when loading is finished.
+ * The Promise is considered successful if and only if the event is loaded.
+ */
+ MatrixPromise *loadEvent(const QString &roomId, const QString &eventId);
+
/**
* Import from key backup file.
*
* @param fileUrl The url of the key backup file. Will be passed to
* QUrl::toLocalFile(), and must be a local file.
* @param password The password to decrypt the key backup file.
* @return A MatrixPromise that resolves when the keys are imported.
* If successful, `data["imported"]` contains the number of imported keys.
* If unsuccessful, `data` contains a standard error structure.
*/
MatrixPromise *importFromKeyBackupFile(QUrl fileUrl, QString password);
/**
* Send an outbound device verification request.
*
* @param userId The user id of the device to verify.
* @param deviceId The device id of the device to verify.
*/
MatrixPromise *requestVerifyDevice(QString userId, QString deviceId);
private:
MatrixPromise *sendAccountDataImpl(Kazv::Event event);
};
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 6060f7b..93d0a08 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -1,152 +1,154 @@
set(KAZV_TEST_TEMP_DIR ${CMAKE_CURRENT_BINARY_DIR}/test-temp)
configure_file(test-temp.hpp.in test-temp.hpp)
set(kazvtestlib_SRCS
test-model.cpp
)
add_library(kazvtestlib STATIC ${kazvtestlib_SRCS})
target_include_directories(kazvtestlib PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(kazvtestlib PUBLIC kazvprivlib libkazv::kazvtestfixtures)
add_executable(matrix-sdk-session-loader matrix-sdk-session-loader.cpp matrix-sdk-sessions-test.cpp)
target_compile_definitions(matrix-sdk-session-loader PRIVATE MATRIX_SDK_SESSIONS_TEST_NO_MAIN)
target_link_libraries(matrix-sdk-session-loader Qt${QT_MAJOR_VERSION}::Test kazvtestlib)
ecm_add_tests(
qt-job-handler-test.cpp
qt-promise-handler-test.cpp
qt-json-test.cpp
device-mgmt-test.cpp
matrix-session-test.cpp
matrix-session-controller-test.cpp
matrix-sdk-sessions-test.cpp
matrix-promise-test.cpp
matrix-room-timeline-test.cpp
matrix-room-member-list-model-test.cpp
matrix-room-list-test.cpp
matrix-room-state-test.cpp
kazv-markdown-test.cpp
matrix-sticker-pack-test.cpp
matrix-link-test.cpp
matrix-user-given-attrs-map-test.cpp
kazv-abstract-list-model-test.cpp
matrix-event-list-test.cpp
matrix-event-test.cpp
kazv-file-test.cpp
db-store-test.cpp
sso-login-process-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
ecm_add_test(
kazv-io-job-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvtestlib
)
ecm_add_test(
kazv-io-manager-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvtestlib
)
add_executable(quicktest quick-test.cpp)
target_link_libraries(quicktest PRIVATE Qt${QT_MAJOR_VERSION}::QuickTest kazvtestlib)
if(KAZV_BUILD_BENCHMARKS)
ecm_add_test(
matrix-room-timeline-benchmark-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
endif()
set(quicktest_QMLS
tst_About.qml
tst_AddStickerPopup.qml
tst_AvatarImageAdapters.qml
tst_ConfirmUploadPopup.qml
tst_CreateRoomPage.qml
tst_DeviceList.qml
tst_DevicePopup.qml
tst_Device.qml
tst_EventHistoryView.qml
tst_EventReactions.qml
tst_EventReadIndicator.qml
tst_EventSourceView.qml
tst_EventView.qml
tst_EventViewRedacted.qml
tst_EventViewVideo.qml
tst_FileHandler.qml
tst_JoinRoomPage.qml
tst_KazvIOMenu.qml
tst_kazvshortcutsActionItem.qml
tst_LoginPage.qml
tst_MatrixHelpers.qml
tst_Notifications.qml
tst_PageManager.qml
tst_ReactToEventPopup.qml
tst_RoomInvitePage.qml
tst_RoomListViewItemDelegate.qml
tst_RoomMemberListPage.qml
tst_RoomNameProvider.qml
tst_RoomPage.qml
tst_RoomPinnedEventsPage.qml
tst_RoomSettingsPage.qml
tst_RoomStatePage.qml
tst_RoomStickerPackItemDelegate.qml
tst_RoomStickerPacksPage.qml
tst_RoomTimelineView.qml
tst_SendMessageBoxDrafts.qml
tst_SendMessageBox.qml
tst_StickerPackNameProvider.qml
tst_TypingIndicator.qml
tst_UserNameProvider.qml
tst_UserPage.qml
)
foreach(testfilename ${quicktest_QMLS})
message(STATUS "Adding quick test ${testfilename}")
string(REPLACE .qml "" testname ${testfilename})
add_test(NAME ${testname} COMMAND quicktest -input ${CMAKE_CURRENT_SOURCE_DIR}/quick-tests/${testfilename})
set_property(TEST ${testname}
PROPERTY LABELS "quicktest")
endforeach()
if(kazv_ENABLE_APPIUM_TESTS)
qt_add_qml_module(kazvappiumcomponentqmlmodule
STATIC
URI moe.kazv.mxc.kazvappiumcomponent
VERSION 0.0
)
kazv_add_qml_files(kazvappiumcomponentqmlmodule
BASE_DIRECTORY run-appium-component-qml
SOURCES
Main.qml
)
add_executable(run-appium-component run-appium-component.cpp)
target_link_libraries(run-appium-component
PRIVATE
kazvappiumcomponentqmlmodule
kazvappiumcomponentqmlmoduleplugin
kazvtestlib
Qt::Quick
)
set(appium_tests_SOURCES
TestStickerPicker.py
+ TestEventViewUnavailable.py
+ TestRoomTimelineView.py
)
foreach(testfilename ${appium_tests_SOURCES})
message(STATUS "Adding appium test ${testfilename}")
string(REPLACE .py "" testname ${testfilename})
add_test(NAME ${testname}
COMMAND
${SELENIUM_WEBDRIVER_AT_SPI_RUN_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/appium-tests/${testfilename}
)
set_property(TEST ${testname}
PROPERTY ENVIRONMENT "KAZV_APPIUM_TEST_BIN=$<TARGET_FILE:run-appium-component>;TEST_WITH_VIDEO_RECORDER=0")
set_property(TEST ${testname}
PROPERTY LABELS "appiumtest")
endforeach()
endif()
diff --git a/src/tests/appium-tests/TestEventViewUnavailable.py b/src/tests/appium-tests/TestEventViewUnavailable.py
new file mode 100755
index 0000000..7f31e2c
--- /dev/null
+++ b/src/tests/appium-tests/TestEventViewUnavailable.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+# This file is part of kazv.
+# SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from helpers import utils
+from appium.webdriver.common.appiumby import AppiumBy
+
+class EventViewUnavailableTest(utils.ComponentTest):
+ def test_load(self):
+ create_button = self.driver.find_element(by=AppiumBy.NAME, value='create-event-view')
+ create_button.click()
+ self.wait_for(lambda: self.get_json_for('spy-count') >= 1)
+ self.driver.find_element(by=AppiumBy.NAME, value='spy-last-arg=$abcdef')
+
+if __name__ == '__main__':
+ utils.main(__file__)
diff --git a/src/tests/appium-tests/TestEventViewUnavailable.qml b/src/tests/appium-tests/TestEventViewUnavailable.qml
new file mode 100644
index 0000000..c0b2891
--- /dev/null
+++ b/src/tests/appium-tests/TestEventViewUnavailable.qml
@@ -0,0 +1,87 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtTest
+
+import '../../contents/ui' as Kazv
+import 'qml-helpers' as AppiumQmlHelpers
+import '../quick-tests/test-helpers' as QmlHelpers
+
+QmlHelpers.TestItem {
+ id: upper
+ property var unavailableEvent: QtObject {
+ property string eventId: '$abcdef'
+ property string sender: ''
+ property string type: ''
+ property string stateKey: ''
+ property var content: {}
+ property bool isAvailable: false
+ }
+ property var sender: ({
+ membership: 'join',
+ userId: '@foo:tusooa.xyz',
+ name: 'foo',
+ avatarMxcUri: '',
+ })
+
+ signal loadEventRequested(string eventId)
+
+ SignalSpy {
+ id: loadEventRequestedSpy
+ signalName: 'loadEventRequested'
+ }
+
+ function reset() {
+ loadEventRequestedSpy.clear();
+ loadEventRequestedSpy.target = upper;
+ spyLastArgLabel.value = spyLastArgLabel.getValue('');
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ Button {
+ text: 'create-event-view'
+ onClicked: {
+ const ev = upper.eventViewComp.createObject(innerLayout);
+ }
+ }
+
+ AppiumQmlHelpers.InfoLabel {
+ tag: 'spy-count'
+ value: loadEventRequestedSpy.count
+ }
+
+ AppiumQmlHelpers.InfoLabel {
+ id: spyLastArgLabel
+ Connections {
+ target: upper
+ function onLoadEventRequested(json) {
+ spyLastArgLabel.value = spyLastArgLabel.getValue(json);
+ }
+ }
+ tag: 'spy-last-arg'
+ value: getValue('')
+
+ function getValue(v) {
+ return v;
+ }
+ }
+ ColumnLayout {
+ id: innerLayout
+ }
+ }
+
+ property var eventViewComp: Component {
+ Kazv.EventView {
+ Layout.fillWidth: true
+ event: upper.unavailableEvent
+ sender: upper.sender
+ }
+ }
+}
diff --git a/src/tests/appium-tests/TestRoomTimelineView.py b/src/tests/appium-tests/TestRoomTimelineView.py
new file mode 100755
index 0000000..f680acc
--- /dev/null
+++ b/src/tests/appium-tests/TestRoomTimelineView.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+# This file is part of kazv.
+# SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from helpers import utils
+from appium.webdriver.common.appiumby import AppiumBy
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support.relative_locator import locate_with
+import json
+
+class RoomTimelineViewTest(utils.ComponentTest):
+ def test_delete_event(self):
+ event = self.driver.find_element(by=AppiumBy.ACCESSIBILITY_ID, value='event-$1')
+ event.find_element(AppiumBy.NAME, utils.l10n('event-popup-action')).click()
+ self.driver.find_element(AppiumBy.NAME, utils.l10n('event-delete')).click()
+ self.driver.find_element(AppiumBy.NAME, utils.l10n('confirm-deletion-popup-confirm-action')).click()
+ self.driver.find_element(AppiumBy.NAME, 'redact-last-arg=$1')
+ self.driver.find_element(AppiumBy.NAME, 'make-redact-fail').click()
+ expected_notif = utils.l10n('event-delete-failed', { 'errorMsg': 'You are not allowed to do so', 'errorCode': 'M_FORBIDDEN' })
+ self.driver.find_element(AppiumBy.NAME, 'passive-notification=' + expected_notif)
+
+ def test_delete_local_echo(self):
+ event = self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, 'event-some-txn-id0')
+ event.find_element(AppiumBy.NAME, utils.l10n('event-popup-action')).click()
+ self.driver.find_element(AppiumBy.NAME, utils.l10n('event-delete')).click()
+ self.driver.find_element(AppiumBy.NAME, utils.l10n('confirm-deletion-popup-confirm-action')).click()
+ self.driver.find_element(AppiumBy.NAME, 'remove-local-echo-last-arg=some-txn-id0')
+
+if __name__ == '__main__':
+ utils.main(__file__)
diff --git a/src/tests/appium-tests/TestRoomTimelineView.qml b/src/tests/appium-tests/TestRoomTimelineView.qml
new file mode 100644
index 0000000..2236772
--- /dev/null
+++ b/src/tests/appium-tests/TestRoomTimelineView.qml
@@ -0,0 +1,117 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtTest
+
+import '../../contents/ui' as Kazv
+import 'qml-helpers' as AppiumQmlHelpers
+
+AppiumQmlHelpers.TestItem {
+ id: upper
+
+ signal loadEventRequested(string eventId)
+ property var makeLocalEcho: (i) => ({
+ eventId: '',
+ sender: '',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.text',
+ body: 'some body' + i,
+ },
+ encrypted: false,
+ isState: false,
+ unsignedData: {},
+ isLocalEcho: true,
+ isSending: true,
+ isFailed: false,
+ isAvailable: true,
+ txnId: 'some-txn-id' + i,
+ })
+
+ property var makeTextEvent: (i) => ({
+ eventId: '$' + i,
+ sender: '@foo:tusooa.xyz',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.text',
+ body: 'some body',
+ },
+ isAvailable: true,
+ formattedTime: '4:06 P.M.',
+ })
+
+ property var room: ({
+ pinEvents: mockHelper.promise(),
+ unpinEvents: mockHelper.promise(),
+ redactEvent: mockHelper.funcThenPromise((eventId) => {
+ console.log('redactEvent:', eventId);
+ redactLastArgLabel.value = eventId;
+ }),
+ removeLocalEcho: mockHelper.funcThenPromise((eventId) => {
+ console.log('rm local echo:', eventId);
+ removeLocalEchoLastArgLabel.value = eventId;
+ }),
+ messageById(id) {
+ return { eventId: id };
+ },
+ localEchoById(id) {
+ return { txnId: id };
+ }
+ })
+
+ property var timeline: ListModel {
+ ListElement {}
+ ListElement {}
+
+ function at(index) {
+ if (index < 1) {
+ return makeLocalEcho(index);
+ } else {
+ return makeTextEvent(index);
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ RowLayout {
+ AppiumQmlHelpers.InfoLabel {
+ id: redactLastArgLabel
+ tag: 'redact-last-arg'
+ value: ''
+ }
+ AppiumQmlHelpers.InfoLabel {
+ id: removeLocalEchoLastArgLabel
+ tag: 'remove-local-echo-last-arg'
+ value: ''
+ }
+ Button {
+ text: 'make-redact-fail'
+ onClicked: () => {
+ room.redactEvent.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
+ }
+ }
+ }
+
+ Kazv.RoomTimelineView {
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ id: roomTimelineView
+ room: upper.room
+ timeline: upper.timeline
+ }
+ }
+
+ function reset() {
+ redactLastArgLabel.value = '';
+ removeLocalEchoLastArgLabel.value = '';
+ }
+}
diff --git a/src/tests/appium-tests/helpers/utils.py b/src/tests/appium-tests/helpers/utils.py
index f5d95ef..de3334e 100644
--- a/src/tests/appium-tests/helpers/utils.py
+++ b/src/tests/appium-tests/helpers/utils.py
@@ -1,62 +1,69 @@
# This file is part of kazv.
# SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
# SPDX-License-Identifier: AGPL-3.0-or-later
import unittest
from appium import webdriver
from appium.options.common.base import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
import sys
import os
import json
import time
tests_dir = None
program_name = None
module_name = None
class TestUtilMixin:
def get_json_for(self, name):
elem = self.driver.find_element(by=AppiumBy.XPATH, value=f'//*[starts-with(@name, "{name}=")]')
print(elem, file=sys.stderr)
attr = elem.get_attribute('name')
print(attr, file=sys.stderr)
return json.loads(attr[len(name) + 1:])
def wait_for(self, pred, timeout_ms=10000, retry_ms=100):
steady_deadline = time.monotonic() + timeout_ms / 1000
satisfied = False
while time.monotonic() < steady_deadline:
if pred():
satisfied = True
break
time.sleep(retry_ms / 1000)
self.assertTrue(satisfied, msg=f'Predicate {pred} does not return truthy within {timeout_ms}ms')
class ComponentTest(unittest.TestCase, TestUtilMixin):
@classmethod
def setUpClass(cls):
options = AppiumOptions()
print('----program name:', program_name, file=sys.stderr)
print('----tests dir:', tests_dir, file=sys.stderr)
print('----module name:', module_name, file=sys.stderr)
options.set_capability("app", f'env {program_name} {tests_dir}/{module_name}.qml')
cls.driver = webdriver.Remote(command_executor='http://127.0.0.1:4723', options=options)
cls.driver.implicitly_wait = 10
def setUp(self):
self.driver.find_element(by=AppiumBy.NAME, value='appium-component-reset').click()
@classmethod
def tearDownClass(cls):
cls.driver.quit()
+def l10n(key, args=None):
+ if args is None:
+ stringifiedArgs = 'undefined'
+ else:
+ stringifiedArgs = json.dumps(args, separators=(',', ':'))
+ return f'{key}, {stringifiedArgs}'
+
def main(filename):
global tests_dir, program_name, module_name
tests_dir = os.path.dirname(filename)
program_name = os.environ.get(
'KAZV_APPIUM_TEST_BIN',
os.path.join(os.getcwd(), 'bin', 'run-appium-component')
)
module_name, _ext = os.path.splitext(os.path.basename(filename))
unittest.main()
diff --git a/src/tests/appium-tests/qml-helpers/TestItem.qml b/src/tests/appium-tests/qml-helpers/TestItem.qml
new file mode 100644
index 0000000..97d2359
--- /dev/null
+++ b/src/tests/appium-tests/qml-helpers/TestItem.qml
@@ -0,0 +1,30 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import '../../quick-tests/test-helpers' as QmlHelpers
+
+QmlHelpers.TestItem {
+ id: item
+ showPassiveNotification: mockHelper.func((text) => {
+ notificationLabel.value = text;
+ })
+ default property alias content_: innerLayout.data
+
+ ColumnLayout {
+ anchors.fill: parent
+ InfoLabel {
+ id: notificationLabel
+ tag: 'passive-notification'
+ value: ''
+ }
+ ColumnLayout {
+ id: innerLayout
+ }
+ }
+}
diff --git a/src/tests/matrix-event-test.cpp b/src/tests/matrix-event-test.cpp
index 80b9140..aa10e27 100644
--- a/src/tests/matrix-event-test.cpp
+++ b/src/tests/matrix-event-test.cpp
@@ -1,169 +1,189 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <kazv-defs.hpp>
#include <QtTest>
#include <QSignalSpy>
#include <lager/state.hpp>
#include <testfixtures/factory.hpp>
#include <matrix-room-timeline.hpp>
#include <matrix-sdk.hpp>
#include <matrix-room-list.hpp>
#include <matrix-room.hpp>
#include <matrix-event-list.hpp>
#include <matrix-event.hpp>
#include <matrix-event-reaction-list-model.hpp>
#include <matrix-event-reaction.hpp>
#include "test-model.hpp"
#include "test-utils.hpp"
using namespace Qt::Literals::StringLiterals;
using namespace Kazv;
using namespace Kazv::Factory;
class MatrixEventTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void testEdits();
void testEncryptedEdits();
void testReactions();
+ void testAvailable();
};
void MatrixEventTest::testEdits()
{
auto r = makeRoom(withRoomTimeline({
makeEvent(withEventId("$0")),
makeEvent(withEventId("$1") | withEventRelationship("moe.kazv.mxc.some-rel", "$0") | withEventKV("/origin_server_ts"_json_pointer, 1720751684500)),
// this one is invalid because there is no m.new_content
makeEvent(withEventId("$2") | withEventContent(json{{"body", "first"}}) | withEventRelationship("m.replace", "$1") | withEventKV("/origin_server_ts"_json_pointer, 1720751684700)),
makeEvent(withEventId("$3") | withEventContent(json{{"m.new_content", {{"body", "second"}}}}) | withEventRelationship("m.replace", "$1") | withEventKV("/origin_server_ts"_json_pointer, 1720751684800)),
makeEvent(withEventId("$4") | withEventContent(json{{"m.new_content", {{"body", "third"}}}}) | withEventRelationship("m.replace", "$1") | withEventKV("/origin_server_ts"_json_pointer, 1720751684900)),
}));
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
auto event = toUniquePtr(timeline->at(3));
QCOMPARE(event->eventId(), u"$1"_s);
auto history = toUniquePtr(event->history());
// three versions including the original one
QCOMPARE(history->count(), 3);
QCOMPARE(toUniquePtr(history->at(0))->underlyingEvent(), event->underlyingEvent());
auto v1 = toUniquePtr(history->at(1));
QCOMPARE(v1->content()[u"body"_s], QStringLiteral("second"));
QCOMPARE(v1->eventId(), QStringLiteral("$3"));
QCOMPARE(v1->underlyingEvent().originServerTs(), 1720751684800);
QCOMPARE(v1->relationType(), QStringLiteral("moe.kazv.mxc.some-rel"));
auto v2 = toUniquePtr(history->at(2));
QCOMPARE(v2->content()[u"body"_s], QStringLiteral("third"));
QCOMPARE(v2->eventId(), QStringLiteral("$4"));
QCOMPARE(v2->underlyingEvent().originServerTs(), 1720751684900);
QCOMPARE(v2->relationType(), QStringLiteral("moe.kazv.mxc.some-rel"));
}
void MatrixEventTest::testEncryptedEdits()
{
auto r = makeRoom();
auto events = EventList{
makeEvent(withEventId("$0") | withEventType("m.room.encrypted")).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.room.message"},
{"content", {{"body", "first"}}},
}, Event::Decrypted),
makeEvent(withEventId("$1") | withEventType("m.room.encrypted") | withEventRelationship("m.replace", "$0")).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.room.message"},
{"content", {{"m.new_content", {{"body", "second"}}}}},
}, Event::Decrypted),
};
withRoomTimeline(events)(r);
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
auto event = toUniquePtr(timeline->at(1));
QCOMPARE(event->eventId(), u"$0"_s);
auto history = toUniquePtr(event->history());
// three versions including the original one
QCOMPARE(history->count(), 2);
QCOMPARE(toUniquePtr(history->at(0))->underlyingEvent(), event->underlyingEvent());
auto v1 = toUniquePtr(history->at(1));
QCOMPARE(v1->sender(), QString::fromStdString(events[1].sender()));
QCOMPARE(v1->content()[u"body"_s], QStringLiteral("second"));
QCOMPARE(v1->eventId(), QStringLiteral("$1"));
QCOMPARE(v1->relationType(), QStringLiteral());
}
void MatrixEventTest::testReactions()
{
auto r = makeRoom();
auto events = EventList{
makeEvent(withEventId("$0") | withEventType("m.room.encrypted")).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.room.message"},
{"content", {{"body", "first"}}},
}, Event::Decrypted),
makeEvent(withEventId("$1")
| withEventType("m.room.encrypted")
| withEventRelationship("m.annotation", "$0")
| withEventKV("/content/m.relates_to/key"_json_pointer, "mew")
).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.reaction"},
}, Event::Decrypted),
makeEvent(withEventId("$2")
| withEventType("m.room.encrypted")
| withEventRelationship("m.annotation", "$0")
| withEventKV("/content/m.relates_to/key"_json_pointer, "mew")
).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.reaction"},
}, Event::Decrypted),
makeEvent(withEventId("$3")
| withEventType("m.room.encrypted")
| withEventRelationship("m.annotation", "$0")
| withEventKV("/content/m.relates_to/key"_json_pointer, "meww")
).setDecryptedJson(json{
{"room_id", r.roomId},
{"type", "m.reaction"},
}, Event::Decrypted),
};
withRoomTimeline(events)(r);
auto model = SdkModel{makeClient(withRoom(r))};
SessionSetup s(model);
auto roomList = toUniquePtr(s.session.roomList());
auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
auto timeline = toUniquePtr(room->timeline());
auto event = toUniquePtr(timeline->at(events.size() - 1));
QCOMPARE(event->eventId(), u"$0"_s);
auto reactions = toUniquePtr(event->reactions());
QCOMPARE(reactions->count(), 2);
auto r0 = toUniquePtr(reactions->at(0));
auto r1 = toUniquePtr(reactions->at(1));
QVERIFY(r0->count() == 1 || r1->count() == 1);
QVERIFY(r0->count() == 2 || r1->count() == 2);
QVERIFY(r0->key() == u"mew"_s || r1->key() == u"mew"_s);
QVERIFY(r0->key() == u"meww"_s || r1->key() == u"meww"_s);
}
+void MatrixEventTest::testAvailable()
+{
+ auto r = makeRoom();
+ auto events = EventList{
+ makeEvent(withEventId("$0") | withEventKV("/room_id"_json_pointer, r.roomId)),
+ makeEvent(withEventId("$1") | withEventKV("/room_id"_json_pointer, r.roomId)),
+ };
+ withRoomTimeline(events)(r);
+
+ auto model = SdkModel{makeClient(withRoom(r))};
+ SessionSetup s(model);
+ auto roomList = toUniquePtr(s.session.roomList());
+ auto room = toUniquePtr(roomList->room(QString::fromStdString(r.roomId)));
+ auto e0 = toUniquePtr(room->messageById(u"$0"_s));
+ QVERIFY(e0->isAvailable());
+ auto e2 = toUniquePtr(room->messageById(u"$2"_s));
+ QVERIFY(!e2->isAvailable());
+}
+
QTEST_MAIN(MatrixEventTest)
#include "matrix-event-test.moc"
diff --git a/src/tests/quick-tests/test-helpers/MockHelper.qml b/src/tests/quick-tests/test-helpers/MockHelper.qml
index a969484..711310f 100644
--- a/src/tests/quick-tests/test-helpers/MockHelper.qml
+++ b/src/tests/quick-tests/test-helpers/MockHelper.qml
@@ -1,80 +1,87 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.15
import '.' as TestHelpers
import '../test-helpers.js' as Helpers
/**
* Helper class for conveniently mocking functions.
*
* The mocks record the arguments and return values of the functions.
*
* - `MockHelper.func(f, t)` returns a mock of the function `f`, with
* its arguments recorded transformed by `t`.
* The transform can be either:
* - A function, which means the recorded arguments will be
* `t(originalArgs)`.
* - An array, which means the recorded arguments will be an
* object whose keys are the items of the array and the values are
* the recorded arguments.
* - For example, if the provided transform is `['foo', 'bar']` and
* the mock is called with `(1, 2, 3)`, the recorded arguments
* will be `{ foo: 1, bar: 2 }`. (Note that the additional argument
* 3 is ignored)
* - undefined, which means the arguments will be recorded as-is
* (equivalent to providing the identity function).
* - `MockHelper.promise(t)` returns a mock of a function returning
* a MatrixPromiseMock, with its arguments recorded transformed by `t`.
* - `MockHelper.noop(t)` returns a mock of a function doing nothing,
* with its arguments recorded transformed by `t`.
* - `MockHelper.clearAll()` resets the recorded information of all
* mocks *created by this MockHelper*.
*
* The mocks have the following interface:
* - `mock()` calls the function and returns the data.
* - `mock.clear()` resets the recorded information (times called,
* arguments, return values).
* - `mock.calledTimes()` returns the number of times the mock gets called.
* - `mock.lastArgs()` returns the arguments of the last call, as an array.
* - `mock.args` returns a list of arguments of each call, ordered from the
* earliest call to the last call.
* - `mock.lastRetVal()` returns the return value of the last call.
* - `mock.retVals` returns a list of return values of each call, ordered
* from the earliest call to the last call.
*/
QtObject {
id: mockHelper
property var promiseComp: Component {
TestHelpers.MatrixPromiseMock {}
}
property var mocks: []
function clearAll() {
mocks.forEach((mock) => {
mock.clear();
});
}
function func(f, t) {
const ret = Helpers.mockFunction(f, t);
mocks.push(ret);
return ret;
}
function createPromise() {
return promiseComp.createObject(mockHelper);
}
function promise(t) {
return func(createPromise, t);
}
+ function funcThenPromise(f, t) {
+ return func(function () {
+ f.apply(this, arguments);
+ return createPromise();
+ }, t);
+ }
+
function noop(t) {
return func(() => {}, t);
}
}
diff --git a/src/tests/quick-tests/tst_EventView.qml b/src/tests/quick-tests/tst_EventView.qml
index ea92a73..7b17175 100644
--- a/src/tests/quick-tests/tst_EventView.qml
+++ b/src/tests/quick-tests/tst_EventView.qml
@@ -1,702 +1,719 @@
/*
* 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 moe.kazv.mxc.kazv 0.0 as MK
import org.kde.kirigami 2.13 as Kirigami
import '../../contents/ui' as Kazv
import 'test-helpers.js' as Helpers
import 'test-helpers' as QmlHelpers
QmlHelpers.TestItem {
id: item
property var activateUserPage: mockHelper.noop()
property var paginateBackRequested: mockHelper.noop()
property var viewEventHistoryRequested: mockHelper.noop()
property var pinEventRequested: mockHelper.noop()
property var unpinEventRequested: mockHelper.noop()
property var deleteEventRequested: mockHelper.noop()
property var timeline: ({
gaps: {},
})
property var pinnedEvents: ({
count: 2,
eventIds: {
'$123': true,
'$456': true,
},
})
property var room: ({
resendMessage: mockHelper.promise(),
removeLocalEcho: mockHelper.promise(),
ensureStateEvent: mockHelper.promise(),
messageById: (_id) => item.textEvent,
member: (_id) => ({}),
})
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,
+ isSending: true,
isFailed: false,
+ isAvailable: true,
txnId: 'some-txn-id',
})
property var failedLocalEcho: Object.assign({}, item.localEcho, { isSending: false, isFailed: true, })
property var textEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var htmlEvent: ({
eventId: '',
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>',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var replyHtmlEvent: ({
eventId: '',
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>',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var replyNoQuoteHtmlEvent: ({
eventId: '',
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>',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var redactedEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {},
redacted: true,
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var imageEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.image',
body: 'some body',
info: {
h: Kirigami.Units.gridUnit * 10,
w: Kirigami.Units.gridUnit * 10,
}
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var stickerEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.sticker',
stateKey: '',
content: {
body: 'some body',
},
+ isAvailable: true,
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',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var eventTextBySomeoneElse: ({
eventId: '',
sender: '@bar:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
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',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var unknownTypeEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'moe.kazv.mxc.unknown.event',
stateKey: '',
content: {
msgtype: 'm.image',
body: 'some body',
},
+ isAvailable: true,
})
property var unknownMsgtypeEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'moe.kazv.mxc.unknown.msgtype',
body: 'some body',
},
+ isAvailable: true,
})
property var cannotDecryptEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'moe.kazv.mxc.cannot.decrypt',
body: 'some body',
},
+ isAvailable: true,
})
property var editedEvent: ({
eventId: '!some-event-id',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
isEdited: true,
})
property var pinnedEvent: ({
eventId: '$123',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var notPinnedEvent: ({
eventId: '$789',
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var sender: ({
membership: 'join',
userId: '@foo:tusooa.xyz',
name: 'foo',
avatarMxcUri: '',
})
property var senderOther: ({
membership: 'join',
userId: '@bar:tusooa.xyz',
name: 'bar',
avatarMxcUri: '',
})
property var eventViewComp: Component {
Kazv.EventView {
Layout.fillWidth: true
event: QtObject {
property var eventId: '$some-event-id'
function readers() { return 0; }
}
sender: item.sender
isGapped: true
}
}
property var eventComp: Component {
QtObject {
property var eventId: '$some-event-id'
function readers() { return 0; }
}
}
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
}
Kazv.EventView {
id: eventViewTextBySomeoneElse
event: item.eventTextBySomeoneElse
sender: item.senderOther
}
Kazv.EventView {
id: eventViewImage
event: item.imageEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewImageCompact
compactMode: true
event: item.imageEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewSticker
event: item.stickerEvent
sender: item.sender
}
Kazv.EventView {
id: eventViewStickerCompact
compactMode: true
event: item.stickerEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewEdited
event: item.editedEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewPinned
event: item.pinnedEvent
sender: item.sender
}
Kazv.EventView {
Layout.fillWidth: true
id: eventViewNotPinned
event: item.notPinnedEvent
sender: item.sender
}
}
TestCase {
id: eventViewTest
name: 'EventViewTest'
when: windowShown
function init() {
item.matrixSession.userId = '@foo:tusooa.xyz';
eventView.event = item.localEcho;
mockHelper.clearAll();
}
function test_localEcho() {
const indicator = findChild(eventView, 'localEchoIndicator');
verify(indicator);
verify(indicator.icon.name === 'state-sync');
verify(indicator.Accessible.name === l10n.get('event-sending'));
const resendButton = findChild(eventView, 'localEchoIndicator');
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.icon.name === 'state-warning');
verify(indicator.Accessible.name === l10n.get('event-resend'));
const menuComp = findChild(eventViewFailed, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const deleteAction = findChild(menu, 'deleteMenuItem');
verify(deleteAction.enabled);
deleteAction.trigger();
compare(deleteEventRequested.calledTimes(), 1);
}
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, 'eventViewMainItem');
verify(text.textFormat === TextEdit.RichText);
verify(text.text.includes('some body'));
}
function test_htmlMessageReply() {
const text = findChild(eventViewHtmlReply, 'eventViewMainItem');
verify(text.textFormat === TextEdit.RichText);
verify(!text.text.includes('<blockquote>'));
}
function test_htmlMessageReplyNoQuote() {
const text = findChild(eventViewHtmlReplyNoQuote, 'eventViewMainItem');
verify(text.textFormat === TextEdit.RichText);
verify(!text.text.includes('<blockquote>'));
verify(text.text.includes('some body'));
}
function test_menuText() {
const menuComp = findChild(eventViewText, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
verify(menu);
tryVerify(() => findChild(menu, 'replyToMenuItem').enabled);
tryVerify(() => findChild(menu, 'editMenuItem').enabled);
tryVerify(() => findChild(menu, 'deleteMenuItem').enabled);
}
function test_menuRedacted() {
const menuComp = findChild(eventViewRedacted, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
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);
}
function test_eventByOther() {
const menuComp = findChild(eventViewTextBySomeoneElse, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
verify(menu);
tryVerify(() => !findChild(menu, 'editMenuItem').enabled);
}
function test_menuSelfDestruct() {
const menuComp = findChild(eventViewTextBySomeoneElse, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
verify(menu);
menu.popup(item);
menu.dismiss();
const menu2 = menuComp.createObject(menuComp.parent);
menu2.popup(item);
menu2.dismiss();
}
function test_userLink() {
const text = findChild(eventViewText, 'textEventContent');
text.linkActivated('https://matrix.to/#/@mew:example.com');
tryVerify(() => item.room.ensureStateEvent.calledTimes());
verify(Helpers.deepEqual(
item.room.ensureStateEvent.lastArgs(),
['m.room.member', '@mew:example.com']));
item.room.ensureStateEvent.lastRetVal().resolve(true, {});
tryVerify(() => item.activateUserPage.calledTimes());
}
function test_senderNameOverrided() {
const name = findChild(eventViewText, 'userNicknameText');
compare(name.text, l10n.get(
'user-name-overrided',
{ overridedName: 'something', globalName: 'foo' }));
}
function test_senderNameNotOverrided() {
const name = findChild(eventViewTextBySomeoneElse, 'userNicknameText');
compare(name.text, 'bar');
}
function test_paginateBack() {
const obj = item.eventViewComp.createObject(item);
tryVerify(() => item.paginateBackRequested.calledTimes());
verify(item.paginateBackRequested.lastArgs()[0] === '$some-event-id');
item.paginateBackRequested.clear();
obj.event.eventId = '$some-other-event-id';
tryVerify(() => item.paginateBackRequested.calledTimes() === 1);
verify(item.paginateBackRequested.lastArgs()[0] === '$some-other-event-id');
item.paginateBackRequested.clear();
const newEvent = eventComp.createObject(item);
obj.event = newEvent;
tryVerify(() => item.paginateBackRequested.calledTimes() === 1);
verify(item.paginateBackRequested.lastArgs()[0] === '$some-event-id');
item.paginateBackRequested.clear();
obj.isGapped = false;
verify(item.paginateBackRequested.calledTimes() === 0);
}
function test_avatarClick() {
const avatar = findChild(eventViewText, 'senderAvatar');
mouseClick(avatar);
tryVerify(() => item.activateUserPage.calledTimes() === 1);
// verify that the user id is passed
verify(item.activateUserPage.lastArgs()[2] === '@foo:tusooa.xyz');
}
function test_imageEvent() {
verify(!findChild(eventViewImage, 'summaryLabel').visible);
verify(findChild(eventViewImage, 'bodyLabel').visible);
verify(findChild(eventViewImageCompact, 'summaryLabel').visible);
verify(!findChild(eventViewImageCompact, 'bodyLabel').visible);
verify(!findChild(eventViewSticker, 'summaryLabel').visible);
verify(findChild(eventViewSticker, 'bodyLabel').visible);
verify(findChild(eventViewStickerCompact, 'summaryLabel').visible);
verify(!findChild(eventViewStickerCompact, 'bodyLabel').visible);
const image = findChild(eventViewImageCompact, 'mainImage');
verify(image.height <= Kirigami.Units.gridUnit * 5);
}
function test_history() {
{
const menuComp = findChild(eventViewText, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const action = findChild(menu, 'viewHistoryMenuItem');
verify(!action.enabled);
}
{
const menuComp = findChild(eventViewEdited, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const action = findChild(menu, 'viewHistoryMenuItem');
verify(action.enabled);
action.trigger();
compare(viewEventHistoryRequested.calledTimes(), 1);
compare(viewEventHistoryRequested.lastArgs()[0], '!some-event-id');
}
}
function test_pinned() {
{
const menuComp = findChild(eventViewPinned, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const action = findChild(menu, 'pinUnpinMenuItem');
verify(action.enabled);
action.trigger();
compare(unpinEventRequested.calledTimes(), 1);
compare(unpinEventRequested.lastArgs()[0], '$123');
compare(pinEventRequested.calledTimes(), 0);
}
{
const menuComp = findChild(eventViewNotPinned, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const action = findChild(menu, 'pinUnpinMenuItem');
verify(action.enabled);
action.trigger();
compare(pinEventRequested.calledTimes(), 1);
compare(pinEventRequested.lastArgs()[0], '$789');
compare(pinEventRequested.calledTimes(), 1);
}
{
const menuComp = findChild(eventView, 'bubble').menuComp;
const menu = menuComp.createObject(menuComp.parent);
const action = findChild(menu, 'pinUnpinMenuItem');
verify(!action.enabled);
}
}
}
}
diff --git a/src/tests/quick-tests/tst_RoomTimelineView.qml b/src/tests/quick-tests/tst_RoomTimelineView.qml
index f06b4ce..dd9c88b 100644
--- a/src/tests/quick-tests/tst_RoomTimelineView.qml
+++ b/src/tests/quick-tests/tst_RoomTimelineView.qml
@@ -1,196 +1,173 @@
/*
* This file is part of kazv.
* SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window
import QtQuick.Controls
import QtTest 1.0
import moe.kazv.mxc.kazv 0.0 as MK
import org.kde.kirigami 2.13 as Kirigami
import '../../contents/ui' as Kazv
import 'test-helpers.js' as JsHelpers
import 'test-helpers' as QmlHelpers
QmlHelpers.TestItem {
id: item
+ signal loadEventRequested(string eventId)
property var makeLocalEcho: (i) => ({
eventId: '',
sender: '',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body' + i,
},
encrypted: false,
isState: false,
unsignedData: {},
isLocalEcho: true,
isSending: true,
isFailed: false,
+ isAvailable: true,
txnId: 'some-txn-id' + i,
})
property var makeTextEvent: (i) => ({
eventId: '$' + i,
sender: '@foo:tusooa.xyz',
type: 'm.room.message',
stateKey: '',
content: {
msgtype: 'm.text',
body: 'some body',
},
+ isAvailable: true,
formattedTime: '4:06 P.M.',
})
property var room: ({
pinEvents: mockHelper.promise(),
unpinEvents: mockHelper.promise(),
redactEvent: mockHelper.promise(),
removeLocalEcho: mockHelper.promise(),
messageById(id) {
- return { eventId: id };
+ return { eventId: id, isAvailable: true };
},
localEchoById(id) {
- return { txnId: id };
+ return { txnId: id, isAvailable: true };
}
})
Kazv.RoomTimelineView {
anchors.fill: parent
id: roomTimelineView
+ room: item.room
timeline: item.timeline
}
property var backfillRequested: mockHelper.noop()
property var timeline: ListModel {
ListElement {}
ListElement {}
function at(index) {
if (index < 1) {
return makeLocalEcho(index);
} else {
return makeTextEvent(index);
}
}
}
TestCase {
id: roomTimelineViewTest
name: 'RoomTimelineViewTest'
when: windowShown
function init() {
mockHelper.clearAll();
}
function test_selected() {
tryVerify(() => roomTimelineView.itemAtIndex(0));
tryVerify(() => roomTimelineView.itemAtIndex(1));
const localEcho = roomTimelineView.itemAtIndex(0);
verify(!localEcho.isSelected);
const sentMessage = roomTimelineView.itemAtIndex(1);
verify(!sentMessage.isSelected);
roomTimelineView.selectedEventId = '$1';
verify(sentMessage.isSelected);
}
function test_pinSuccess() {
roomTimelineView.pinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'pinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.pinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.pinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'pinEventPopup'));
room.pinEvents.lastRetVal().resolve(true, {});
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-pin-success-prompt'));
}
function test_pinFailure() {
roomTimelineView.pinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'pinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.pinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.pinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'pinEventPopup'));
room.pinEvents.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-pin-failed-prompt', { errorCode: 'M_FORBIDDEN', errorMsg: 'You are not allowed to do so' }));
}
function test_unpinSuccess() {
roomTimelineView.unpinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'unpinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.unpinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.unpinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'unpinEventPopup'));
room.unpinEvents.lastRetVal().resolve(true, {});
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-unpin-success-prompt'));
}
function test_unpinFailure() {
roomTimelineView.unpinEventRequested('$2');
const popup = findChild(Overlay.overlay, 'unpinEventPopup');
verify(popup);
tryVerify(() => popup.opened);
mouseClick(findChild(popup, 'confirmButton'));
verify(room.unpinEvents.calledTimes() === 1);
verify(JsHelpers.deepEqual(room.unpinEvents.lastArgs()[0], ['$2']));
tryVerify(() => !popup.opened);
tryVerify(() => !findChild(Overlay.overlay, 'unpinEventPopup'));
room.unpinEvents.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
compare(showPassiveNotification.calledTimes(), 1);
compare(showPassiveNotification.lastArgs()[0], l10n.get('event-unpin-failed-prompt', { errorCode: 'M_FORBIDDEN', errorMsg: 'You are not allowed to do so' }));
}
-
- function test_deleteEvent() {
- roomTimelineView.deleteEventRequested("some-event-id", false);
- const popup = findChild(Overlay.overlay, 'confirmDeletionPopup');
- verify(popup);
- tryVerify(() => popup.opened);
- mouseClick(findChild(popup, 'confirmButton'));
- verify(room.redactEvent.calledTimes() === 1);
- verify(room.redactEvent.lastArgs()[0], ['some-eventId']);
- tryVerify(() => !popup.opened);
- tryVerify(() => !findChild(Overlay.overlay, 'confirmDeletionPopup'));
- room.redactEvent.lastRetVal().resolve(false, { errorCode: 'M_FORBIDDEN', error: 'You are not allowed to do so' });
- compare(showPassiveNotification.calledTimes(), 1);
- compare(showPassiveNotification.lastArgs()[0], l10n.get('event-delete-failed', { errorMsg: 'You are not allowed to do so', errorCode: 'M_FORBIDDEN' }));
- }
-
- function test_deleteLocalEcho() {
- roomTimelineView.deleteEventRequested("some-txnId", true);
- const popup = findChild(Overlay.overlay, 'confirmDeletionPopup');
- verify(popup);
- tryVerify(() => popup.opened);
- mouseClick(findChild(popup, 'confirmButton'));
- verify(room.removeLocalEcho.calledTimes() === 1);
- verify(room.removeLocalEcho.lastArgs()[0], ['some-txnId']);
- tryVerify(() => !popup.opened);
- tryVerify(() => !findChild(Overlay.overlay, 'confirmDeletionPopup'));
- }
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Sep 19, 7:23 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1762657
Default Alt Text
(167 KB)

Event Timeline