Compare commits

..

58 Commits

Author SHA1 Message Date
Joshua Goins
9f3c542d04 List relevant side effects of leaving a room
Leaving a room is a big deal, yet we don't really inform the user all
that well. It's easy to leave a room that you have to be invited back
into, or lose access to history. If you're the only admin or room
creator, this becomes even more complex.
2026-02-14 13:57:56 -05:00
Joshua Goins
4e616d53b2 Fix the emoticon editor page
It wasn't possible to edit the shortcode or description anymore, because
signals weren't hooked up. I also added a separator to help separate
some of these controls visually.
2026-02-14 12:23:31 -05:00
Joshua Goins
57b6dbcbde Don't open room-specific profile in account menu
This should be your main profile, since visually and functionally this
is "outside" the current room.

Since the workaround is a bit estoeric, I added a comment so I remember
why I did this later.
2026-02-14 12:23:20 -05:00
Joshua Goins
49646c63f8 Improve read marker delegate, add button to mark as read
I added an icon to the read marker (to help distinguish it from
text-heavy chat rooms), and fixed up the padding. I also find myself
reaching to right-click rooms often to mark them as read, so why not do
this from the read marker itself?
2026-02-14 12:23:10 -05:00
Joshua Goins
9d2a427619 Improve the search pages, especially in error and searching states
Someone hit a nasty bug while attempting to find a KDE room on the
kde.org server, the error wouldn't come up normally and the dialog would
be blank. That's because that specific placeholder message doesn't
appear until you type something into the search field, which is weird.

There is a few other oddities with SearchPage that I squashed, including
showing the loading placeholder in more appropiate situations.
2026-02-14 11:04:29 -05:00
Tobias Fella
593ad27e8c Implement sending voice messages 2026-02-14 11:04:22 -05:00
Joshua Goins
3d07f723c8 Make member list sorted a bit more efficient
I don't have any hard numbers on what difference this makes, but it's
definitely a positive improvement. I noticed and fixed a few issues that
were made more glaring by recent changes in libQuotient:

1. Room::memberJoined is called during the historical loading or
whatever, when we only need that *after* stuff is settled.
2. We really don't need to sort the room's members immediately - it's
only relevant when UserListModel is used (and I think this was previous
behavior?) So now its done lazily.
3. We do not want to call Room::effectivePowerLevel willy-nilly. It may
become a more expensive lookup, and it's also varying levels of wasteful
depending on which sorting algorithm the STL uses. It doesn't cost much
for us to keep a temporary cache for the lambda function to use.
2026-02-14 09:55:05 -05:00
Joshua Goins
d47a4eb0de Fix weird scrolling behavior in room list
You may have noticed that the room list acts a bit... odd. You usually
can't scroll all the way down with a scrollwheel, it just
stops. It's possible to continue a little bit more with the scrollbar.
And sometimes the scrollbar doesn't know how big it's actually supposed
to be, commonly occuring when switching from a large room list to a
smaller one.

I narrowed it down the same usual problem with views in QtQuick:
variable sized delegates! Using GammaRay I figured out that for the
delegates currently in use they're slightly different: 46 pixels for
regular room delegates, 42 pixels for section headers and the "Find your
Friends" button was also different.

*Technically* TableView (and by extension TreeView) is supposed to allow
variable-sized delegates, and we should be able to advertise that with
rowHeightProvider. I tried a bunch of different solutions and none of
them worked reliably, so I took the usual sledgehammer approach of
making all of the delegates the same size.

This fixes all of the obvious bugs with the room list I could see, with
the one visual downside of making the section headers slightly taller.
But since I spent some time improving the tap targets, this is only a
visual change and not a functional one.

I also made sure to test it in compact mode, and everything shrinks as
expected.
2026-02-14 09:54:42 -05:00
l10n daemon script
11f8407ef7 GIT_SILENT Sync po/docbooks with svn 2026-02-14 01:52:45 +00:00
Joshua Goins
e4c9230c09 Re-settle the timeline view when replying to messages
Or changing the height of the chatbar in other ways, which I'm sure is
going to become more common with our new rich text system.
2026-02-13 19:58:40 -05:00
Joshua Goins
318432d561 Fix undefined reference in Bubble
This tends to spam the logs while loading/unloading timelines.
2026-02-13 19:58:19 -05:00
Joshua Goins
afa699381b Change loading delegate to animated progress bar
Gives a nicer visual indication instead of static text, in my opinion.
2026-02-13 19:58:19 -05:00
l10n daemon script
244ef77c4a GIT_SILENT Sync po/docbooks with svn 2026-02-13 01:47:10 +00:00
Joshua Goins
847db41fb3 Hide rooms with custom defined types in quick switcher
This matches the behavior in other room lists. I also tried to normalize
the constructor with SortFilterRoomTreeModel.
2026-02-12 16:33:22 -05:00
Joshua Goins
115d4e7466 Reduce the tap target of RoomTreeSection
For some reason I don't understand, the ItemDelegate used for these
sections are ginormous. There is quite a bit of padding which confuses
users as its unexpectedly used for the tap area.

I changed it so we only listen for taps inside of the contentItem
itself, which is a more suitable area.
2026-02-12 07:29:45 -05:00
Joshua Goins
346d311909 Replace 👀 with ❤️ in the quick reactions bar
I personally use this emoji more than the other, and have seen it more
in the wild too.
2026-02-12 07:29:33 -05:00
Joshua Goins
cff27ca7db Add user ID to the account editor page
For some reason this was never featured here, and it's always useful to
have another place to check and/or copy this ID.
2026-02-12 07:29:20 -05:00
Joshua Goins
35daae6b5d Replace "Show QR Code" in account menu with "Open Profile" action
When you just want to share or view your own profile, the UX has proven
to be a bit confusing. You could try to scroll to find a previous
message of yours, or hopelessly go down the rabbithole of settings (none
of which provide a copyable user ID currently.) Lets cut down on the
slack by providing a way to instantly open your profile from anywhere.

This replaces the "Show QR Code" action because this is duplicative
within the profile itself.
2026-02-12 07:29:10 -05:00
l10n daemon script
94fb429c47 GIT_SILENT Sync po/docbooks with svn 2026-02-12 01:47:38 +00:00
Joshua Goins
9f4146e5b1 Fix the emoticon editor looking visually broken
We need to set a minimum width/height here since the Image isn't
technically loaded, when creating a new one.
2026-02-11 18:52:21 -05:00
Darshan Phaldesai
a9b4a900c9 Move notification button to UserInfo
Move notifications button to a more appropriate position next to username. It used to live in the room/spaces bar, which doesn't make sense context wise. Also the mobile view moves to a bottom navbar anyway.

This is just a copy-paste of the adjacent settings tool button with appropriate icon and callback.
2026-02-11 08:56:04 -05:00
Joshua Goins
18d7d2f736 Add a way to open settings on mobile again
This is visible on desktop, but wasn't accessible on mobile.
2026-02-11 08:55:08 -05:00
Joshua Goins
be65d506c4 Fix the mobile title sometimes disappearing
I was depending on an implicit connection property from somewhere.
2026-02-11 08:55:08 -05:00
Azhar Momin
f5d726989f Add support for copying & deleting multiple messages at once
BUG: 496458
2026-02-11 08:00:09 -05:00
l10n daemon script
0f634ff795 GIT_SILENT Sync po/docbooks with svn 2026-02-11 01:47:49 +00:00
Darshan Phaldesai
1289194b3f Messages: Make the Date/Timestamps more usable
Previously timestamps were in the right-hand side of the messages which made it very hard to relate timestamps with their corresponding messages. 
Moving them right next to the name makes much better UX wise (and surprisingly didn't make the UI too crowded). I have tested this in dark light and bubbles mode, and it all looks good and comfortable to me.

I have also tweaked how the timestamps are formatted. 
- For messages on the same day, it will skip the date part.
- For recent days, it uses relative timestamp (yesterday, XX:XX) 
- For everything before its shows short form date and time

The tooltip now uses Long Format of Date and Time.
2026-02-10 19:32:36 -05:00
Tobias Fella
8ca1b8b1d3 Adapt to libquotient api changes 2026-02-10 12:48:50 +00:00
Joshua Goins
793f81e733 Focus code and location chooser maximize components too
I did this for images a while back, but not for these. Otherwise you
can't press escape or perform other key navigation functions easily.

BUG: 515462
FIXED-IN: 25.12.2
2026-02-09 21:32:24 -05:00
Tobias Fella
4c31f42144 Fix type name for NeoChatDateTime
QML doesn't want it to start with an uppercase letter
2026-02-09 21:22:23 -05:00
Azhar Momin
6b664b0547 Fix context menu not appearing in room media scroll view
MessageDelegate calls model.findEvent(root.eventId), which existed in
MessageFilterModel but was missing in MediaMessageFilterModel. This adds
a findEvent() implementation to MediaMessageFilterModel so the context
menu works correctly in the media scroll view.
2026-02-09 21:09:37 -05:00
l10n daemon script
44f78353ab GIT_SILENT Sync po/docbooks with svn 2026-02-10 01:45:39 +00:00
l10n daemon script
3b2df95df7 GIT_SILENT Sync po/docbooks with svn 2026-02-09 01:43:37 +00:00
James Graham
8edb248647 Fix use after free in message delegate. We can't delete the incubator in the completed callback because it then returns to the incubator we just deleted. 2026-02-08 18:21:12 +00:00
l10n daemon script
07bc06f4ff GIT_SILENT Sync po/docbooks with svn 2026-02-08 01:44:05 +00:00
l10n daemon script
0d9988013b GIT_SILENT Sync po/docbooks with svn 2026-02-07 01:44:26 +00:00
Tobias Fella
c1720bbaa7 Fix various qml warnings 2026-02-06 14:45:03 +01:00
Tobias Fella
692ce82717 Fix minor warnings 2026-02-06 14:19:08 +01:00
Tobias Fella
ac04fa6a13 Fixes 2026-02-06 14:18:54 +01:00
Tobias Fella
39aaced0f9 Fix unqualified access 2026-02-06 14:17:55 +01:00
Tobias Fella
9887665560 Prevent shadowing 2026-02-06 14:17:20 +01:00
Tobias Fella
c590cb76a0 Use Application.layoutDirection 2026-02-06 14:14:31 +01:00
Tobias Fella
f9b0c56fa0 Prevent shadowing 2026-02-06 14:12:55 +01:00
Tobias Fella
aa7ab6b2ec Fix minor warnings 2026-02-06 14:12:24 +01:00
Tobias Fella
ac6a5663a1 Use required properties 2026-02-06 14:11:23 +01:00
Tobias Fella
f09dc79995 Remove unused import 2026-02-06 14:09:34 +01:00
Tobias Fella
693b2e74fe SupportDialog: Set ComponentBehavior 2026-02-06 14:07:08 +01:00
Tobias Fella
3a54d58516 Fix pageStack warnings 2026-02-06 14:05:23 +01:00
Tobias Fella
6abb117989 Set componentBehavior 2026-02-06 14:03:47 +01:00
Tobias Fella
b2dee6a96b Fix unqualified access 2026-02-06 14:02:41 +01:00
Tobias Fella
1e1ba1dca3 Cast qml object where necessary 2026-02-06 13:58:55 +01:00
Tobias Fella
55676b06c2 QrScannerPage: Prevent shadowing 2026-02-06 13:57:20 +01:00
Tobias Fella
513496cf85 Add translation context 2026-02-06 13:56:26 +01:00
Tobias Fella
69215401dd Use let instead of var 2026-02-06 13:55:45 +01:00
Tobias Fella
d5ec37e1af Fix some unqualified access warnings 2026-02-06 13:53:20 +01:00
Tobias Fella
6a45e2532c Prevent more shadowing 2026-02-06 13:50:40 +01:00
Tobias Fella
322926e31c Remove invalid anchors 2026-02-06 13:49:44 +01:00
Tobias Fella
29592a7f92 Prevent shadowing 2026-02-06 13:48:54 +01:00
Tobias Fella
8fc8baa2d2 Fix a few minor qml warnings 2026-02-06 12:53:07 +01:00
129 changed files with 16140 additions and 12272 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,17 +24,9 @@ KirigamiComponents.ConvergentContextMenu {
}
Kirigami.Action {
text: i18nc("@action:button", "Show QR Code")
icon.name: "view-barcode-qr-symbolic"
onTriggered: {
(Qt.createComponent('org.kde.neochat', 'QrCodeMaximizeComponent').createObject(QQC2.Overlay.overlay, {
text: "https://matrix.to/#/" + root.connection.localUser.id,
title: root.connection.localUser.displayName,
subtitle: root.connection.localUser.id,
// Note: User::avatarUrl does not set user_id, and thus cannot be used directly here. Hence the makeMediaUrl.
avatarSource: root.connection.localUser.avatarUrl.toString().length > 0 ? root.connection.makeMediaUrl(root.connection.localUser.avatarUrl) : ""
}) as QrCodeMaximizeComponent).open();
}
text: i18nc("@action:button", "Open Profile")
icon.name: "im-user-symbolic"
onTriggered: RoomManager.resolveResource(root.connection.localUserId, "qr") // Use "qr" action to make sure a room isn't passed, see RoomManager::visitUser
}
Kirigami.Action {
@@ -97,9 +89,9 @@ KirigamiComponents.ConvergentContextMenu {
text: i18nc("@action:inmenu Open support dialog", "Support")
icon.name: "help-contents-symbolic"
onTriggered: {
Qt.createComponent("org.kde.neochat", "SupportDialog").createObject(QQC2.Overlay.overlay, {
(Qt.createComponent("org.kde.neochat", "SupportDialog").createObject(QQC2.Overlay.overlay, {
connection: root.connection,
}).open();
}) as SupportDialog).open();
}
}

View File

@@ -4,9 +4,6 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import org.kde.kirigami as Kirigami
import org.kde.neochat

View File

@@ -20,9 +20,9 @@ Components.AbstractMaximizeComponent {
property NeochatRoomMember author
/**
* @brief The timestamp of the event as a NeoChatDateTime.
* @brief The timestamp of the event as a neoChatDateTime.
*/
required property NeoChatDateTime dateTime
required property neoChatDateTime dateTime
/**
* @brief The code text to show.
@@ -79,7 +79,7 @@ Components.AbstractMaximizeComponent {
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
QQC2.TextArea {
id: codeText
id: codeTextEdit
topPadding: Kirigami.Units.smallSpacing
bottomPadding: Kirigami.Units.smallSpacing
leftPadding: lineNumberColumn.width + lineNumberColumn.anchors.leftMargin + Kirigami.Units.smallSpacing * 2
@@ -100,15 +100,15 @@ Components.AbstractMaximizeComponent {
SyntaxHighlighter {
property string definitionName: Repository.definitionForName(root.language).name
textEdit: definitionName == "None" ? null : codeText
textEdit: definitionName == "None" ? null : codeTextEdit
definition: definitionName
}
ColumnLayout {
id: lineNumberColumn
anchors {
top: codeText.top
topMargin: codeText.topPadding + 1
left: codeText.left
top: codeTextEdit.top
topMargin: codeTextEdit.topPadding + 1
left: codeTextEdit.left
leftMargin: Kirigami.Units.smallSpacing
}
spacing: 0
@@ -116,7 +116,7 @@ Components.AbstractMaximizeComponent {
id: repeater
model: LineModel {
id: lineModel
Component.onCompleted: setDocument(codeText.textDocument)
Component.onCompleted: setDocument(codeTextEdit.textDocument)
}
delegate: QQC2.Label {
id: label
@@ -150,4 +150,6 @@ Components.AbstractMaximizeComponent {
color: Kirigami.Theme.backgroundColor
}
}
onOpened: forceActiveFocus()
}

View File

@@ -3,8 +3,6 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQml.Models
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.labs.components as KirigamiComponents

View File

@@ -8,13 +8,31 @@ import org.kde.kirigami as Kirigami
import org.kde.neochat
import Quotient
Kirigami.PromptDialog {
id: root
required property NeoChatRoom room
title: root.room.isSpace ? i18nc("@title:dialog", "Confirm Leaving Space") : i18nc("@title:dialog", "Confirm Leaving Room")
subtitle: root.room ? i18nc("Do you really want to leave <room name>?", "Do you really want to leave %1?", root.room.displayNameForHtml) : ""
subtitle: {
if (root.room) {
let message = xi18nc("@info Do you really want to leave <room name>?", "Do you really want to leave %1?", root.room.displayNameForHtml)
// List any possible side-effects the user needs to be made aware of.
if (root.room.historyVisibility !== "world_readable" && root.room.historyVisibility !== "shared") {
message += xi18nc("@info", "<br><strong>This room's history is limited to when you rejoin the room.</strong>")
}
if (root.room.joinRule === JoinRule.JoinRule.Invite) {
message += xi18nc("@info", "<br><strong>This room can only be rejoined with an invite.</strong>");
}
return message;
}
return "";
}
dialogType: Kirigami.PromptDialog.Warning
onRejected: {
@@ -28,7 +46,7 @@ Kirigami.PromptDialog {
text: i18nc("@action:button", "Leave Room")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
icon.name: "arrow-left-symbolic"
onClicked: root.room.forget();
//onClicked: root.room.forget();
}
}
}

View File

@@ -41,13 +41,11 @@ ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
QQC2.Button {
anchors.bottom: parent.bottom
text: i18n("They match")
icon.name: "dialog-ok"
onClicked: root.accept()
}
QQC2.Button {
anchors.bottom: parent.bottom
text: i18n("They don't match")
icon.name: "dialog-cancel"
onClicked: root.reject()

View File

@@ -9,8 +9,6 @@ import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as Components
import org.kde.neochat
Delegates.RoundedItemDelegate {
id: root

View File

@@ -166,7 +166,7 @@ ColumnLayout {
}
RowLayout {
visible: root.currentRoom.connection.canCheckMutualRooms
visible: (root.currentRoom.connection as NeoChatConnection).canCheckMutualRooms
spacing: 0
Layout.topMargin: Kirigami.Units.largeSpacing * 2

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
@@ -8,7 +10,6 @@ import QtQuick.Window
import QtQml
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat
Kirigami.Page {

View File

@@ -2,8 +2,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtLocation
import QtPositioning
@@ -45,6 +43,8 @@ Components.AbstractMaximizeComponent {
}
]
onOpened: forceActiveFocus()
PositionSource {
id: positionSource

View File

@@ -100,7 +100,7 @@ Kirigami.ApplicationWindow {
function onCurrentRoomChanged() {
if (RoomManager.currentRoom && root.pageStack.depth <= 1 && root.initialized && Kirigami.Settings.isMobile) {
let roomPage = pageStack.push(Qt.createComponent('org.kde.neochat', 'RoomPage'));
let roomPage = root.pageStack.push(Qt.createComponent('org.kde.neochat', 'RoomPage'));
roomPage.forceActiveFocus();
roomPage.backRequested.connect(event => {
RoomManager.clearCurrentRoom();
@@ -151,8 +151,6 @@ Kirigami.ApplicationWindow {
}
contextDrawer: RoomDrawer {
id: contextDrawer
// This is a memory for all user initiated actions on the drawer, i.e. clicking the button
// It is used to ensure that user choice is remembered when changing pages and expanding and contracting the window width
property bool drawerUserState: NeoChatConfig.autoRoomInfoDrawer
@@ -178,9 +176,9 @@ Kirigami.ApplicationWindow {
// Connect to the onClicked function of the RoomDrawer handle button
Connections {
target: contextDrawer.handle.children[0]
target: root.contextDrawer.handle.children[0]
function onClicked() {
contextDrawer.drawerUserState = contextDrawer.drawerOpen;
root.contextDrawer.drawerUserState = root.contextDrawer.drawerOpen;
}
}

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.kirigami as Kirigami
@@ -16,7 +18,7 @@ Kirigami.PromptDialog {
customFooterActions: Kirigami.Action {
icon.name: "camera-video-symbolic"
text: hasExistingMeeting ? i18nc("@action:button Join the Jitsi meeting", "Join") : i18nc("@action:button Start a new Jitsi meeting", "Start")
text: root.hasExistingMeeting ? i18nc("@action:button Join the Jitsi meeting", "Join") : i18nc("@action:button Start a new Jitsi meeting", "Start")
onTriggered: root.accept()
}
}

View File

@@ -66,7 +66,7 @@ Kirigami.Dialog {
id: optionModel
readonly property bool allValuesSet: {
for( var i = 0; i < optionModel.rowCount(); i++ ) {
for (let i = 0; i < optionModel.rowCount(); i++) {
if (optionModel.get(i).optionText.length <= 0) {
return false;
}
@@ -83,7 +83,7 @@ Kirigami.Dialog {
function values() {
let textValues = []
for( var i = 0; i < optionModel.rowCount(); i++ ) {
for(let i = 0; i < optionModel.rowCount(); i++) {
textValues.push(optionModel.get(i).optionText);
}
return textValues;

View File

@@ -18,7 +18,7 @@ Kirigami.Page {
required property NeoChatConnection connection
padding: 0
Component.onCompleted: camera.start()
Component.onCompleted: session.camera.start()
Connections {
target: root.QQC2.ApplicationWindow.window
@@ -66,12 +66,8 @@ Kirigami.Page {
CaptureSession {
id: session
camera: Camera {
id: camera
}
imageCapture: ImageCapture {
id: imageCapture
}
camera: Camera {}
imageCapture: ImageCapture {}
videoOutput: viewFinder
}
}

View File

@@ -30,7 +30,7 @@ Kirigami.Page {
type: Kirigami.MessageType.Information
position: Kirigami.InlineMessage.Position.Header
text: xi18n("This report will <strong>only</strong> be sent to the administrators of <link>%1</link> (your server).", root.connection.domain)
text: xi18nc("@info", "This report will <strong>only</strong> be sent to the administrators of <link>%1</link> (your server).", root.connection.domain)
}
QQC2.TextArea {

View File

@@ -80,6 +80,12 @@ Kirigami.Page {
onHeightChanged: {
// HACK: See TimelineView for the hack details.
// We get the height change here *first* so we are informed this is because of a window resize and not due to the pinned message.
resetViewSettling();
}
// Resets the view settling of the timeline.
// This should be called whenever the apparent height of the timeline changes, or else the view will scroll on its own!
function resetViewSettling(): void {
(timelineViewLoader.item as TimelineView).resetViewSettling();
}
@@ -104,8 +110,8 @@ Kirigami.Page {
enabled: hasExistingMeeting || canStartNewMeeting
visible: root.currentRoom && !root.currentRoom.isSpace
onTriggered: {
const dialog = Qt.createComponent("org.kde.neochat", "MeetingDialog").createObject(QQC2.Overlay.overlay, { hasExistingMeeting });
dialog.onAccepted.connect(doAction);
const dialog = Qt.createComponent("org.kde.neochat", "MeetingDialog").createObject(QQC2.Overlay.overlay, { hasExistingMeeting }) as MeetingDialog;
dialog.accepted.connect(doAction);
dialog.open();
}
@@ -215,7 +221,7 @@ Kirigami.Page {
}
TapHandler {
onTapped: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'RoomPinnedMessagesPage'), {
onTapped: (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'RoomPinnedMessagesPage'), {
room: root.currentRoom
}, {
title: i18nc("@title", "Pinned Messages")
@@ -229,6 +235,58 @@ Kirigami.Page {
Layout.fillWidth: true
}
Kirigami.InlineMessage {
id: selectedMessagesControl
Layout.fillWidth: true
showCloseButton: false
visible: root.currentRoom?.selectedMessageCount > 0
position: Kirigami.InlineMessage.Position.Header
type: Kirigami.MessageType.Positive
icon.name: "edit-select-all-symbolic"
text: i18nc("@info", "Selected Messages: %1", root.currentRoom?.selectedMessageCount)
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Copy Conversation")
icon.name: "edit-copy"
onTriggered: {
Clipboard.saveText(root.currentRoom.getFormattedSelectedMessages())
showPassiveNotification(i18nc("@info", "Conversation copied to clipboard"));
}
},
Kirigami.Action {
text: i18nc("@action:button", "Delete Messages")
icon.name: "trash-empty-symbolic"
icon.color: Kirigami.Theme.negativeTextColor
enabled: root.currentRoom?.canDeleteSelectedMessages
onTriggered: {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Remove Messages"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for removing these messages"),
actionText: i18nc("@action:button 'Remove' as in 'Remove these messages'", "Remove"),
icon: "delete",
reporting: false,
connection: root.currentRoom.connection,
}, {
title: i18nc("@title:dialog", "Remove Messages"),
width: Kirigami.Units.gridUnit * 25
}) as ReasonDialog;
dialog.accepted.connect(reason => {
root.currentRoom.deleteSelectedMessages(reason);
});
}
},
Kirigami.Action {
icon.name: "dialog-close"
icon.color: Kirigami.Theme.negativeTextColor
onTriggered: root.currentRoom.clearSelectedMessages()
}
]
}
Kirigami.InlineMessage {
id: banner
@@ -304,6 +362,9 @@ Kirigami.Page {
width: parent.width
currentRoom: root.currentRoom
connection: root.currentRoom.connection as NeoChatConnection
// Creating a reply (or doing anything in the chat bar) can change the height, but this isn't picked up on the root's onHeightChanged.
onHeightChanged: root.resetViewSettling()
}
}

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2026 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts

View File

@@ -8,7 +8,6 @@ import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.prison
import org.kde.neochat

View File

@@ -604,6 +604,7 @@ QString RoomManager::findSpaceIdForCurrentRoom() const
void RoomManager::setCurrentRoom(const QString &roomId)
{
if (m_currentRoom != nullptr) {
m_currentRoom->clearSelectedMessages();
m_currentRoom->disconnect(this);
}

View File

@@ -16,4 +16,5 @@ ecm_add_qml_module(Chatbar GENERATE_PLUGIN_SOURCE
EmojiDialog.qml
EmojiTonesPicker.qml
ImageEditorPage.qml
VoiceMessageDialog.qml
)

View File

@@ -150,6 +150,19 @@ QQC2.Control {
}
tooltip: text
},
BusyAction {
icon.name: "microphone"
isBusy: false
text: i18nc("@action:button", "Send a Voice Message")
displayHint: QQC2.AbstractButton.IconOnly
onTriggered: {
let dialog = voiceMessageDialog.createObject(root, {
room: root.currentRoom
}) as VoiceMessageDialog;
dialog.open();
}
tooltip: text
},
BusyAction {
id: sendAction
@@ -548,6 +561,11 @@ QQC2.Control {
NewPollDialog {}
}
Component {
id: voiceMessageDialog
VoiceMessageDialog {}
}
CompletionMenu {
id: completionMenu
chatDocumentHandler: documentHandler

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtMultimedia
import org.kde.kirigami as Kirigami
import org.kde.coreaddons
import org.kde.neochat
QQC2.Dialog {
id: root
required property NeoChatRoom room
VoiceRecorder {
id: voiceRecorder
readonly property bool recording: recorder.recorderState == MediaRecorder.RecordingState
room: root.room
}
width: Kirigami.Units.gridUnit * 24
standardButtons: QQC2.DialogButtonBox.Cancel
title: i18nc("@title:dialog", "Record Voice Message")
contentItem: ColumnLayout {
QQC2.RoundButton {
icon.name: voiceRecorder.recording ? "media-playback-stop" : "media-record"
text: voiceRecorder.recording ? i18nc("@action:button Stop audio recording", "Stop Recording") : i18nc("@action:button Start audio recording", "Start Recording")
Layout.preferredHeight: Kirigami.Units.gridUnit * 4
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
Layout.alignment: Qt.AlignHCenter
display: QQC2.RoundButton.IconOnly
enabled: voiceRecorder.isSupported
onClicked: voiceRecorder.recording ? voiceRecorder.stopRecording() : voiceRecorder.startRecording()
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
QQC2.Label {
text: i18nc("@info Duration being the length of an audio recording", "Duration: %1", Format.formatDuration(voiceRecorder.recorder.duration))
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
text: i18nc("@info", "Voice message recording requires a newer Qt version than is currently installed on this system.")
visible: !voiceRecorder.isSupported
}
}
footer: QQC2.DialogButtonBox {
QQC2.Button {
text: i18nc("@action:button Send the voice message", "Send")
icon.name: "document-send"
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
onClicked: voiceRecorder.send()
enabled: !voiceRecorder.recording && voiceRecorder.recorder.duration > 0 && voiceRecorder.isSupported
}
}
}

View File

@@ -40,9 +40,11 @@ ColumnLayout {
model: root.connection.getSupportedRoomVersions()
delegate: FormCard.FormTextDelegate {
id: versionDelegate
required property var modelData
text: modelData.id
contentItem.children: QQC2.Label {
text: modelData.status
text: versionDelegate.modelData.status
color: Kirigami.Theme.disabledTextColor
}
}

View File

@@ -23,6 +23,7 @@ target_sources(LibNeoChat PRIVATE
texthandler.cpp
urlhelper.cpp
utils.cpp
voicerecorder.cpp
enums/chatbartype.h
enums/messagecomponenttype.h
enums/messagetype.h

View File

@@ -276,6 +276,11 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const
if (role == NotificationCountRole) {
return room->notificationCount();
}
if (role == RoomTypeRole) {
if (room->creation()) {
return room->creation()->contentPart<QString>("type"_L1);
}
}
return QVariant();
}
@@ -310,6 +315,7 @@ QHash<int, QByteArray> RoomListModel::roleNames() const
roles[IsChildSpaceRole] = "isChildSpace";
roles[IsDirectChat] = "isDirectChat";
roles[NotificationCountRole] = "notificationCount";
roles[RoomTypeRole] = "roomType";
return roles;
}

View File

@@ -54,6 +54,7 @@ public:
ReplacementIdRole, /**< The room id of the room replacing this one, if any. */
IsDirectChat, /**< Whether this room is a direct chat. */
NotificationCountRole, /**< Count of all notifications that also include non-notable events like unread messages. */
RoomTypeRole, /**< The room's type. */
};
Q_ENUM(EventRoles)

View File

@@ -172,6 +172,10 @@ void UserListModel::refreshAllMembers()
{
beginResetModel();
if (m_currentRoom != nullptr) {
// Only sort members when this model needs to be refreshed.
if (m_currentRoom->sortedMemberIds().isEmpty()) {
m_currentRoom->sortAllMembers();
}
m_members = m_currentRoom->sortedMemberIds();
} else {
m_members.clear();

View File

@@ -27,6 +27,11 @@ QString NeoChatDateTime::shortDateTime() const
return QLocale().toString(m_dateTime.toLocalTime(), QLocale::ShortFormat);
}
QString NeoChatDateTime::longDateTime() const
{
return QLocale().toString(m_dateTime.toLocalTime(), QLocale::LongFormat);
}
QString NeoChatDateTime::relativeDate() const
{
KFormat formatter;
@@ -39,6 +44,14 @@ QString NeoChatDateTime::relativeDateTime() const
return formatter.formatRelativeDateTime(m_dateTime.toLocalTime(), QLocale::ShortFormat);
}
QString NeoChatDateTime::shortRelativeDateTime() const
{
if (m_dateTime > QDate::currentDate().startOfDay()) {
return hourMinuteString();
}
return relativeDate() + u", "_s + hourMinuteString();
}
bool NeoChatDateTime::isValid() const
{
return m_dateTime.isValid();

View File

@@ -18,7 +18,7 @@
class NeoChatDateTime
{
Q_GADGET
QML_ELEMENT
QML_NAMED_ELEMENT(neoChatDateTime)
/**
* @brief The base QDateTime used to generate the other values.
@@ -35,16 +35,21 @@ class NeoChatDateTime
*/
Q_PROPERTY(QString shortDateTime READ shortDateTime CONSTANT)
/**
* @brief The date and time formatted as per QLocale::LongFormat for your locale.
*/
Q_PROPERTY(QString longDateTime READ longDateTime CONSTANT)
/**
* @brief The date formatted as relative to now.
*
* If the date falls within one week before or after the current date
* If the date falls within 2 days or after the current date
* then a relative date string will be returned, such as:
* - Yesterday
* - Today
* - Tomorrow
* - Last Tuesday
* - Next Wednesday
* - Two days ago
* - In Two Days
*
* If the date falls outside this period then the format QLocale::ShortFormat
* for your locale is used.
@@ -54,21 +59,37 @@ class NeoChatDateTime
/**
* @brief The time and date formatted as relative to now.
*
* The format is "RelativeDate, hh::mm"
* The format is "RelativeDate at hh::mm"
*
* If the date falls within one week before or after the current date
* If the date falls within 2 days before or after the current date
* then a relative date string will be returned, such as:
* - Yesterday
* - Today
* - Tomorrow
* - Last Tuesday
* - Next Wednesday
* - Two days ago
* - In Two Days
*
* If the date falls outside this period then the format QLocale::ShortFormat
* for your locale is used.
*/
Q_PROPERTY(QString relativeDateTime READ relativeDateTime CONSTANT)
/**
* @brief The time and date formatted as relative to now.
*
* The format is "RelativeDate, hh::mm"
*
* If the date falls on the same day as current date, the date is skipped.
* If the date falls within 2 days before current date
* then a relative date string will be returned, such as:
* - Yesterday
* - Tomorrow
*
* If the date falls outside this period then the format QLocale::ShortFormat
* for your locale is used.
*/
Q_PROPERTY(QString shortRelativeDateTime READ shortRelativeDateTime CONSTANT)
/**
* @brief Whether this object has a valid date time.
*/
@@ -81,8 +102,10 @@ public:
QString hourMinuteString() const;
QString shortDateTime() const;
QString longDateTime() const;
QString relativeDate() const;
QString relativeDateTime() const;
QString shortRelativeDateTime() const;
bool isValid() const;

View File

@@ -59,6 +59,8 @@
#include <KJobTrackerInterface>
#include <KLocalizedString>
#include <ranges>
using namespace Quotient;
std::function<bool(const Quotient::RoomEvent *)> NeoChatRoom::m_hiddenFilter = [](const Quotient::RoomEvent *) -> bool {
@@ -173,9 +175,16 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
connect(neochatconnection, &NeoChatConnection::globalUrlPreviewEnabledChanged, this, &NeoChatRoom::urlPreviewEnabledChanged);
connect(this, &Room::fullyReadMarkerMoved, this, &NeoChatRoom::invalidateLastUnreadHighlightId);
// Wait until the initial member list is available before sorting
connect(this, &Room::memberListChanged, this, &NeoChatRoom::refreshAllMembers, Qt::SingleShotConnection);
connect(this, &Room::memberJoined, this, &NeoChatRoom::insertMemberSorted);
// This may look weird, but this is actually for performance.
// We only want to listen to new member joining *when* the initial member list was loaded.
connect(
this,
&Room::memberListChanged,
this,
[this] {
connect(this, &Room::memberJoined, this, &NeoChatRoom::insertMemberSorted);
},
Qt::SingleShotConnection);
}
bool NeoChatRoom::visible() const
@@ -630,7 +639,14 @@ bool NeoChatRoom::isUserBanned(const QString &user) const
void NeoChatRoom::deleteMessagesByUser(const QString &user, const QString &reason)
{
doDeleteMessagesByUser(user, reason);
QStringList events;
for (const auto &event : messageEvents()) {
if (event->senderId() == user && !event->isRedacted() && !event.viewAs<RedactionEvent>() && !event->isStateEvent()) {
events += event->id();
}
}
doDeleteMessageIds(events, reason);
}
QString NeoChatRoom::historyVisibility() const
@@ -761,16 +777,10 @@ void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel
}
}
QCoro::Task<void> NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason)
QCoro::Task<void> NeoChatRoom::doDeleteMessageIds(const QStringList eventIds, QString reason)
{
QStringList events;
for (const auto &event : messageEvents()) {
if (event->senderId() == user && !event->isRedacted() && !event.viewAs<RedactionEvent>() && !event->isStateEvent()) {
events += event->id();
}
}
for (const auto &e : events) {
auto job = connection()->callApi<RedactEventJob>(id(), QString::fromLatin1(QUrl::toPercentEncoding(e)), connection()->generateTxnId(), reason);
for (const auto &eventId : eventIds) {
auto job = connection()->callApi<RedactEventJob>(id(), eventId, connection()->generateTxnId(), reason);
co_await qCoro(job.get(), &BaseJob::finished);
if (job->error() != BaseJob::Success) {
qWarning() << "Error: \"" << job->error() << "\" while deleting messages. Aborting";
@@ -1914,14 +1924,21 @@ void NeoChatRoom::invalidateLastUnreadHighlightId(const QString &fromEventId, co
}
}
void NeoChatRoom::refreshAllMembers()
void NeoChatRoom::sortAllMembers()
{
m_sortedMemberIds = memberIds();
// Build up a temporary cache, because we may be checking the same member over and over while sorting.
QHash<QString, int> effectivePowerLevels;
effectivePowerLevels.reserve(m_sortedMemberIds.size());
for (const auto &member : m_sortedMemberIds) {
effectivePowerLevels[member] = memberEffectivePowerLevel(member);
}
MemberSorter sorter;
std::ranges::sort(m_sortedMemberIds, [this, &sorter](const auto &left, const auto &right) {
const auto leftPl = memberEffectivePowerLevel(left);
const auto rightPl = memberEffectivePowerLevel(right);
std::ranges::sort(m_sortedMemberIds, [&sorter, &effectivePowerLevels](const auto &left, const auto &right) {
const auto leftPl = effectivePowerLevels[left];
const auto rightPl = effectivePowerLevels[right];
if (leftPl > rightPl) {
return true;
}
@@ -1963,4 +1980,96 @@ QList<QString> NeoChatRoom::sortedMemberIds() const
return m_sortedMemberIds;
}
int NeoChatRoom::selectedMessageCount() const
{
return m_selectedMessageIds.size();
}
bool NeoChatRoom::canDeleteSelectedMessages() const
{
if (canSendState("redact"_L1)) {
return true;
}
const QString localUserId = connection()->userId();
return std::ranges::all_of(m_selectedMessageIds, [this, localUserId](const QString &eventId) {
const auto eventIt = findInTimeline(eventId);
if (eventIt == historyEdge()) {
return false;
}
const RoomEvent *event = eventIt->event();
return event && (event->senderId() == localUserId);
});
}
bool NeoChatRoom::isMessageSelected(const QString &eventId) const
{
return m_selectedMessageIds.contains(eventId);
}
void NeoChatRoom::toggleMessageSelection(const QString &eventId)
{
if (!m_selectedMessageIds.remove(eventId)) {
m_selectedMessageIds.insert(eventId);
}
Q_EMIT selectionChanged();
}
QString NeoChatRoom::getFormattedSelectedMessages() const
{
QVector<const RoomEvent *> events;
events.reserve(m_selectedMessageIds.size());
std::ranges::copy(m_selectedMessageIds | std::views::transform([this](const QString &eventId) -> const RoomEvent * {
const auto eventIt = findInTimeline(eventId);
return eventIt != historyEdge() ? eventIt->event() : nullptr;
}) | std::views::filter([](const RoomEvent *event) {
return event != nullptr;
}),
std::back_inserter(events));
std::ranges::sort(events, {}, &RoomEvent::originTimestamp);
QString formattedContent;
formattedContent.reserve(events.size() * 256); // estimate an average of 256 characters per message
for (const RoomEvent *event : events) {
formattedContent += EventHandler::authorDisplayName(this, event);
formattedContent += u""_s;
formattedContent += EventHandler::dateTime(this, event).shortDateTime();
formattedContent += u'\n';
formattedContent += EventHandler::plainBody(this, event);
formattedContent += u"\n\n"_s;
}
return formattedContent.trimmed();
}
void NeoChatRoom::deleteSelectedMessages(const QString &reason)
{
QStringList events;
for (const auto &eventId : m_selectedMessageIds) {
const auto eventIt = findInTimeline(eventId);
if (eventIt == historyEdge()) {
continue;
}
const RoomEvent *event = eventIt->event();
if (event && !event->isRedacted() && !is<RedactionEvent>(*event)) {
events += eventId;
}
}
doDeleteMessageIds(events, reason);
clearSelectedMessages();
}
void NeoChatRoom::clearSelectedMessages()
{
m_selectedMessageIds.clear();
Q_EMIT selectionChanged();
}
#include "moc_neochatroom.cpp"

View File

@@ -220,6 +220,16 @@ class NeoChatRoom : public Quotient::Room
*/
Q_PROPERTY(bool spaceHasUnreadMessages READ spaceHasUnreadMessages NOTIFY spaceHasUnreadMessagesChanged)
/**
* @brief The number of selected messages in the room.
*/
Q_PROPERTY(int selectedMessageCount READ selectedMessageCount NOTIFY selectionChanged)
/**
* @brief Whether the user can delete the selected messages.
*/
Q_PROPERTY(bool canDeleteSelectedMessages READ canDeleteSelectedMessages NOTIFY selectionChanged)
public:
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
@@ -673,9 +683,53 @@ public:
/**
* @return List of members in this room, sorted by power level and then by name.
*
* This list is only populated after sortAllMembers() is called.
*/
QList<QString> sortedMemberIds() const;
/**
* @brief The number of selected messages in the room.
*/
int selectedMessageCount() const;
/**
* @brief Whether the user can delete the selected messages.
*/
bool canDeleteSelectedMessages() const;
/**
* @brief Whether the given message is selected.
*/
Q_INVOKABLE bool isMessageSelected(const QString &eventId) const;
/**
* @brief Toggle the selection state of the given message.
*/
Q_INVOKABLE void toggleMessageSelection(const QString &eventId);
/**
* @brief Get the content of the selected messages formatted as a single string.
*/
Q_INVOKABLE QString getFormattedSelectedMessages() const;
/**
* @brief Delete the selected messages with an optional reason.
*/
Q_INVOKABLE void deleteSelectedMessages(const QString &reason = QString());
/**
* @brief Clear the selection of messages.
*/
Q_INVOKABLE void clearSelectedMessages();
/**
* @brief Sort all members based on their display name, and power level.
*
* @note This is a very expensive operation, and should only be done when truly needed.
*/
void sortAllMembers();
private:
bool m_visible = false;
@@ -693,7 +747,7 @@ private:
void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
QCoro::Task<void> doDeleteMessagesByUser(const QString &user, QString reason);
QCoro::Task<void> doDeleteMessageIds(const QStringList eventIds, QString reason);
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString(), std::optional<Quotient::EventRelation> relatesTo = std::nullopt);
std::unique_ptr<Quotient::RoomEvent> m_cachedEvent;
@@ -713,6 +767,7 @@ private:
QString m_lastUnreadHighlightId;
QList<QString> m_sortedMemberIds;
QSet<QString> m_selectedMessageIds;
private Q_SLOTS:
void updatePushNotificationState(QString type);
@@ -721,8 +776,6 @@ private Q_SLOTS:
void invalidateLastUnreadHighlightId(const QString &fromEventId, const QString &toEventId);
void refreshAllMembers();
void insertMemberSorted(Quotient::RoomMember member);
Q_SIGNALS:
@@ -752,6 +805,7 @@ Q_SIGNALS:
void pinnedMessageChanged();
void highlightCycleStartedChanged();
void spaceHasUnreadMessagesChanged();
void selectionChanged();
/**
* @brief Request a message be shown to the user of the given type.

View File

@@ -51,8 +51,16 @@ SearchPage {
signal roomSelected(string roomId, string displayName, url avatarUrl, string alias, string topic, int memberCount, bool isJoined)
title: i18nc("@action:title Explore public rooms and spaces", "Explore")
customPlaceholderText: publicRoomListModel.redirectedText
customPlaceholderText: {
if (publicRoomListModel.redirectedText.length > 0)
return publicRoomListModel.redirectedText;
if (publicRoomListModel.errorText.length > 0)
return publicRoomListModel.errorText;
return "";
}
customPlaceholderIcon: "data-warning"
enableSearch: publicRoomListModel.errorText.length === 0
Component.onCompleted: focusSearch()
@@ -63,6 +71,7 @@ SearchPage {
display: QQC2.Button.IconOnly
checkable: true
text: i18nc("@action:button", "Only show spaces")
enabled: root.enableSearch
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: text
@@ -96,7 +105,7 @@ SearchPage {
activeFocusOnTab: false // We handle moving to this item via up/down arrows, otherwise the tab order is wacky
text: i18n("Enter a Room Manually")
visible: publicRoomListModel.redirectedText.length === 0
visible: root.customPlaceholderText.length === 0
icon.name: "compass"
icon.width: Kirigami.Units.gridUnit * 2
icon.height: Kirigami.Units.gridUnit * 2

View File

@@ -108,7 +108,7 @@ SearchPage {
id: _private
function openManualUserDialog(): void {
let dialog = manualUserDialog.createObject(this, {
connection: root.connection
connection: root.room.connection
}) as ManualUserDialog;
dialog.parent = root.Window.window.overlay;
dialog.accepted.connect(() => {

View File

@@ -80,6 +80,11 @@ Kirigami.ScrollablePage {
*/
property bool showSearchButton: true
/**
* @brief Enable the search controls like the text field and button.
*/
property bool enableSearch: true
/**
* @brief Message to be shown in a custom placeholder.
* The custom placeholder will be shown if the text is not empty
@@ -139,6 +144,7 @@ Kirigami.ScrollablePage {
Kirigami.SearchField {
id: searchField
focus: true
enabled: root.enableSearch
Layout.fillWidth: true
Keys.onEnterPressed: searchButton.clicked()
Keys.onReturnPressed: searchButton.clicked()
@@ -158,6 +164,7 @@ Kirigami.ScrollablePage {
display: QQC2.Button.IconOnly
visible: root.showSearchButton
text: i18nc("@action:button", "Search")
enabled: root.enableSearch
onClicked: {
if (typeof root.model.search === 'function') {
@@ -173,7 +180,6 @@ Kirigami.ScrollablePage {
Timer {
id: searchTimer
interval: 500
running: true
onTriggered: if (typeof root.model.search === 'function') {
root.model.search();
}
@@ -193,7 +199,7 @@ Kirigami.ScrollablePage {
id: noSearchMessage
icon.name: "search"
anchors.centerIn: parent
visible: searchField.text.length === 0 && listView.count === 0 && customPlaceholder.text.length === 0
visible: searchField.text.length === 0 && listView.count === 0 && !root.model.searching && customPlaceholder.text.length === 0
helpfulAction: root.noSearchHelpfulAction
}
@@ -208,13 +214,13 @@ Kirigami.ScrollablePage {
Kirigami.PlaceholderMessage {
id: customPlaceholder
anchors.centerIn: parent
visible: searchField.text.length > 0 && listView.count === 0 && !root.model.searching && text.length > 0
visible: listView.count === 0 && !root.model.searching && text.length > 0
icon.name: root.customPlaceholderIcon
}
Kirigami.LoadingPlaceholder {
anchors.centerIn: parent
visible: searchField.text.length > 0 && listView.count === 0 && (root.model.searching || searchTimer.running) && customPlaceholder.text.length === 0
visible: listView.count === 0 && (root.model.searching || searchTimer.running)
}
Keys.onUpPressed: {

View File

@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "voicerecorder.h"
#include <QFile>
#include <QTemporaryFile>
#include <KFormat>
#include <Quotient/events/filesourceinfo.h>
using namespace Qt::Literals::StringLiterals;
VoiceRecorder::VoiceRecorder(QObject *parent)
: QObject(parent)
, m_buffer(new QBuffer)
, m_format(QMediaFormat::FileFormat::Ogg)
{
m_session.setAudioInput(&m_input);
m_recorder.setAudioBitRate(24000);
m_recorder.setAudioSampleRate(48000);
m_format.setAudioCodec(QMediaFormat::AudioCodec::Opus);
m_recorder.setAudioChannelCount(1);
m_recorder.setMediaFormat(m_format);
m_buffer->open(QIODevice::ReadWrite);
m_recorder.setOutputDevice(m_buffer);
m_session.setRecorder(&m_recorder);
}
VoiceRecorder::~VoiceRecorder()
{
delete m_buffer;
}
void VoiceRecorder::startRecording()
{
m_buffer->setData({});
m_recorder.record();
}
void VoiceRecorder::stopRecording()
{
m_recorder.stop();
}
QMediaRecorder *VoiceRecorder::recorder()
{
return &m_recorder;
}
void VoiceRecorder::send()
{
Quotient::FileSourceInfo fileMetadata;
QByteArray data;
m_buffer->seek(0);
if (m_room->usesEncryption()) {
std::tie(fileMetadata, data) = Quotient::encryptFile(m_buffer->data());
m_buffer->close();
m_buffer->setData(data);
m_buffer->open(QIODevice::ReadOnly);
}
auto room = m_room;
auto buffer = m_buffer;
auto duration = m_recorder.duration();
m_buffer = nullptr;
m_room->connection()->uploadContent(buffer, {}, u"audio/ogg"_s).then([fileMetadata, room, buffer, duration](const auto &job) mutable {
QJsonObject mscFile{
{u"mimetype"_s, u"audio/ogg"_s},
{u"name"_s, u"Voice Message"_s},
{u"size"_s, buffer->size()},
};
if (room->usesEncryption()) {
mscFile[u"file"_s] = toJson(fileMetadata);
} else {
mscFile[u"url"_s] = job->contentUri().toString();
}
Quotient::setUrlInSourceInfo(fileMetadata, job->contentUri());
QJsonObject content{
{u"body"_s, u"Voice message"_s},
{u"msgtype"_s, u"m.audio"_s},
{u"org.matrix.msc1767.text"_s,
QJsonObject{{u"body"_s, u"Voice Message (%1, %2)"_s.arg(KFormat().formatDuration(duration), KFormat().formatByteSize(buffer->size()))}}},
{u"org.matrix.msc1767.file"_s, mscFile},
{u"info"_s,
QJsonObject{
{u"mimetype"_s, u"audio/ogg"_s},
{u"size"_s, buffer->size()},
{u"duration"_s, duration},
}},
{u"org.matrix.msc1767.audio"_s,
QJsonObject{
{u"duration"_s, duration},
{u"waveform"_s, QJsonArray{}}, // TODO
}},
{u"org.matrix.msc3245.voice"_s, QJsonObject{}}};
if (room->usesEncryption()) {
content[u"file"_s] = toJson(fileMetadata);
} else {
content[u"url"_s] = job->contentUri().toString();
}
room->postJson(u"m.room.message"_s, content);
});
}
void VoiceRecorder::setRoom(NeoChatRoom *room)
{
m_room = room;
Q_EMIT roomChanged();
}
NeoChatRoom *VoiceRecorder::room() const
{
return m_room.get();
}
bool VoiceRecorder::isSupported() const
{
return m_format.isSupported(QMediaFormat::Encode);
}

View File

@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QObject>
#include <qqmlintegration.h>
#include "neochatroom.h"
#include <QAudioInput>
#include <QBuffer>
#include <QMediaCaptureSession>
#include <QMediaFormat>
#include <QMediaRecorder>
class VoiceRecorder : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QMediaRecorder *recorder READ recorder CONSTANT)
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED)
// TODO: Remove once no longer required
Q_PROPERTY(bool isSupported READ isSupported CONSTANT)
public:
explicit VoiceRecorder(QObject *parent = nullptr);
~VoiceRecorder() override;
Q_INVOKABLE void startRecording();
Q_INVOKABLE void stopRecording();
Q_INVOKABLE void send();
QMediaRecorder *recorder();
NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
bool isSupported() const;
Q_SIGNALS:
void roomChanged();
private:
QAudioInput m_input;
QMediaCaptureSession m_session;
QMediaRecorder m_recorder;
QBuffer *m_buffer;
QPointer<NeoChatRoom> m_room;
QMediaFormat m_format;
};

View File

@@ -27,14 +27,15 @@ RowLayout {
required property var author
/**
* @brief The timestamp of the event as a NeoChatDateTime.
* @brief The timestamp of the event as a neoChatDateTime.
*/
required property NeoChatDateTime dateTime
required property neoChatDateTime dateTime
Layout.fillWidth: true
Layout.maximumWidth: Message.maxContentWidth
implicitHeight: Math.max(nameButton.implicitHeight, timeLabel.implicitHeight)
spacing: Kirigami.Units.mediumSpacing
QQC2.Label {
id: nameButton
@@ -45,11 +46,15 @@ RowLayout {
font.weight: Font.Bold
elide: Text.ElideRight
clip: true // Intentional to limit insane Unicode in display names
Layout.fillWidth: true
Layout.maximumWidth: nameButton.implicitWidth + Kirigami.Units.smallSpacing
function openUserMenu(): void {
const menu = Qt.createComponent("org.kde.neochat", "UserMenu").createObject(root, {
window: QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow,
author: root.author,
author: root.author
});
menu.popup(root.QQC2.Overlay.overlay);
}
@@ -73,20 +78,24 @@ RowLayout {
onTapped: nameButton.openUserMenu()
}
}
Item {
Layout.fillWidth: true
}
QQC2.Label {
id: timeLabel
text: root.dateTime.hourMinuteString
text: root.dateTime.shortRelativeDateTime
horizontalAlignment: Text.AlignRight
color: Kirigami.Theme.disabledTextColor
QQC2.ToolTip.visible: timeHoverHandler.hovered
QQC2.ToolTip.text: root.dateTime.shortDateTime
QQC2.ToolTip.text: root.dateTime.longDateTime
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
HoverHandler {
id: timeHoverHandler
}
}
Item {
Layout.fillWidth: true
}
}

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Qt.labs.qmlmodels

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
@@ -34,7 +36,7 @@ QQC2.Control {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
Layout.preferredHeight: active ? item.implicitHeight : 0
Layout.preferredHeight: active ? (item as Item).implicitHeight : 0
active: visible
visible: root.chatBarCache.replyId.length > 0 || root.chatBarCache.attachmentPath.length > 0
@@ -71,7 +73,7 @@ QQC2.Control {
root.chatBarCache.text = text;
}
Keys.onEnterPressed: {
Keys.onEnterPressed: event => {
if (completionMenu.visible) {
completionMenu.complete();
} else if (event.modifiers & Qt.ShiftModifier) {
@@ -80,7 +82,7 @@ QQC2.Control {
_private.post();
}
}
Keys.onReturnPressed: {
Keys.onReturnPressed: event => {
if (completionMenu.visible) {
completionMenu.complete();
} else if (event.modifiers & Qt.ShiftModifier) {
@@ -108,7 +110,7 @@ QQC2.Control {
height: implicitHeight
y: -height - 5
z: 10
connection: root.Message.room.connection
connection: root.Message.room.connection as NeoChatConnection
chatDocumentHandler: documentHandler
margins: 0
Behavior on height {
@@ -165,7 +167,7 @@ QQC2.Control {
text: i18nc("@action:button", "Attach an image or file")
icon.name: "mail-attachment"
onClicked: {
let dialog = (Clipboard.hasImage ? attachDialog : openFileDialog).createObject(QQC2.Overlay.overlay);
let dialog = (Clipboard.hasImage ? attachDialog : openFileDialog).createObject(QQC2.Overlay.overlay) as QQC2.Dialog;
dialog.chosen.connect(path => root.chatBarCache.attachmentPath = path);
dialog.open();
}
@@ -225,8 +227,6 @@ QQC2.Control {
Message.maxContentWidth: paneLoader.item.width
}
QQC2.Button {
id: cancelButton
anchors.top: parent.top
anchors.right: parent.right
@@ -261,9 +261,9 @@ QQC2.Control {
function updateText() {
// This could possibly be undefined due to some esoteric QtQuick issue. Referencing it somewhere in JS is enough.
documentHandler.document;
if (chatBarCache?.isEditing && chatBarCache.relationMessage.length > 0) {
textArea.text = chatBarCache.relationMessage;
documentHandler.updateMentions(chatBarCache.editId);
if (root.chatBarCache?.isEditing && root.chatBarCache.relationMessage.length > 0) {
textArea.text = root.chatBarCache.relationMessage;
documentHandler.updateMentions(root.chatBarCache.editId);
textArea.forceActiveFocus();
textArea.cursorPosition = textArea.text.length;
}

View File

@@ -28,9 +28,9 @@ QQC2.Control {
required property NeochatRoomMember author
/**
* @brief The timestamp of the event as a NeoChatDateTime.
* @brief The timestamp of the event as a neoChatDateTime.
*/
required property NeoChatDateTime dateTime
required property neoChatDateTime dateTime
/**
* @brief The display text of the message.

View File

@@ -2,12 +2,13 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtCore as Core
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Dialogs as Dialogs
import Qt.labs.qmlmodels
import org.kde.coreaddons
import org.kde.kirigami as Kirigami
@@ -51,7 +52,7 @@ ColumnLayout {
property bool autoOpenFile: false
function saveFileAs() {
const dialog = fileDialog.createObject(QQC2.Overlay.overlay);
const dialog = fileDialog.createObject(QQC2.Overlay.overlay) as Dialogs.FileDialog;
dialog.selectedFile = Message.room.fileNameToDownload(root.eventId);
dialog.open();
}
@@ -70,7 +71,7 @@ ColumnLayout {
states: [
State {
name: "downloadedInstant"
when: root.fileTransferInfo.completed && autoOpenFile
when: root.fileTransferInfo.completed && root.autoOpenFile
PropertyChanges {
openButton.icon.name: "document-open"
@@ -84,7 +85,7 @@ ColumnLayout {
},
State {
name: "downloaded"
when: root.fileTransferInfo.completed && !autoOpenFile
when: root.fileTransferInfo.completed && !root.autoOpenFile
PropertyChanges {
openButton.visible: false
@@ -138,7 +139,7 @@ ColumnLayout {
id: openButton
icon.name: "document-open"
onClicked: {
autoOpenFile = true;
root.autoOpenFile = true;
root.Message.room.downloadTempFile(root.eventId);
}
@@ -166,7 +167,7 @@ ColumnLayout {
onAccepted: {
NeoChatConfig.lastSaveDirectory = currentFolder;
NeoChatConfig.save();
if (autoOpenFile) {
if (root.autoOpenFile) {
UrlHelper.copyTo(root.fileTransferInfo.localPath, selectedFile);
} else {
root.Message.room.download(root.eventId, selectedFile);

View File

@@ -40,7 +40,7 @@ ColumnLayout {
Layout.maximumWidth: Message.maxContentWidth
LiveLocationsModel {
id: liveLocationModel
id: locationModel
eventId: root.eventId
room: Message.room
}
@@ -50,13 +50,13 @@ ColumnLayout {
Layout.preferredWidth: root.Message.maxContentWidth
Layout.preferredHeight: root.Message.maxContentWidth / 16 * 9
map.center: QtPositioning.coordinate(liveLocationModel.boundingBox.y, liveLocationModel.boundingBox.x)
map.center: QtPositioning.coordinate(locationModel.boundingBox.y, locationModel.boundingBox.x)
map.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin
MapItemView {
model: liveLocationModel
model: locationModel
delegate: LocationMapItem {}
}
@@ -64,7 +64,7 @@ ColumnLayout {
acceptedButtons: Qt.LeftButton
onTapped: {
fullScreenMap.createObject(parent, {
liveLocationModel: liveLocationModel
liveLocationModel: locationModel
})
}
}

View File

@@ -2,9 +2,10 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
@@ -102,7 +103,7 @@ Flow {
}
onClicked: {
var dialog = emojiDialog.createObject(reactButton);
var dialog = emojiDialog.createObject(reactButton) as EmojiDialog;
dialog.showStickers = false;
dialog.chosen.connect(emoji => {
root.Message.room.toggleReaction(root.eventId, emoji);

View File

@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Qt.labs.qmlmodels
@@ -117,6 +119,7 @@ DelegateChooser {
DelegateChoice {
roleValue: MessageComponentType.Location
delegate: MimeComponent {
required property string display
mimeIconSource: "mark-location"
label: display
}
@@ -125,6 +128,7 @@ DelegateChooser {
DelegateChoice {
roleValue: MessageComponentType.LiveLocation
delegate: MimeComponent {
required property string display
mimeIconSource: "mark-location"
label: display
}

View File

@@ -113,8 +113,8 @@ void EventMessageContentModel::initializeModel()
}
});
#if Quotient_VERSION_MINOR > 9
connect(m_room, &Room::newThread, this, [this](const Thread &newThread) {
if (newThread.threadRootId == m_eventId) {
connect(m_room, &Room::newThread, this, [this](const auto &newThread) {
if (newThread == m_eventId) {
resetContent();
}
});

View File

@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
@@ -59,7 +61,7 @@ Kirigami.OverlayDrawer {
if (_lastX === -1) {
return;
}
if (Qt.application.layoutDirection === Qt.RightToLeft) {
if (Application.layoutDirection === Qt.RightToLeft) {
root.actualWidth = Math.min(root.maxWidth, Math.max(root.minWidth, root.roomDrawerWidth - _lastX + mapToGlobal(mouseX, mouseY).x));
} else {
root.actualWidth = Math.min(root.maxWidth, Math.max(root.minWidth, root.roomDrawerWidth + _lastX - mapToGlobal(mouseX, mouseY).x));
@@ -68,7 +70,7 @@ Kirigami.OverlayDrawer {
}
enabled: true
edge: Qt.application.layoutDirection == Qt.RightToLeft ? Qt.LeftEdge : Qt.RightEdge
edge: Application.layoutDirection == Qt.RightToLeft ? Qt.LeftEdge : Qt.RightEdge
// If modal has been changed and the drawer is closed automatically then dim on popup open will have been switched off in main.qml so switch it back on after the animation completes.
// This is to avoid dim being active for a split second when the drawer is switched to modal which looks terrible.

View File

@@ -1,8 +1,9 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
@@ -103,7 +104,7 @@ Kirigami.Page {
Connections {
target: root.Kirigami.PageStack.pageStack
onWideModeChanged: {
function onWideModeChanged(): void {
if ((root.Kirigami.PageStack.pageStack as Kirigami.PageRow).wideMode) {
root.Kirigami.PageStack.pop();
}

View File

@@ -15,6 +15,7 @@ import org.kde.neochat
RowLayout {
id: root
required property NeoChatConnection connection
property bool collapsed: false
signal search
@@ -36,7 +37,7 @@ RowLayout {
} else if(RoomManager.currentSpace === 'DM') {
return i18nc("@title", "Direct Messages");
}
return root.connection.room(RoomManager.currentSpace).displayName;
return root.connection.room(RoomManager.currentSpace)?.displayName;
}
return i18nc("@title List of rooms", "Rooms");
}
@@ -57,4 +58,16 @@ RowLayout {
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
display: QQC2.Button.IconOnly
visible: Kirigami.Settings.isMobile
text: i18nc("@action:button", "Open Settings")
icon.name: "settings-configure-symbolic"
onClicked: NeoChatSettingsView.open()
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}

View File

@@ -139,6 +139,7 @@ Kirigami.Page {
contentItem: TreeView {
id: treeView
topMargin: Math.round(Kirigami.Units.smallSpacing / 2)
bottomMargin: Math.round(Kirigami.Units.smallSpacing / 2)
clip: true
reuseItems: false
@@ -147,6 +148,17 @@ Kirigami.Page {
selectionModel: ItemSelectionModel {}
// This is somewhat of a hack.
// If we don't calculate this for TableView, then the content area doesn't quite scroll down far enough to cover the margins.
rowHeightProvider: row => {
// NOTE: This padding value should be kept in sync with the padding value of our delegates.
const padding = Kirigami.Units.mediumSpacing;
// NOTE: This calculation should be kept in sync with the height value of our delegates.
return Kirigami.Units.gridUnit + (NeoChatConfig.compactRoomList ? 0 : Kirigami.Units.largeSpacing * 2) + padding * 2;
}
// NOTE: For any future delegate spelunkers, please *keep the delegate heights in sync*.
// If you fail to do so, weird scrolling behavior begins to manifest.
delegate: DelegateChooser {
role: "delegateType"
@@ -181,8 +193,8 @@ Kirigami.Page {
delegate: Delegates.RoundedItemDelegate {
text: i18nc("@action:button", "Find your friends")
icon.name: "list-add-user"
icon.width: Kirigami.Units.gridUnit * 2
icon.height: Kirigami.Units.gridUnit * 2
icon.width: Kirigami.Units.gridUnit + (NeoChatConfig.compactRoomList ? 0 : Kirigami.Units.largeSpacing * 2)
icon.height: Kirigami.Units.gridUnit + (NeoChatConfig.compactRoomList ? 0 : Kirigami.Units.largeSpacing * 2)
onClicked: (Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage'), {
connection: root.connection
@@ -270,6 +282,7 @@ Kirigami.Page {
Component {
id: exploreComponent
ExploreComponent {
connection: root.connection
collapsed: root.collapsed
onSearch: root.search()

View File

@@ -6,8 +6,11 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
QQC2.ItemDelegate {
import org.kde.neochat
Delegates.RoundedItemDelegate {
id: root
required property TreeView treeView
@@ -31,43 +34,58 @@ QQC2.ItemDelegate {
activeFocusOnTab: false
background: null
onClicked: root.treeView.toggleExpanded(row)
Keys.onEnterPressed: root.treeView.toggleExpanded(row)
Keys.onReturnPressed: root.treeView.toggleExpanded(row)
Keys.onSpacePressed: root.treeView.toggleExpanded(row)
contentItem: RowLayout {
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
visible: !root.collapsed
horizontalPadding: 0
topPadding: 0
bottomPadding: 0
text: root.collapsed ? "" : root.displayName
contentItem: Item {
implicitHeight: Math.max(layout.implicitHeight, Kirigami.Units.gridUnit + (NeoChatConfig.compactRoomList ? 0 : Kirigami.Units.largeSpacing * 2))
onClicked: root.treeView.toggleExpanded(row)
}
QQC2.ToolButton {
id: collapseButton
Layout.alignment: Qt.AlignHCenter
RowLayout {
id: layout
icon {
name: root.expanded ? "go-up" : "go-down"
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
anchors.fill: parent
spacing: 0
Kirigami.ListSectionHeader {
visible: !root.collapsed
horizontalPadding: 0
topPadding: 0
bottomPadding: 0
text: root.collapsed ? "" : root.displayName
onClicked: root.treeView.toggleExpanded(root.row)
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
text: root.expanded ? i18nc("Collapse <section name>", "Collapse %1", root.displayName) : i18nc("Expand <section name", "Expand %1", root.displayName)
display: QQC2.Button.IconOnly
QQC2.ToolButton {
id: collapseButton
activeFocusOnTab: false
Layout.alignment: Qt.AlignCenter
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
icon {
name: root.expanded ? "go-up" : "go-down"
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
}
text: root.expanded ? i18nc("Collapse <section name>", "Collapse %1", root.displayName) : i18nc("Expand <section name", "Expand %1", root.displayName)
display: QQC2.Button.IconOnly
onClicked: root.treeView.toggleExpanded(root.row)
activeFocusOnTab: false
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: root.treeView.toggleExpanded(root.row)
}
}
// Reduce the size of the tap target, this is smaller the ginormous section header ItemDelegate
TapHandler {
onTapped: root.treeView.toggleExpanded(root.row)
}
}
}

View File

@@ -24,10 +24,6 @@ QQC2.Control {
topPadding: 0
bottomPadding: 0
onActiveFocusChanged: if (activeFocus) {
notificationsButton.forceActiveFocus();
}
contentItem: ColumnLayout {
spacing: 0
@@ -46,42 +42,9 @@ QQC2.Control {
}
ColumnLayout {
id: column
width: scrollView.width
spacing: 0
AvatarTabButton {
id: notificationsButton
Layout.fillWidth: true
Layout.preferredHeight: width - Kirigami.Units.smallSpacing
Layout.maximumHeight: width - Kirigami.Units.smallSpacing
Layout.topMargin: Kirigami.Units.smallSpacing / 2
Layout.bottomMargin: Kirigami.Units.smallSpacing / 2
text: i18n("View notifications")
contentItem: Kirigami.Icon {
source: "notifications"
}
visible: !Kirigami.Settings.isMobile // Shows up in the mobile bar instead
activeFocusOnTab: true
onSelected: (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'NotificationsView'), {
connection: root.connection
}, {
title: i18nc("@title", "Notifications"),
modality: Qt.NonModal
})
}
Kirigami.Separator {
visible: notificationsButton.visible
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.smallSpacing
Layout.rightMargin: Kirigami.Units.smallSpacing
}
AvatarTabButton {
id: allRoomButton

View File

@@ -89,6 +89,23 @@ RowLayout {
window: QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow
}
}
QQC2.ToolButton {
display: QQC2.Button.IconOnly
text: i18n("View notifications")
icon.name: "notifications"
onClicked: (Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'NotificationsView'), {
connection: root.connection
}, {
title: i18nc("@title", "Notifications"),
modality: Qt.NonModal
})
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
display: QQC2.Button.IconOnly
text: i18nc("@action:button", "Open Settings")

View File

@@ -172,6 +172,8 @@ void PublicRoomListModel::next(int limit)
}
m_redirectedText.clear();
Q_EMIT redirectedChanged();
m_errorText.clear();
Q_EMIT errorTextChanged();
if (job) {
qCDebug(PublicRoomList) << "Other job running, ignore";
@@ -200,9 +202,12 @@ void PublicRoomListModel::next(int limit)
this->beginInsertRows({}, rooms.count(), rooms.count() + job->chunk().count() - 1);
rooms.append(job->chunk());
this->endInsertRows();
} else if (job->error() == BaseJob::ContentAccessError) {
} else if (job->error() == BaseJob::ContentAccessError && !m_searchText.isEmpty()) {
m_redirectedText = job->jsonData()[u"error"_s].toString();
Q_EMIT redirectedChanged();
} else {
m_errorText = job->jsonData()[u"error"_s].toString();
Q_EMIT errorTextChanged();
}
this->job = nullptr;
@@ -329,4 +334,9 @@ QString PublicRoomListModel::redirectedText() const
return m_redirectedText;
}
QString PublicRoomListModel::errorText() const
{
return m_errorText;
}
#include "moc_publicroomlistmodel.cpp"

Some files were not shown because too many files have changed in this diff Show More