Compare commits

...

47 Commits

Author SHA1 Message Date
Joshua Goins
8ff23239f7 Render placeholder avatars in notifications, like we do in app
Right now if someone or a room doesn't have an avatar, there's a good
chance it will end up falling back to the NeoChat app icon (not very
descriptive.) It also tends to break the flow of conversations, that's
the whole reason to have an avatar in the notification in the first
place.

So I made it render a similar-looking avatar like it does in-app, by
re-implementing it in QPainter. I also took the time to refactor and
clean up the avatar generation for notifications so the logic should be
easier to follow.

To reduce the maintenance required, we re-use some functionality from
Kirigami Add-ons. The rest of the drawing logic is custom but sensible,
but some creative liberty was used to ensure it looks decent.
2026-01-17 12:18:34 -05:00
Joshua Goins
5f20a86b62 Reduce the amount of items in the account menu
The devices entry gone (not even Element has this) and so is the logout
action. The Switch account menu item is moved to the bottom so its
easier to access by mouse. The "Open Secret Backup" is moved under
Security settings where it lives next to the other crypto-related
settings.
2026-01-17 11:03:38 -05:00
l10n daemon script
f2d3c9706e GIT_SILENT Sync po/docbooks with svn 2026-01-17 01:44:32 +00:00
Joshua Goins
39de4d10e4 Add hack around the timeline never settling just right
This is due to some kind of bug in ListView that never resettles
properly for bottom-to-top views. This can arise when the pinned message
is loaded (because that squishes the view) or the window is resized
(because that also resizes the view.)

We can work around it by assuming the following:
1. The RoomPage knows the window is resizing because it gets its height
changed before TimelineView.
2. The first height change can be a marker to position the view at the
beginning.

This fixes the issue for me, I did the following in order to test this:
* Switch between many rooms, especially ones with a pinned message. Now
all of them start at the bottom as they should.
* Resize the window, ensure that if you scrolled it stays around that
position - otherwise it sticks at the bottom.
2026-01-16 18:34:05 -05:00
Joshua Goins
4c37dcf518 Improve reliability of restoring the last space and room (again)
I found that 50% of the time, NeoChat won't restore the last space but
instead get stuck at Home. Even worse, it will overwrite Home's last
opened room with the one from the space - resulting in really buggy
behavior.

The reason why this happens is partly due to the space hierarchy cache
(I think) but that's not the real problem in my opinion. During
setCurrentSpace, we needlessly update the last space & room config
despite us being the ones already reading it.

In addition to that I also refactored this code a bit to be more
consolidated and readable.
2026-01-16 18:14:07 -05:00
Joshua Goins
3c77711417 Add hack around atYEnd
This fixes the annoying "I just scrolled down to the bottom, how come
NeoChat doesn't think I did?"

From what I can tell this is also ListView bug (or something caused by
our style/Kirigami) that creates cases like contentY being -643.2 (for a
ListView of height 643) thus that's not "at Y's end". For our case
though, we don't care and can safely round it.
2026-01-16 17:07:03 -05:00
Yuri Chornoivan
136063bd37 Fix minor typo 2026-01-16 05:29:13 +02:00
l10n daemon script
730a9e97fd GIT_SILENT Sync po/docbooks with svn 2026-01-16 01:53:34 +00:00
Joshua Goins
d5260376d2 Support replying and editing messages directly from room search
There's two parts to making this work mainly:
1. Use getEvent instead of findInTimeline so the related event is
actually found.
2. Close the dialog once a reply relation is found, so you can easily
reply in the chat bar.
2026-01-15 20:13:50 -05:00
Joshua Goins
dc935e09b7 Use countedNotifications instead of our own calculations w/ DMs
This fixes an odd disconnect you can sometimes see when the notification
isn't an invite or a "direct chat notification", which conflicts with
what we use to control the tooltip and visibility.
2026-01-15 16:51:57 -05:00
Joshua Goins
5759f7d82b Add a way to view server support information
This is useful if your server has said information, for matrix.org
includes abuse and administrator e-mails.

See #707
2026-01-15 16:21:03 -05:00
Joshua Goins
ed4b77c184 Remove the three item hamburger menu, re-distribute remaining items
There are only three, somewhat odd menu items remaining in this menu.
(Two if you don't have a camera.)

* Find your Friends, which is already accessible in a few other places
and currently has dubious utility.
* Create a Room, which also is barely used and can be combined with the
Create Space button in the space drawer.
* Scan a QR code, which can be placed in the account menu. I know this
isn't the most ideal place, but I can't think of anything better at the
moment.
2026-01-15 16:18:57 -05:00
Tobias Fella
716ee2e494 When "always allow verifying devices" option is enabled, show a less confusing message in the devices page 2026-01-15 09:12:17 -05:00
Joshua Goins
c15860cac3 Give DelegateContextMenu an actual room
This allows me to hide the "Reply" action for read-only rooms. Don't ask
me how it even worked before, I don't know.
2026-01-15 09:00:35 -05:00
Joshua Goins
f5c991c55c Pass room through the model, not when creating the delegate
This is another thing that enables us to view multiple rooms in a single
timeline. Specifically, this improves the experience in room search
going across room versions and getting a correct readOnly status (for
hiding certain hover actions.)
2026-01-15 09:00:35 -05:00
Joshua Goins
41609749d8 Fix a crash when grabbing relationAuthor
There's a bug in how we're using this function in room search, but we
definitely don't want it to crash. The event is technically not in the
timeline, so we were dereferencing an invalid iterator or whatever.
2026-01-15 09:00:35 -05:00
Joshua Goins
644df80090 Don't show "Configure Web Shortcuts" if there are none
This prevents some weird edge cases where the Configure action is
visible, but nothing is actually searchable - like for images.
2026-01-15 09:00:23 -05:00
Joshua Goins
e3307326ef Close the message menu after selecting a quick reaction
And also ensure the "select an emoji" menu doesn't close the message
menu after *not* choosing an emoji, so it acts more like a submenu.
2026-01-15 09:00:13 -05:00
Joshua Goins
74d4e786d3 Clarify where reports are sent to
Contrary to popular belief (unfortunately) these reports are *only* sent
to your own server, which is then opaquely handled in some unknownable
way.

See #707
2026-01-15 09:00:02 -05:00
Joshua Goins
1e461658b8 Hide "Reply in Thread" message action if we don't have threads enabled 2026-01-15 08:16:41 -05:00
l10n daemon script
f305cb849f GIT_SILENT Sync po/docbooks with svn 2026-01-15 01:52:21 +00:00
l10n daemon script
e1bbbfe4fd GIT_SILENT Sync po/docbooks with svn 2026-01-14 01:44:13 +00:00
Kai Uwe Broulik
7a2211f8e0 RoomPage: Fix selected text and hovered link in context menu
They were not forwarded to the menu.
Also, "isThread" argument is no longer there in the signal.
2026-01-13 17:37:30 -05:00
Azhar Momin
4155e9116a Fix some runtime qml warnings 2026-01-13 17:35:02 -05:00
Azhar Momin
a989ef42b2 Fix pushDialogLayer failing in DelegateContextMenu 2026-01-13 17:35:02 -05:00
Joshua Goins
2babf44b28 Grab the correct room in MessageModel::data
Not all events that are processed in this model belong to the room, e.g.
searching through multiple room versions. Now the model finds the
correct room based on the reported room in the event.

This fixes searching through upgraded rooms, and unblocks searching
through multiple rooms in the future.
2026-01-13 09:25:49 -05:00
Joshua Goins
89e42dbc53 Decrypt when downloading single events from the server
This fixes issues like not being able to view pinned messages in
encrypted rooms.
2026-01-13 07:52:07 -05:00
l10n daemon script
ba57570dbf GIT_SILENT Sync po/docbooks with svn 2026-01-13 09:49:08 +00:00
Joshua Goins
1a500a087b Separate priviled members list, and more useful permissions
Having both the member list and permission controls is troublesome, and
scales the larger your moderation team is. We eventually may want to
manage banned/muted users too so I think it warrants having a new page.
I also moved the search field to the top so it's more accessible.

As for permissions, I tried to improve the UX generally while not
changing it too heavily. First the easy change is to the text, hopefully
the sections should be clearer (especially for "state" events.) The
bigger change here is the new sections, I tried to make it more useful
and organized. Additionally, I added more permissions like sharing live
locations and polls so they're more easily configurable.

One other change is that permissions are visible regardless of whether
you can set them or not, matching Element's behavior.
2026-01-11 21:14:15 -05:00
l10n daemon script
e54955ec0c GIT_SILENT Sync po/docbooks with svn 2026-01-12 01:55:14 +00:00
Joshua Goins
8608b3b62e Remove extra arguments in StateComponent's viewEventMenu call 2026-01-11 18:30:02 -05:00
Joshua Goins
fea0cfbf4e Fix opening message menus for popup windows
We were previously assuming that we always want to parent these menus to
RoomPage, but that only exists on the main window. If you tried to open
the menu for say - the search window - then it would confusingly still
open on the main menu.

Thankfully the way to fix this is simple, by passing a parent QtObject
around.
2026-01-11 18:30:02 -05:00
Joshua Goins
5b6e5a25e5 Allow opening message menus for out-of-room events
These are more common than we thought, good examples are pinned or
searched messages - which are not going to be in the room's history
unless you happen to have them loaded. But currently our message menu
infrastructure expects them to be, since its looked up by the room +
event ID.

To fix this is simple, we now move the job of finding the event to the
caller which may use a model instead. I didn't fix all existing
call-sites yet, mainly the message menu opening one since that was the
most obvious bug. But this opens up the door for other assumptions about
room history to be fixed too.

I had to do a bit of C++ re-jiggering in order to expose useful
functions to QML.
2026-01-11 18:30:02 -05:00
Joshua Goins
58b47b8711 Rename indexforEventId to indexForEventId
This is a simple change to ensure its properly camelCase.
2026-01-11 18:30:02 -05:00
Joshua Goins
b45967508c Fix reply colors being broken if you're faster than the server
This is that bug that causes reply colors to be white, and this error to
print in the log:

qrc:/qt/qml/org/kde/neochat/messagecontent/ReplyComponent.qml:41: TypeError: Cannot read property 'color' of null

The reason why this happens is inside of EventMessageContentModel, it
needs to be able to find the relevant event in the room to fetch the
room member (and then their color.) Dependent on many variables to
align, this can happen easily if you are faster than your server giving
you said events.

But this is an easy fix, we obviously get the event afterwards and just
need to re-evaluate the the author property. I also made sure it falls
back to some color instead of white, which will also quiet the error.
2026-01-11 16:27:44 -05:00
Lorenz Wildberg
2ec1fa92fa fix bug: room settings don't open 2026-01-11 16:08:57 +01:00
l10n daemon script
7602a56594 GIT_SILENT Sync po/docbooks with svn 2026-01-11 01:44:50 +00:00
Joshua Goins
e8da02be7d Add dialog to see who has read this message
You were previously relegated to looking at any avatars or a buggy
tooltip, but suffer no longer! If you tap on a message's read marker, a
dialog will pop up listing the users who have read it.

Uou can also view their profiles from here, etc.
2026-01-10 13:33:23 -05:00
Veres Károly
71c84be4b4 Null check decoded messages in loadPinnedMessage
`decryptMessage` returns null if it fails to decode the passed message. This value
then got fed into `EventHandler::richBody` which logged a warning and cleared `m_pinnedMessage`.
If we instead retain the value as an `EncryptedEvent`, the UI will pin the encrypted event
placeholder instead of hiding the existence of pins.
2026-01-10 14:48:09 +00:00
Veres Károly
b684fb451d Null check pinned messages after decryption when filling PinnedMessageModel.
`room()->decryptMessage()` returns null if the message fails to decode. Since elements of `m_pinnedEvents`
get directly dereferenced in getEventForIndex, storing null values leads to a segfault.
In this case we should retain the EncryptedEvent to let the UI report the error.
2026-01-10 14:48:09 +00:00
Joshua Goins
3a416990ca Make clicking room list section headers more reliable
ListSectionHeader itself is an ItemDelegate, which eats up input events.
We can work around this by also listening to onClicked there too.
2026-01-09 23:01:11 -05:00
Joshua Goins
9b7d16973d Improve the Advanced Room settings page more
I added a separator, made it so the "Copy Room ID to clipboard" button
is always shown, and made it more obvious that "Upgrade Room" prompts
you first.
2026-01-09 23:01:02 -05:00
Joshua Goins
dd59e63574 Refer to user's power level as "Power Level", not "Role"
This is a more accurate name for this.
2026-01-09 23:00:52 -05:00
Joshua Goins
7f7e48dfd4 Fix undefined references in UserDetailDialog
This can happen when viewing non-room profiles.
2026-01-09 23:00:52 -05:00
Joshua Goins
7119132e4b Add a way to add private notes for users on their profile
This is a common feature in other chat applications (like Discord) as a
way to keep track of important information. While there isn't a standard
API for this, we can use account data to store notes.
2026-01-09 21:06:10 -05:00
l10n daemon script
6001cc6d4f GIT_SILENT Sync po/docbooks with svn 2026-01-10 01:53:22 +00:00
l10n daemon script
989f1ad020 GIT_SILENT Sync po/docbooks with svn 2026-01-09 01:43:14 +00:00
107 changed files with 21802 additions and 12122 deletions

View File

@@ -208,7 +208,7 @@ void TimelineMessageModelTest::idToRow()
auto room = new TestUtils::TestRoom(connection, u"#myroom:kde.org"_s, u"test-min-sync.json"_s);
model->setRoom(room);
QCOMPARE(model->indexforEventId(u"$153456789:example.org"_s).row(), 0);
QCOMPARE(model->indexForEventId(u"$153456789:example.org"_s).row(), 0);
}
void TimelineMessageModelTest::cleanup()

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

@@ -37,6 +37,8 @@ qt_add_library(neochat STATIC
texttospeechhelper.cpp
models/limitermodel.cpp
models/limitermodel.h
supportcontroller.cpp
supportcontroller.h
)
set_source_files_properties(qml/OsmLocationPlugin.qml PROPERTIES
@@ -106,6 +108,8 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/NewPollDialog.qml
qml/UserMenu.qml
qml/MeetingDialog.qml
qml/SeenByDialog.qml
qml/SupportDialog.qml
DEPENDENCIES
QtCore
QtQuick
@@ -216,6 +220,7 @@ target_link_libraries(neochat PUBLIC
KF6::ItemModels
KF6::I18nQml
KirigamiApp
KirigamiAddonsComponents
QuotientQt6
Login
Rooms

View File

@@ -11,6 +11,7 @@
#include <KNotification>
#include <KNotificationPermission>
#include <KNotificationReplyAction>
#include <KirigamiAddons/Components/NameUtils>
#include <QPainter>
#include <Quotient/accountregistry.h>
@@ -144,16 +145,9 @@ void NotificationsManager::processNotificationJob(QPointer<NeoChatConnection> co
body = notification["event"_L1]["content"_L1]["body"_L1].toString();
}
QImage avatar_image;
if (!sender.avatarUrl().isEmpty()) {
avatar_image = room->member(sender.id()).avatar(128, 128, {});
} else {
avatar_image = room->avatar(128);
}
postNotification(dynamic_cast<NeoChatRoom *>(room),
sender.displayName(),
room->member(sender.id()),
body,
avatar_image,
notification["event"_L1].toObject()["event_id"_L1].toString(),
true,
pair.first);
@@ -195,9 +189,8 @@ bool NotificationsManager::shouldPostNotification(QPointer<NeoChatConnection> co
}
void NotificationsManager::postNotification(NeoChatRoom *room,
const QString &sender,
const RoomMember &member,
const QString &text,
const QImage &icon,
const QString &replyEventId,
bool canReply,
qint64 timestamp)
@@ -222,11 +215,11 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
if (room->isDirectChat()) {
entry = text.toHtmlEscaped();
} else {
entry = i18n("%1: %2", sender, text.toHtmlEscaped());
entry = i18n("%1: %2", member.displayName(), text.toHtmlEscaped());
}
notification->setText(entry);
notification->setPixmap(createNotificationImage(icon, room));
notification->setPixmap(createNotificationImage(member, room));
auto defaultAction = notification->addDefaultAction(i18n("Open NeoChat in this room"));
connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() {
@@ -294,18 +287,10 @@ void NotificationsManager::doPostInviteNotification(QPointer<NeoChatRoom> room)
}
const auto sender = room->member(roomMemberEvent->senderId());
QImage avatar_image;
if (roomMemberEvent && !room->member(roomMemberEvent->senderId()).avatarUrl().isEmpty()) {
avatar_image = room->member(roomMemberEvent->senderId()).avatar(128, 128, {});
} else {
qWarning() << "using this room's avatar";
avatar_image = room->avatar(128);
}
KNotification *notification = new KNotification(u"invite"_s);
notification->setText(i18n("%1 invited you to a room", sender.htmlSafeDisplayName()));
notification->setTitle(room->displayName());
notification->setPixmap(createNotificationImage(avatar_image, nullptr));
notification->setPixmap(createNotificationImage(sender, room));
auto defaultAction = notification->addDefaultAction(i18n("Open this invitation in NeoChat"));
connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() {
if (!room) {
@@ -411,41 +396,125 @@ void NotificationsManager::postPushNotification(const QByteArray &message)
}
}
QPixmap NotificationsManager::createNotificationImage(const QImage &icon, NeoChatRoom *room)
QPixmap NotificationsManager::createNotificationImage(const Quotient::RoomMember &member, NeoChatRoom *room)
{
QImage senderIcon = member.avatar(avatarDimension, avatarDimension, {});
bool senderIconIsPlaceholder = false;
if (senderIcon.isNull()) {
senderIcon = createPlaceholderImage(member.displayName());
senderIconIsPlaceholder = true;
}
QImage icon;
if (room->isDirectChat()) {
icon = senderIcon;
} else {
QImage roomIcon = room->avatar(avatarDimension, avatarDimension);
bool roomIconIsPlaceholder = false;
if (roomIcon.isNull()) {
roomIcon = createPlaceholderImage(room->displayName());
roomIconIsPlaceholder = true;
}
icon = createCombinedNotificationImage(senderIcon, senderIconIsPlaceholder, roomIcon, roomIconIsPlaceholder);
}
return QPixmap::fromImage(icon);
}
QImage NotificationsManager::createCombinedNotificationImage(const QImage &senderIcon,
const bool senderIconIsPlaceholder,
const QImage &roomIcon,
const bool roomIconIsPlaceholder)
{
// Handle avatars that are lopsided in one dimension
const int biggestDimension = std::max(icon.width(), icon.height());
const QRect imageRect{0, 0, biggestDimension, biggestDimension};
const int biggestDimension = std::max(senderIcon.width(), senderIcon.height());
const QRectF imageRect = QRect{0, 0, biggestDimension, biggestDimension}.toRectF();
QImage roundedImage(imageRect.size(), QImage::Format_ARGB32);
QImage roundedImage(imageRect.size().toSize(), QImage::Format_ARGB32);
roundedImage.fill(Qt::transparent);
QPainter painter(&roundedImage);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.setPen(Qt::NoPen);
// Fill background for transparent avatars
painter.setBrush(Qt::white);
painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
if (senderIconIsPlaceholder) {
painter.drawImage(imageRect, senderIcon);
} else {
// Fill background for transparent non-placeholder avatars
painter.setBrush(Qt::white);
painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
QBrush brush(icon.scaledToHeight(biggestDimension));
painter.setBrush(brush);
painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
if (room != nullptr) {
const QImage roomAvatar = room->avatar(imageRect.width(), imageRect.height());
if (!roomAvatar.isNull() && icon != roomAvatar) {
const QRect lowerQuarter{imageRect.center(), imageRect.size() / 2};
painter.setBrush(Qt::white);
painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
painter.setBrush(roomAvatar.scaled(lowerQuarter.size()));
painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
}
painter.setBrush(senderIcon.scaledToHeight(biggestDimension));
painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
}
return QPixmap::fromImage(roundedImage);
const QRectF lowerQuarter{imageRect.center(), imageRect.size() / 2.0};
if (roomIconIsPlaceholder) {
// Ditto for room icons, but we also want to "carve out" the transparent area for readability
painter.setCompositionMode(QPainter::CompositionMode_Clear);
painter.setBrush(Qt::transparent);
painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
painter.drawImage(lowerQuarter, roomIcon);
} else {
painter.setBrush(Qt::white);
painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
painter.setBrush(roomIcon.scaled(lowerQuarter.size().toSize()));
painter.drawRoundedRect(lowerQuarter, lowerQuarter.width(), lowerQuarter.height());
}
return roundedImage;
}
QImage NotificationsManager::createPlaceholderImage(const QString &name)
{
const QColor color = NameUtils().colorsFromString(name);
QImage image(avatarDimension, avatarDimension, QImage::Format_ARGB32);
image.fill(Qt::transparent);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing);
// Draw background
QColor backgroundColor = color;
backgroundColor.setAlphaF(0.07); // Same as in Kirigami Add-ons.
painter.setBrush(backgroundColor);
painter.setPen(Qt::transparent);
painter.drawRoundedRect(image.rect(), image.width(), image.height());
constexpr float borderWidth = 3.0; // Slightly bigger than in Add-ons so it renders better with QPainter at these dimensions.
// Draw border
painter.setBrush(Qt::transparent);
painter.setPen(QPen(color, borderWidth));
painter.drawRoundedRect(image.rect().toRectF().marginsRemoved(QMarginsF(borderWidth, borderWidth, borderWidth, borderWidth)),
image.width(),
image.height());
const QString initials = NameUtils().initialsFromString(name);
QTextOption option;
option.setAlignment(Qt::AlignCenter);
// Calculation similar to the one found in Kirigami Add-ons.
constexpr int largeSpacing = 8; // Same as what's defined in kirigami.
constexpr int padding = std::max(0, std::min(largeSpacing, avatarDimension - largeSpacing * 2));
QFont font;
font.setPixelSize((avatarDimension - padding) / 2);
painter.setBrush(color);
painter.setPen(color);
painter.setFont(font);
painter.drawText(image.rect(), initials, option);
return image;
}
#include "moc_notificationsmanager.cpp"

View File

@@ -13,6 +13,11 @@
#include <Quotient/csapi/notifications.h>
#include <Quotient/jobs/basejob.h>
namespace Quotient
{
class RoomMember;
}
class NeoChatConnection;
class KNotification;
class NeoChatRoom;
@@ -67,15 +72,24 @@ private:
QStringList m_connActiveJob;
void startNotificationJob(QPointer<NeoChatConnection> connection);
QPixmap createNotificationImage(const QImage &icon, NeoChatRoom *room);
/**
* @return A combined image of the sender and room's avatar.
*/
static QPixmap createNotificationImage(const Quotient::RoomMember &member, NeoChatRoom *room);
/**
* @return The sender and room icon combined together into one image. Used internally by createNotificationImage.
*/
static QImage createCombinedNotificationImage(const QImage &senderIcon, bool senderIconIsPlaceholder, const QImage &roomIcon, bool roomIconIsPlaceholder);
/**
* @return A placeholder avatar image, similar to the one found in Kirigami Add-ons.
*/
static QImage createPlaceholderImage(const QString &name);
bool shouldPostNotification(QPointer<NeoChatConnection> connection, const QJsonValue &notification);
void postNotification(NeoChatRoom *room,
const QString &sender,
const QString &text,
const QImage &icon,
const QString &replyEventId,
bool canReply,
qint64 timestamp);
void
postNotification(NeoChatRoom *room, const Quotient::RoomMember &member, const QString &text, const QString &replyEventId, bool canReply, qint64 timestamp);
void doPostInviteNotification(QPointer<NeoChatRoom> room);
@@ -84,6 +98,8 @@ private:
bool permissionAsked = false;
static constexpr int avatarDimension = 128;
private Q_SLOTS:
void processNotificationJob(QPointer<NeoChatConnection> connection, Quotient::GetNotificationsJob *job, bool initialization);
};

View File

@@ -5,6 +5,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtMultimedia
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
@@ -18,6 +19,10 @@ KirigamiComponents.ConvergentContextMenu {
required property NeoChatConnection connection
required property Kirigami.ApplicationWindow window
data: MediaDevices {
id: devices
}
Kirigami.Action {
text: i18nc("@action:button", "Show QR Code")
icon.name: "view-barcode-qr-symbolic"
@@ -33,12 +38,14 @@ KirigamiComponents.ConvergentContextMenu {
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Switch Account")
icon.name: "system-switch-user"
shortcut: "Ctrl+U"
onTriggered: (Qt.createComponent("org.kde.neochat", "AccountSwitchDialog").createObject(QQC2.Overlay.overlay, {
text: i18nc("@action:inmenu", "Scan a QR Code")
icon.name: "document-scan-symbolic"
visible: devices.videoInputs.length > 0
onTriggered: (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent("org.kde.neochat", "QrScannerPage"), {
connection: root.connection
}) as Kirigami.Dialog).open();
}, {
title: i18nc("@title", "Scan a QR Code")
})
}
Kirigami.Action {
@@ -55,14 +62,6 @@ KirigamiComponents.ConvergentContextMenu {
}
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Devices")
icon.name: "computer-symbolic"
onTriggered: {
NeoChatSettingsView.open('devices');
}
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Open Developer Tools")
icon.name: "tools"
@@ -76,14 +75,6 @@ KirigamiComponents.ConvergentContextMenu {
})
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Open Secret Backup")
icon.name: "unlock"
onTriggered: root.window.pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UnlockSSSSDialog'), {}, {
title: i18nc("@title:window", "Open Key Backup")
})
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Verify This Device")
icon.name: "security-low"
@@ -103,10 +94,25 @@ KirigamiComponents.ConvergentContextMenu {
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Logout…")
icon.name: "im-kick-user"
onTriggered: (Qt.createComponent("org.kde.neochat", "ConfirmLogoutDialog").createObject(QQC2.Overlay.overlay, {
text: i18nc("@action:inmenu Open support dialog", "Support")
icon.name: "help-contents-symbolic"
onTriggered: {
Qt.createComponent("org.kde.neochat", "SupportDialog").createObject(QQC2.Overlay.overlay, {
connection: root.connection,
}).open();
}
}
Kirigami.Action {
separator: true
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Switch Account")
icon.name: "system-switch-user"
shortcut: "Ctrl+U"
onTriggered: (Qt.createComponent("org.kde.neochat", "AccountSwitchDialog").createObject(QQC2.Overlay.overlay, {
connection: root.connection
}) as Kirigami.Dialog).open()
}) as Kirigami.Dialog).open();
}
}

View File

@@ -154,7 +154,10 @@ Components.AlbumMaximizeComponent {
onOpened: forceActiveFocus()
onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentRoom)
onItemRightClicked: {
const event = root.currentRoom.findEvent(root.currentEventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.currentRoom)
}
onSaveItem: {
var dialog = saveAsDialog.createObject(QQC2.Overlay.overlay) as Dialogs.FileDialog;

View File

@@ -6,6 +6,7 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
Kirigami.Page {
id: root
@@ -13,6 +14,8 @@ Kirigami.Page {
required property string placeholder
required property string actionText
required property string icon
required property bool reporting
required property NeoChatConnection connection
signal accepted(reason: string)
@@ -21,6 +24,15 @@ Kirigami.Page {
topPadding: 0
bottomPadding: 0
header: Kirigami.InlineMessage {
showCloseButton: false
visible: root.reporting
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)
}
QQC2.TextArea {
id: reason
placeholderText: root.placeholder
@@ -31,7 +43,7 @@ Kirigami.Page {
Keys.onReturnPressed: event => {
if (event.modifiers & Qt.ControlModifier) {
root.accepted(reason.text);
root.closeDialog();
root.Kirigami.PageStack.closeDialog();
}
}
@@ -52,14 +64,14 @@ Kirigami.Page {
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.AcceptRole
onClicked: {
root.accepted(reason.text);
root.closeDialog();
root.Kirigami.PageStack.closeDialog();
}
}
QQC2.Button {
icon.name: "dialog-cancel-symbolic"
text: i18nc("@action", "Cancel")
QQC2.DialogButtonBox.buttonRole: QQC2.DialogButtonBox.RejectRole
onClicked: root.closeDialog()
onClicked: root.Kirigami.PageStack.closeDialog()
}
}
}

View File

@@ -75,6 +75,12 @@ Kirigami.Page {
focus: true
padding: 0
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.
(timelineViewLoader.item as TimelineView).resetViewSettling();
}
actions: [
Kirigami.Action {
id: jitsiMeetingAction
@@ -113,7 +119,7 @@ Kirigami.Page {
}
},
Kirigami.Action {
visible: Kirigami.Settings.isMobile || !(root.Kirigami.PageStack.pageStack as Kirigami.PageRow).wideMode
visible: Kirigami.Settings.isMobile || !(root.Kirigami.PageStack.pageStack as Kirigami.PageRow)?.wideMode
icon.name: "view-right-new"
onTriggered: (root.QQC2.ApplicationWindow.window as Main).openRoomDrawer()
}
@@ -289,7 +295,7 @@ Kirigami.Page {
footer: Loader {
id: chatBarLoader
height: active ? (item as ChatBar).implicitHeight : 0
height: active ? (item as ChatBar)?.implicitHeight : 0
active: timelineViewLoader.active && !root.currentRoom.readOnly
sourceComponent: ChatBar {
id: chatBar
@@ -349,14 +355,17 @@ Kirigami.Page {
});
}
function onShowDelegateMenu(eventId: string, author, messageComponentType, plainText: string, richText: string, mimeType: string, progressInfo, isThread: bool, selectedText: string, hoveredLink: string) {
(delegateContextMenu.createObject(root, {
function onShowDelegateMenu(parent: QtObject, room: NeoChatRoom, eventId: string, author, messageComponentType, plainText: string, richText: string, mimeType: string, progressInfo, selectedText: string, hoveredLink: string) {
(delegateContextMenu.createObject(parent, {
room: room,
author: author,
eventId: eventId,
plainText: plainText,
mimeType: mimeType,
progressInfo: progressInfo,
messageComponentType: messageComponentType,
selectedText,
hoveredLink,
}) as DelegateContextMenu).popup();
}

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2026 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat
Kirigami.Dialog {
id: root
property var model
standardButtons: Kirigami.Dialog.NoButton
width: Math.min(QQC2.ApplicationWindow.window.width, Kirigami.Units.gridUnit * 24)
maximumHeight: Kirigami.Units.gridUnit * 24
title: i18nc("@title:menu Seen by/read marker dialog", "Seen By")
contentItem: ColumnLayout {
spacing: 0
QQC2.ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
ListView {
id: listView
model: root.model
spacing: Kirigami.Units.smallSpacing
onCountChanged: {
if (listView.count === 0) {
root.close();
}
}
delegate: Delegates.RoundedItemDelegate {
id: userDelegate
required property string displayName
required property url avatarUrl
required property color memberColor
required property string userId
implicitHeight: Kirigami.Units.gridUnit * 2
text: displayName
highlighted: false
onClicked: {
root.close();
RoomManager.resolveResource(userDelegate.userId);
}
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
KirigamiComponents.Avatar {
implicitWidth: height
sourceSize {
height: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5
width: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5
}
source: userDelegate.avatarUrl
name: userDelegate.displayName
color: userDelegate.memberColor
Layout.fillHeight: true
}
QQC2.Label {
text: userDelegate.displayName
textFormat: Text.PlainText
elide: Text.ElideRight
clip: true // Intentional to limit insane Unicode in display names
Layout.fillWidth: true
}
}
}
}
}
}
}

View File

@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: 2026 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.neochat
Kirigami.Dialog {
id: root
required property NeoChatConnection connection
readonly property SupportController supportController: SupportController {
connection: root.connection
}
readonly property bool hasSupportResources: supportController.supportPage.length > 0 && supportController.contacts.length > 0
title: i18nc("@title Support information", "Support")
width: Math.min(Kirigami.Units.gridUnit * 30, QQC2.ApplicationWindow.window.width)
ColumnLayout {
spacing: 0
FormCard.FormTextDelegate {
id: explanationTextDelegate
text: root.hasSupportResources ?
i18nc("@info:label %1 is the domain of the server", "Official support resources provided by %1:", root.connection.domain)
: i18nc("@info:label %1 is the domain of the server", "%1 has no support resources.", root.connection.domain)
}
FormCard.FormDelegateSeparator {
above: explanationTextDelegate
below: openSupportPageDelegate
visible: openSupportPageDelegate.visible
}
FormCard.FormLinkDelegate {
id: openSupportPageDelegate
icon.name: "help-contents-symbolic"
text: i18nc("@action:button Open support webpage", "Open Support")
url: root.supportController.supportPage
visible: root.supportController.supportPage.length > 0
}
FormCard.FormDelegateSeparator {
above: openSupportPageDelegate
visible: root.supportController.contacts.length > 0
}
Repeater {
model: root.supportController.contacts
delegate: FormCard.AbstractFormDelegate {
id: contactDelegate
required property string role
required property string matrixId
required property string emailAddress
background: null
Layout.fillWidth: true
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.Icon {
source: "user"
}
QQC2.Label {
text: {
// Translate known keys
if (contactDelegate.role === "m.role.admin") {
return i18nc("@info:label Adminstrator contact", "Admin")
} else if (contactDelegate.role === "m.role.security") {
return i18nc("@info:label Security contact", "Security")
}
return contactDelegate.role;
}
elide: Text.ElideRight
Layout.fillWidth: true
}
QQC2.ToolButton {
visible: contactDelegate.matrixId.length > 0
icon.name: "document-send-symbolic"
onClicked: {
root.close();
root.connection.requestDirectChat(contactDelegate.matrixId);
}
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: i18nc("@info:tooltip %1 is a Matrix ID", "Contact via Matrix (%1)", contactDelegate.matrixId)
}
QQC2.ToolButton {
visible: contactDelegate.emailAddress.length > 0
icon.name: "mail-sent-symbolic"
onClicked: Qt.openUrlExternally("mailto:%1".arg(contactDelegate.emailAddress))
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.text: i18nc("@info:tooltip %1 is an e-mail address", "Contact via e-mail (%1)", contactDelegate.emailAddress)
}
}
}
}
}
}

View File

@@ -13,7 +13,7 @@ FormCard.FormCardPage {
property bool processing: false
title: i18nc("@title:window", "Load your encrypted messages")
title: i18nc("@title:window", "Manage Secret Backup")
topPadding: Kirigami.Units.gridUnit
leftPadding: 0

View File

@@ -176,9 +176,11 @@ Kirigami.Dialog {
onTriggered: {
let dialog = ((root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Report User"),
placeholder: i18nc("@info:placeholder", "Reason for reporting this user"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for reporting this user"),
icon: "dialog-warning-symbolic",
actionText: i18nc("@action:button 'Report' as in 'Report this user to the administrators'", "Report")
actionText: i18nc("@action:button 'Report' as in 'Report this user to the administrators'", "Report"),
reporting: true,
connection: root.connection,
}, {
title: i18nc("@title", "Report User"),
width: Kirigami.Units.gridUnit * 25
@@ -232,16 +234,23 @@ Kirigami.Dialog {
actions: [
Kirigami.Action {
visible: !root.isSelf && root.room.canSendState("kick") && root.room.containsUser(root.user.id) && root.room.memberEffectivePowerLevel(root.user.id) < root.room.memberEffectivePowerLevel(root.connection.localUserId)
visible: {
if (root.room) {
return !root.isSelf && root.room.canSendState("kick") && root.room.containsUser(root.user.id) && root.room.memberEffectivePowerLevel(root.user.id) < root.room.memberEffectivePowerLevel(root.connection.localUserId);
}
return false;
}
text: i18nc("@action:button Kick the user from the room", "Kick…")
icon.name: "im-kick-user"
onTriggered: {
let dialog = (root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Kick User"),
placeholder: i18nc("@info:placeholder", "Reason for kicking this user"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for kicking this user"),
actionText: i18nc("@action:button 'Kick' as in 'Kick this user from the room'", "Kick"),
icon: "im-kick-user"
icon: "im-kick-user",
reporting: false,
connection: root.connection,
}, {
title: i18nc("@title:dialog", "Kick User"),
width: Kirigami.Units.gridUnit * 25
@@ -253,7 +262,12 @@ Kirigami.Dialog {
}
},
Kirigami.Action {
visible: !root.isSelf && root.room.canSendState("ban") && !root.room.isUserBanned(root.user.id) && root.room.memberEffectivePowerLevel(root.user.id) < root.room.memberEffectivePowerLevel(root.connection.localUserId)
visible: {
if (root.room) {
return !root.isSelf && root.room.canSendState("ban") && !root.room.isUserBanned(root.user.id) && root.room.memberEffectivePowerLevel(root.user.id) < root.room.memberEffectivePowerLevel(root.connection.localUserId);
}
return false;
}
text: i18nc("@action:button Ban this user from the room", "Ban…")
icon.name: "im-ban-user"
@@ -261,9 +275,11 @@ Kirigami.Dialog {
onTriggered: {
let dialog = (root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Ban User"),
placeholder: i18nc("@info:placeholder", "Reason for banning this user"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for banning this user"),
actionText: i18nc("@action:button 'Ban' as in 'Ban this user'", "Ban"),
icon: "im-ban-user"
icon: "im-ban-user",
reporting: false,
connection: root.connection,
}, {
title: i18nc("@title:dialog", "Ban User"),
width: Kirigami.Units.gridUnit * 25
@@ -275,7 +291,12 @@ Kirigami.Dialog {
}
},
Kirigami.Action {
visible: !root.isSelf && root.room.canSendState("ban") && root.room.isUserBanned(root.user.id)
visible: {
if (root.room) {
return !root.isSelf && root.room.canSendState("ban") && root.room.isUserBanned(root.user.id);
}
return false;
}
text: i18nc("@action:button Unban the user from this room", "Unban")
icon.name: "im-irc"
@@ -286,7 +307,7 @@ Kirigami.Dialog {
}
},
Kirigami.Action {
visible: (root.user.id === root.connection.localUserId || root.room.canSendState("redact"))
visible: (root.user.id === root.connection.localUserId || (root.room?.canSendState("redact") ?? false))
text: i18nc("@action:button Remove messages from the user in this room", "Remove Messages…")
icon.name: "delete"
@@ -294,9 +315,11 @@ Kirigami.Dialog {
onTriggered: {
let dialog = ((root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Remove Messages"),
placeholder: i18nc("@info:placeholder", "Reason for removing this user's recent messages"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for removing this user's recent messages"),
actionText: i18nc("@action:button 'Remove' as in 'Remove these messages'", "Remove"),
icon: "delete"
icon: "delete",
reporting: false,
connection: root.connection,
}, {
title: i18nc("@title", "Remove Messages"),
width: Kirigami.Units.gridUnit * 25
@@ -311,7 +334,7 @@ Kirigami.Dialog {
}
Kirigami.Heading {
text: i18nc("@title Role such as 'Admin' or 'Moderator' for this user", "Role")
text: i18nc("@title Role such as 'Admin' or 'Moderator' for this user", "Power Level")
level: 2
visible: root.isRoomProfile
@@ -329,7 +352,12 @@ Kirigami.Dialog {
}
QQC2.Button {
visible: root.room.canSendState("m.room.power_levels") && !(root.room.roomCreatorHasUltimatePowerLevel() && root.room.isCreator(root.user.id))
visible: {
if (root.room) {
return root.room.canSendState("m.room.power_levels") && !(root.room.roomCreatorHasUltimatePowerLevel() && root.room.isCreator(root.user.id));
}
return false;
}
text: i18nc("@action:button Set the power level (such as 'Admin') for this user", "Set Power Level")
icon.name: "document-edit-symbolic"
display: QQC2.AbstractButton.IconOnly
@@ -401,5 +429,34 @@ Kirigami.Dialog {
color: Kirigami.Theme.disabledTextColor
}
}
Kirigami.Heading {
text: i18nc("@title Private note for this user", "Private Note")
level: 4
Layout.topMargin: Kirigami.Units.largeSpacing
}
QQC2.TextArea {
id: noteText
text: root.connection.noteForUser(root.user.id)
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
placeholderText: i18nc("@info:placeholder", "Only visible to you")
onTextEdited: editTimer.restart()
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
// Prevent unnecessary edits by waiting 1 second
Timer {
id: editTimer
interval: 1000
onTriggered: root.connection.setNoteForUser(root.user.id, noteText.text)
}
}
}
}

View File

@@ -29,6 +29,28 @@
#include <KIO/OpenUrlJob>
#endif
/**
* @brief Stops RoomManager from updating the last room and space config.
*/
class LastRoomBlocker
{
public:
explicit LastRoomBlocker(RoomManager *manager)
: m_manager(manager)
{
Q_ASSERT(manager);
m_manager->m_dontUpdateLastRoom = true;
}
~LastRoomBlocker()
{
m_manager->m_dontUpdateLastRoom = false;
}
private:
RoomManager *m_manager;
};
RoomManager::RoomManager(QObject *parent)
: QObject(parent)
, m_config(KSharedConfig::openStateConfig())
@@ -282,26 +304,22 @@ void RoomManager::viewEventSource(const QString &eventId)
Q_EMIT showEventSource(eventId);
}
void RoomManager::viewEventMenu(const QString &eventId, NeoChatRoom *room, const QString &selectedText, const QString &hoveredLink)
void RoomManager::viewEventMenu(QObject *parent, const RoomEvent *event, NeoChatRoom *room, const QString &selectedText, const QString &hoveredLink)
{
if (eventId.isEmpty()) {
qWarning() << "Tried to open event menu with empty event id";
if (!event) {
qWarning() << "Tried to open event menu with empty event";
return;
}
const auto it = room->findInTimeline(eventId);
if (it == room->historyEdge()) {
// This is probably a pending event
return;
}
const auto &event = **it;
Q_EMIT showDelegateMenu(eventId,
room->qmlSafeMember(event.senderId()),
MessageComponentType::typeForEvent(event),
EventHandler::plainBody(room, &event),
EventHandler::richBody(room, &event),
EventHandler::mediaInfo(room, &event)["mimeType"_L1].toString(),
room->fileTransferInfo(eventId),
Q_EMIT showDelegateMenu(parent,
room,
event->id(),
room->qmlSafeMember(event->senderId()),
MessageComponentType::typeForEvent(*event),
EventHandler::plainBody(room, event),
EventHandler::richBody(room, event),
EventHandler::mediaInfo(room, event)["mimeType"_L1].toString(),
room->fileTransferInfo(event->id()),
selectedText,
hoveredLink);
}
@@ -324,17 +342,6 @@ void RoomManager::loadInitialRoom()
resolveResource(m_arg);
}
if (m_isMobile) {
QString lastSpace = m_lastRoomConfig.readEntry(u"lastSpace"_s, QString());
// We can't have empty keys in KConfig, so we stored it as "Home"
if (lastSpace == u"Home"_s) {
lastSpace.clear();
}
setCurrentSpace(lastSpace, false);
// We don't want to open a room on startup on mobile
return;
}
if (m_currentRoom) {
// we opened a room with the arg parsing already
return;
@@ -347,16 +354,14 @@ void RoomManager::loadInitialRoom()
void RoomManager::openRoomForActiveConnection()
{
if (!m_connection) {
setCurrentRoom({});
setCurrentSpace({}, false);
return;
}
Q_ASSERT(m_connection);
auto lastSpace = m_lastRoomConfig.readEntry(u"lastSpace"_s, QString());
if (lastSpace == u"Home"_s) {
lastSpace.clear();
}
setCurrentSpace(lastSpace, true);
// We don't want to open a room on startup on mobile
setCurrentSpace(lastSpace, !m_isMobile);
}
UriResolveResult RoomManager::visitUser(User *user, const QString &action)
@@ -513,7 +518,7 @@ void RoomManager::setConnection(NeoChatConnection *connection)
Q_EMIT connectionChanged();
}
void RoomManager::setCurrentSpace(const QString &spaceId, bool setRoom)
void RoomManager::setCurrentSpace(const QString &spaceId, bool goToLastUsedRoom)
{
m_currentSpaceId = spaceId;
@@ -533,25 +538,26 @@ void RoomManager::setCurrentSpace(const QString &spaceId, bool setRoom)
m_lastRoomConfig.writeEntry(u"lastSpace"_s, spaceId.isEmpty() ? u"Home"_s : spaceId);
}
if (!setRoom) {
return;
}
// If we requested to change to the last opened room, do so:
if (goToLastUsedRoom) {
// We don't want to needlessly update the last room config here, that should only be done during explicit user action.
LastRoomBlocker blocker(this);
// We intentionally don't want to open the last room on mobile
if (m_isMobile) {
return;
}
// We can't have empty keys in KConfig, so it's stored as "Home":
if (const auto &lastRoom = m_lastRoomConfig.readEntry(spaceId.isEmpty() ? u"Home"_s : spaceId, QString()); !lastRoom.isEmpty()) {
resolveResource(lastRoom, "no_join"_L1);
return;
}
// We can't have empty keys in KConfig, so it's stored as "Home"
if (const auto &lastRoom = m_lastRoomConfig.readEntry(spaceId.isEmpty() ? u"Home"_s : spaceId, QString()); !lastRoom.isEmpty()) {
resolveResource(lastRoom, "no_join"_L1);
return;
// If no last room was opened, go to the space home:
if (!spaceId.isEmpty() && spaceId != u"DM"_s) {
resolveResource(spaceId, "no_join"_L1);
return;
}
// Fallback to no room opened:
setCurrentRoom({});
}
if (!spaceId.isEmpty() && spaceId != u"DM"_s) {
resolveResource(spaceId, "no_join"_L1);
return;
}
setCurrentRoom({});
}
QString RoomManager::findSpaceIdForCurrentRoom() const
@@ -611,21 +617,23 @@ void RoomManager::setCurrentRoom(const QString &roomId)
Q_EMIT currentRoomChanged();
if (roomId.isEmpty()) {
m_lastRoomConfig.deleteEntry(m_currentSpaceId);
return;
}
if (!m_dontUpdateLastRoom) {
if (roomId.isEmpty()) {
m_lastRoomConfig.deleteEntry(m_currentSpaceId);
return;
}
const auto spaceIdForRoom = findSpaceIdForCurrentRoom();
// We can't have empty keys in KConfig, so name it "Home"
if (spaceIdForRoom.isEmpty()) {
m_lastRoomConfig.writeEntry(u"Home"_s, roomId);
} else {
m_lastRoomConfig.writeEntry(spaceIdForRoom, roomId);
}
const auto spaceIdForRoom = findSpaceIdForCurrentRoom();
// We can't have empty keys in KConfig, so name it "Home"
if (spaceIdForRoom.isEmpty()) {
m_lastRoomConfig.writeEntry(u"Home"_s, roomId);
} else {
m_lastRoomConfig.writeEntry(spaceIdForRoom, roomId);
}
if (m_currentSpaceId != spaceIdForRoom) {
setCurrentSpace(spaceIdForRoom, false);
if (m_currentSpaceId != spaceIdForRoom) {
setCurrentSpace(spaceIdForRoom, false);
}
}
}

View File

@@ -233,7 +233,8 @@ public:
/**
* @brief Show a context menu for the given event.
*/
Q_INVOKABLE void viewEventMenu(const QString &eventId, NeoChatRoom *room, const QString &selectedText = {}, const QString &hoveredLink = {});
Q_INVOKABLE void
viewEventMenu(QObject *parent, const RoomEvent *event, NeoChatRoom *room, const QString &selectedText = {}, const QString &hoveredLink = {});
/**
* @brief Set a URL to be loaded as the initial room.
@@ -306,7 +307,9 @@ Q_SIGNALS:
/**
* @brief Request to show a menu for the given event.
*/
void showDelegateMenu(const QString &eventId,
void showDelegateMenu(QObject *parent,
NeoChatRoom *room,
const QString &eventId,
const NeochatRoomMember *author,
MessageComponentType::Type messageComponentType,
const QString &plainText,
@@ -337,6 +340,11 @@ Q_SIGNALS:
void currentSpaceChanged();
protected:
bool m_dontUpdateLastRoom = false; // Don't set directly, use LastRoomBlocker.
friend class LastRoomBlocker;
private:
bool m_isMobile = false;
@@ -382,8 +390,13 @@ private:
*/
QString findSpaceIdForCurrentRoom() const;
// Space ID, "DM", or empty string
void setCurrentSpace(const QString &spaceId, bool setRoom = true);
/**
* @brief Sets the current space.
*
* @param spaceId The ID of the space, "DM" for direct messages or an empty string for Home.
* @param goToLastUsedRoom If true, we will navigate to the last opened room in this space.
*/
void setCurrentSpace(const QString &spaceId, bool goToLastUsedRoom = true);
/**
* @brief Resolve a user URI.

View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
#include "supportcontroller.h"
#include <Quotient/csapi/support.h>
#include <QDebug>
using namespace Quotient;
void SupportController::setConnection(NeoChatConnection *connection)
{
if (m_connection != connection) {
m_connection = connection;
Q_EMIT connectionChanged();
load();
}
}
NeoChatConnection *SupportController::connection() const
{
return m_connection;
}
QString SupportController::supportPage() const
{
return m_supportPage;
}
QList<SupportContact> SupportController::contacts() const
{
return m_contacts;
}
void SupportController::load()
{
if (!m_connection) {
qWarning() << "Tried to load support information without a valid connection?";
return;
}
m_connection->callApi<GetWellknownSupportJob>()
.onResult([this](const auto &job) {
m_supportPage = job->supportPage();
m_contacts.reserve(job->contacts().size());
for (const auto &contact : job->contacts()) {
m_contacts.push_back(SupportContact{
.role = contact.role,
.matrixId = contact.matrixId,
.emailAddress = contact.emailAddress,
});
}
Q_EMIT loaded();
})
.onFailure([this](const auto &job) {
Q_UNUSED(job)
// Just do nothing, our properties will be empty.
Q_EMIT loaded();
});
}
#include "moc_supportcontroller.cpp"

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include "neochatconnection.h"
class SupportContact
{
Q_GADGET
Q_PROPERTY(QString role MEMBER role)
Q_PROPERTY(QString matrixId MEMBER matrixId)
Q_PROPERTY(QString emailAddress MEMBER emailAddress)
public:
QString role;
QString matrixId;
QString emailAddress;
};
class SupportController : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged REQUIRED)
Q_PROPERTY(QString supportPage READ supportPage NOTIFY loaded)
Q_PROPERTY(QList<SupportContact> contacts READ contacts NOTIFY loaded)
public:
void setConnection(NeoChatConnection *connection);
NeoChatConnection *connection() const;
QString supportPage() const;
QList<SupportContact> contacts() const;
Q_SIGNALS:
void connectionChanged();
void loaded();
private:
void load();
QPointer<NeoChatConnection> m_connection = nullptr;
QList<SupportContact> m_contacts;
QString m_supportPage;
};

View File

@@ -65,7 +65,7 @@ QQC2.Popup {
padding: 2
implicitHeight: Kirigami.Units.gridUnit * 20 + 2 * padding
width: Math.min(contentItem.categoryIconSize * 11 + 2 * padding, QQC2.ApplicationWindow.window.width)
width: Math.min(contentItem.categoryIconSize * 11 + 2 * padding, QQC2.ApplicationWindow.window?.width)
contentItem: EmojiPicker {
id: emojiPicker
height: 400

View File

@@ -150,7 +150,12 @@ Quotient::RoomMember ChatBarCache::relationAuthor() const
if (m_relationId.isEmpty()) {
return room->member(QString());
}
return room->member((*room->findInTimeline(m_relationId))->senderId());
const auto [event, _] = room->getEvent(m_relationId);
if (event != nullptr) {
return room->member(event->senderId());
}
qWarning() << "Failed to find relation" << m_relationId << "in timeline?";
return room->member(QString());
}
bool ChatBarCache::relationAuthorIsPresent() const
@@ -173,8 +178,8 @@ QString ChatBarCache::relationMessage() const
return {};
}
if (auto event = room->findInTimeline(m_relationId); event != room->historyEdge()) {
return EventHandler::markdownBody(&**event);
if (auto [event, _] = room->getEvent(m_relationId); event != nullptr) {
return EventHandler::markdownBody(event);
}
return {};
}
@@ -280,11 +285,6 @@ void ChatBarCache::postMessage()
return;
}
const auto replyIt = room->findInTimeline(replyId());
if (replyIt == room->historyEdge()) {
isReply = false;
}
auto content = std::make_unique<Quotient::EventContent::TextContent>(sendText, u"text/html"_s);
room->post<Quotient::RoomMessageEvent>(text(), *std::get<std::optional<Quotient::RoomMessageEvent::MsgType>>(result), std::move(content), relatesTo);

View File

@@ -619,4 +619,21 @@ bool NeoChatConnection::supportsMatrixSpecVersion(const QString &version)
return supportedMatrixSpecVersions().contains(version);
}
QString NeoChatConnection::noteForUser(const QString &userId)
{
const auto object = accountDataJson(QStringLiteral("org.kde.neochat.user_note"));
return object[userId].toString();
}
void NeoChatConnection::setNoteForUser(const QString &userId, const QString &note)
{
auto object = accountDataJson(QStringLiteral("org.kde.neochat.user_note"));
if (note.isEmpty()) {
object.remove(userId);
} else {
object[userId] = note;
}
setAccountData(QStringLiteral("org.kde.neochat.user_note"), object);
}
#include "moc_neochatconnection.cpp"

View File

@@ -234,6 +234,16 @@ public:
*/
Q_INVOKABLE bool supportsMatrixSpecVersion(const QString &version);
/**
* @return The private note for this user, if set.
*/
Q_INVOKABLE QString noteForUser(const QString &userId);
/**
* @brief Sets the private note for this user.
*/
Q_INVOKABLE void setNoteForUser(const QString &userId, const QString &note);
Q_SIGNALS:
void globalUrlPreviewEnabledChanged();
void labelChanged();

View File

@@ -1186,7 +1186,10 @@ void NeoChatRoom::loadPinnedMessage()
connection()->callApi<GetOneRoomEventJob>(id(), mostRecentEventId).then([this](const auto &job) {
auto event = fromJson<event_ptr_tt<RoomEvent>>(job->jsonData());
if (auto encEv = eventCast<EncryptedEvent>(event.get())) {
event = decryptMessage(*encEv);
auto decryptedMessage = decryptMessage(*encEv);
if (decryptedMessage) {
event = std::move(decryptedMessage);
}
}
m_pinnedMessage = EventHandler::richBody(this, event.get());
Q_EMIT pinnedMessageChanged();
@@ -1654,6 +1657,12 @@ void NeoChatRoom::downloadEventFromServer(const QString &eventId)
}
event_ptr_tt<RoomEvent> event = fromJson<event_ptr_tt<RoomEvent>>(job->jsonData());
if (auto encEv = eventCast<EncryptedEvent>(event.get())) {
auto decryptedEvent = decryptMessage(*encEv);
if (decryptedEvent) {
event = std::move(decryptedEvent);
}
}
m_extraEvents.push_back(std::move(event));
Q_EMIT extraEventLoaded(eventId);
},
@@ -1691,6 +1700,11 @@ std::pair<const Quotient::RoomEvent *, bool> NeoChatRoom::getEvent(const QString
return std::make_pair(extraIt != m_extraEvents.end() ? extraIt->get() : nullptr, false);
}
const RoomEvent *NeoChatRoom::findEvent(const QString &eventId) const
{
return getEvent(eventId).first;
}
const RoomEvent *NeoChatRoom::getReplyForEvent(const RoomEvent &event) const
{
#if Quotient_VERSION_MINOR > 9

View File

@@ -538,6 +538,13 @@ public:
*/
std::pair<const Quotient::RoomEvent *, bool> getEvent(const QString &eventId) const;
/**
* @brief Returns the event object with the given ID if available.
*
* This function works identically to getEvent, except this is usable from QML.
*/
Q_INVOKABLE const Quotient::RoomEvent *findEvent(const QString &eventId) const;
/**
* @brief Returns the event that is being replied to. This includes events that were manually loaded using NeoChatRoom::loadReply.
*/

View File

@@ -130,7 +130,10 @@ QQC2.Control {
TapHandler {
acceptedDevices: PointerDevice.TouchScreen
onLongPressed: RoomManager.viewEventMenu(root.eventId, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
onLongPressed: {
const event = root.Message.room.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
}
}
background: null

View File

@@ -66,7 +66,10 @@ QQC2.Control {
enabled: !quoteText.hoveredLink
acceptedDevices: PointerDevice.TouchScreen
acceptedButtons: Qt.LeftButton
onLongPressed: RoomManager.viewEventMenu(root.eventId, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
onLongPressed: {
const event = root.Message.room.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
}
}
}

View File

@@ -38,7 +38,7 @@ RowLayout {
Layout.fillHeight: true
implicitWidth: Kirigami.Units.smallSpacing
color: root.replyContentModel.author.color
color: root.replyContentModel.author?.color ?? Kirigami.Theme.highlightColor
radius: Kirigami.Units.cornerRadius
}
ColumnLayout {

View File

@@ -86,11 +86,12 @@ RowLayout {
QtObject {
id: _private
function showMessageMenu() {
function showMessageMenu(): void {
if (!NeoChatConfig.developerTools) {
return;
}
RoomManager.viewEventMenu(root.modelData.eventId, root.Message.room, root.author, "", "");
const event = root.Message.room.findEvent(root.modelData.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.Message.room, root.author);
}
}
}

View File

@@ -97,12 +97,18 @@ TextEdit {
enabled: !root.hoveredLink
acceptedButtons: Qt.LeftButton
acceptedDevices: PointerDevice.TouchScreen
onLongPressed: RoomManager.viewEventMenu(root.eventId, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
onLongPressed: {
const event = root.Message.room.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
}
}
TapHandler {
acceptedButtons: Qt.RightButton
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad | PointerDevice.Stylus
gesturePolicy: TapHandler.WithinBounds
onTapped: RoomManager.viewEventMenu(root.eventId, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
onTapped: {
const event = root.Message.room.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.Message.room, root.Message.selectedText, root.Message.hoveredLink);
}
}
}

View File

@@ -269,6 +269,8 @@ void EventMessageContentModel::resetModel()
updateItineraryModel();
Q_EMIT componentsUpdated();
// We need QML to re-evaluate author (for example, reply colors) if it was previously null.
Q_EMIT authorChanged();
}
void EventMessageContentModel::resetContent(bool isEditing, bool isThreading)

View File

@@ -87,7 +87,6 @@ QDateTime MessageContentModel::time() const
QString MessageContentModel::timeString() const
{
return time().toLocalTime().toString(u"hh:mm"_s);
;
}
QString MessageContentModel::authorId() const

View File

@@ -8,6 +8,7 @@ import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat
import org.kde.neochat.timeline as Timeline
import org.kde.neochat.settings as Settings
/**
* @brief Page for holding a room drawer component.
@@ -49,7 +50,7 @@ Kirigami.Page {
text: i18nc("@action:button", "Room settings")
icon.name: 'settings-configure-symbolic'
onTriggered: {
RoomSettingsView.openRoomSettings(root.room, RoomSettingsView.Room);
Settings.RoomSettingsView.openRoomSettings(root.room, Settings.RoomSettingsView.Room);
}
}
]

View File

@@ -52,7 +52,6 @@ QQC2.ScrollView {
delegate: Timeline.MessageDelegate {
alwaysFillWidth: true
cardBackground: false
room: root.room
}
}
@@ -61,7 +60,6 @@ QQC2.ScrollView {
delegate: Timeline.MessageDelegate {
alwaysFillWidth: true
cardBackground: false
room: root.room
}
}
}

View File

@@ -3,6 +3,8 @@
import QtQuick
import org.kde.kirigami as Kirigami
import org.kde.neochat.libneochat
import org.kde.neochat.timeline
@@ -45,4 +47,15 @@ SearchPage {
noResultPlaceholderMessage: i18n("No messages found")
listVerticalLayoutDirection: ListView.BottomToTop
Connections {
target: root.room.mainCache
function onRelationIdChanged(oldEventId: string, newEventId: string): void {
// If we start replying/editing an event, we need to close the search dialog so the user can type.
if (newEventId.length > 0) {
root.Kirigami.PageStack.closeDialog();
}
}
}
}

View File

@@ -15,9 +15,7 @@ import org.kde.neochat
RowLayout {
id: root
property var desiredWidth
property bool collapsed: false
required property NeoChatConnection connection
signal search
@@ -26,10 +24,6 @@ RowLayout {
*/
signal textChanged(string newText)
MediaDevices {
id: devices
}
Kirigami.Heading {
Layout.fillWidth: true
visible: !root.collapsed
@@ -51,78 +45,4 @@ RowLayout {
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
id: menuButton
property QQC2.Menu menuItem: undefined
function openMenu(): void {
if (!menuItem || !menuItem.visible) {
menuItem = menu.createObject(menuButton) as QQC2.Menu;
menuItem.closed.connect(menuButton.toggle);
menuItem.open();
} else {
menuItem.dismiss()
}
}
Accessible.role: Accessible.ButtonMenu
display: QQC2.AbstractButton.IconOnly
down: pressed || menuItem.visible
text: i18nc("@action:button", "Show Menu")
icon.name: "application-menu-symbolic"
onPressed: openMenu()
Keys.onReturnPressed: openMenu()
Keys.onEnterPressed: openMenu()
Accessible.onPressAction: openMenu()
QQC2.ToolTip.visible: hovered && !menuItem.visible
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
Component {
id: menu
QQC2.Menu {
y: menuButton.height
QQC2.MenuItem {
text: i18n("Find your friends")
icon.name: "list-add-user"
onTriggered: (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage'), {
connection: root.connection
}, {
title: i18nc("@title", "Find your friends")
})
}
QQC2.MenuItem {
text: i18n("Create a Room")
icon.name: "system-users-symbolic"
onTriggered: {
(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog').createObject(root, {
connection: root.connection
}) as CreateRoomDialog).open();
}
Kirigami.Action {
shortcut: StandardKey.New
onTriggered: parent.trigger()
}
}
QQC2.MenuItem {
text: i18n("Scan a QR Code")
icon.name: "view-barcode-qr"
visible: devices.videoInputs.length > 0
onTriggered: (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent("org.kde.neochat", "QrScannerPage"), {
connection: root.connection
}, {
title: i18nc("@title", "Scan a QR Code")
})
}
}
}
}

View File

@@ -157,9 +157,11 @@ KirigamiComponents.ConvergentContextMenu {
onTriggered: {
let dialog = (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Report Room"),
placeholder: i18nc("@info:placeholder", "Reason for reporting this room"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for reporting this room"),
icon: "dialog-warning-symbolic",
actionText: i18nc("@action:button 'Report' as in 'Report this room to the administrators'", "Report")
actionText: i18nc("@action:button 'Report' as in 'Report this room to the administrators'", "Report"),
reporting: true,
connection: root.connection,
}, {
title: i18nc("@title", "Report Room"),
width: Kirigami.Units.gridUnit * 25

View File

@@ -270,9 +270,7 @@ Kirigami.Page {
Component {
id: exploreComponent
ExploreComponent {
desiredWidth: root.width - Kirigami.Units.largeSpacing
collapsed: root.collapsed
connection: root.connection
onSearch: root.search()

View File

@@ -46,6 +46,8 @@ QQC2.ItemDelegate {
topPadding: 0
bottomPadding: 0
text: root.collapsed ? "" : root.displayName
onClicked: root.treeView.toggleExpanded(row)
}
QQC2.ToolButton {
id: collapseButton

View File

@@ -9,6 +9,7 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.neochat
@@ -158,7 +159,7 @@ QQC2.Control {
width: Math.max(directChatNotificationCountTextMetrics.advanceWidth + Kirigami.Units.smallSpacing * 2, height)
height: Kirigami.Units.iconSizes.smallMedium
text: visible ? root.connection.directChatNotifications + root.connection.directChatInvites : ""
text: visible ? directChatButton.countedNotifications : ""
visible: directChatButton.hasCountableNotifications && RoomManager.currentSpace !== "DM"
color: Kirigami.Theme.textColor
horizontalAlignment: Text.AlignHCenter
@@ -263,21 +264,46 @@ QQC2.Control {
}
AvatarTabButton {
id: createNewButton
Layout.fillWidth: true
Layout.preferredHeight: width - Kirigami.Units.smallSpacing
Layout.maximumHeight: width - Kirigami.Units.smallSpacing
text: i18n("Create a space")
text: i18nc("@action:button Create a new room or space", "Create")
contentItem: Kirigami.Icon {
source: "list-add"
}
activeFocusOnTab: true
down: menu.opened
onSelected: {
(Qt.createComponent('org.kde.neochat', 'CreateSpaceDialog').createObject(root, {
connection: root.connection
}) as CreateSpaceDialog).open();
onSelected: menu.popup(root.QQC2.Overlay.overlay, createNewButton.mapToGlobal(Qt.point(createNewButton.width, 0)))
KirigamiComponents.ConvergentContextMenu {
id: menu
Kirigami.Action {
text: i18nc("@action:button Create a new room", "New Room…")
icon.name: "list-add-symbolic"
onTriggered: {
(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog').createObject(root, {
connection: root.connection
}) as CreateRoomDialog).open();
}
}
Kirigami.Action {
text: i18nc("@action:button Create a new room", "New Space…")
icon.name: "list-add-symbolic"
onTriggered: {
(Qt.createComponent('org.kde.neochat', 'CreateSpaceDialog').createObject(root, {
connection: root.connection
}) as CreateSpaceDialog).open();
}
}
}
}

View File

@@ -51,6 +51,7 @@ ecm_add_qml_module(Settings GENERATE_PLUGIN_SOURCE
RoomProfile.qml
RoomAdvancedPage.qml
KeyboardShortcutsPage.qml
Members.qml
SOURCES
colorschemer.cpp
threepidaddhelper.cpp

View File

@@ -36,9 +36,10 @@ FormCard.FormCardPage {
devicesModel: root.devicesModel
FormCard.FormButtonDelegate {
icon.name: "security-low"
icon.name: !root.connection.isVerifiedSession ? "security-low" : "security-high"
text: i18nc("@action:button", "Verify This Device")
description: i18nc("@info:description", "This device is marked as insecure until it's verified by another device. It's recommended to verify as soon as possible.")
description: !root.connection.isVerifiedSession ? i18nc("@info:description", "This device is marked as insecure until it's verified by another device. It's recommended to verify as soon as possible.")
: i18nc("@info:description", "This device is marked as secure.")
visible: !root.connection.isVerifiedSession || NeoChatConfig.alwaysVerifyDevice
onClicked: {
root.connection.startSelfVerification();

247
src/settings/Members.qml Normal file
View File

@@ -0,0 +1,247 @@
// SPDX-FileCopyrightText: 2022 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.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.kitemmodels
import org.kde.neochat
FormCard.FormCardPage {
id: root
property NeoChatRoom room
title: i18nc("@title:window", "Members")
readonly property bool loading: permissions.count === 0 && !root.room.roomCreatorHasUltimatePowerLevel()
readonly property PowerLevelModel powerLevelModel: PowerLevelModel {
showMute: false
}
FormCard.FormHeader {
title: i18nc("@title", "Privileged Members")
visible: !root.loading
}
FormCard.FormCard {
visible: !root.loading
FormCard.AbstractFormDelegate {
id: userListSearchCard
visible: root.room.canSendState("m.room.power_levels")
contentItem: Kirigami.SearchField {
id: userListSearchField
autoAccept: false
Layout.fillWidth: true
Keys.onUpPressed: userListView.decrementCurrentIndex()
Keys.onDownPressed: userListView.incrementCurrentIndex()
onAccepted: (userListView.itemAtIndex(userListView.currentIndex) as Delegates.RoundedItemDelegate).action.trigger()
}
QQC2.Popup {
id: userListSearchPopup
x: userListSearchField.x
y: userListSearchField.y - height
width: userListSearchField.width
height: {
let maxHeight = userListSearchField.mapToGlobal(userListSearchField.x, userListSearchField.y).y - Kirigami.Units.largeSpacing * 3;
let minHeight = Kirigami.Units.gridUnit * 2 + userListSearchPopup.padding * 2;
let filterContentHeight = userListView.contentHeight + userListSearchPopup.padding * 2;
return Math.max(Math.min(filterContentHeight, maxHeight), minHeight);
}
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.smallSpacing / 2
rightPadding: Kirigami.Units.smallSpacing / 2
modal: false
onClosed: userListSearchField.text = ""
background: Kirigami.ShadowedRectangle {
property color borderColor: Kirigami.Theme.textColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
width: 1
}
shadow {
xOffset: 0
yOffset: 4
color: Qt.rgba(0, 0, 0, 0.3)
size: 8
}
}
contentItem: QQC2.ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: userListView
clip: true
model: UserFilterModel {
id: userListFilterModel
sourceModel: RoomManager.userListModel
filterText: userListSearchField.text
onFilterTextChanged: {
if (filterText.length > 0 && !userListSearchPopup.visible) {
userListSearchPopup.open();
} else if (filterText.length <= 0 && userListSearchPopup.visible) {
userListSearchPopup.close();
}
}
}
delegate: Delegates.RoundedItemDelegate {
id: userListItem
required property string userId
required property url avatar
required property string name
required property int powerLevel
required property string powerLevelString
text: name
contentItem: RowLayout {
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: userListItem.avatar
name: userListItem.name
}
Delegates.SubtitleContentItem {
itemDelegate: userListItem
subtitle: userListItem.userId
labelItem.textFormat: Text.PlainText
subtitleItem.textFormat: Text.PlainText
Layout.fillWidth: true
}
QQC2.Label {
visible: userListItem.powerLevel > 0
text: userListItem.powerLevelString
color: Kirigami.Theme.disabledTextColor
textFormat: Text.PlainText
wrapMode: Text.NoWrap
}
}
onClicked: {
userListSearchPopup.close();
(powerLevelDialog.createObject(root.QQC2.Overlay.overlay, {
room: root.room,
userId: userListItem.userId,
powerLevel: userListItem.powerLevel
}) as PowerLevelDialog).open();
}
Component {
id: powerLevelDialog
PowerLevelDialog {}
}
}
QQC2.Label {
text: i18nc("@info", "No users found.")
visible: userListView.count === 0
anchors {
left: parent.left
leftMargin: Kirigami.Units.mediumSpacing
verticalCenter: parent.verticalCenter
}
}
}
}
}
}
FormCard.FormDelegateSeparator {
above: userListSearchCard
}
Repeater {
id: permissions
model: KSortFilterProxyModel {
sourceModel: RoomManager.userListModel
sortRoleName: "powerLevel"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (source_row, source_parent) {
let powerLevelRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), UserListModel.PowerLevelRole);
return powerLevelRole != 0;
}
}
delegate: FormCard.FormTextDelegate {
id: privilegedUserDelegate
required property string userId
required property string name
required property int powerLevel
required property string powerLevelString
required property bool isCreator
text: name
textItem.textFormat: Text.PlainText
description: userId
contentItem.children: RowLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: powerLevelLabel
text: privilegedUserDelegate.powerLevelString
visible: (!root.room.canSendState("m.room.power_levels") || (root.room.memberEffectivePowerLevel(root.room.localMember.id) <= privilegedUserDelegate.powerLevel && privilegedUserDelegate.userId != root.room.localMember.id)) || privilegedUserDelegate.isCreator
color: Kirigami.Theme.disabledTextColor
}
QQC2.ComboBox {
focusPolicy: Qt.NoFocus // provided by parent
model: PowerLevelModel {}
textRole: "name"
valueRole: "value"
visible: !powerLevelLabel.visible
Component.onCompleted: {
let index = indexOfValue(privilegedUserDelegate.powerLevel)
if (index === -1) {
displayText = privilegedUserDelegate.powerLevelString;
} else {
currentIndex = index;
}
}
onActivated: {
root.room.setUserPowerLevel(privilegedUserDelegate.userId, currentValue);
}
}
}
}
}
}
Item {
visible: root.loading
Layout.fillWidth: true
implicitHeight: root.height * 0.9
Kirigami.LoadingPlaceholder {
anchors.centerIn: parent
text: i18nc("@placeholder", "Loading…")
}
}
}

View File

@@ -85,6 +85,19 @@ FormCard.FormCardPage {
title: i18nc("@title:group", "Encryption")
}
FormCard.FormCard {
FormCard.FormButtonDelegate {
id: secretBackupDelegate
text: i18nc("@action:inmenu", "Manage Secret Backup")
description: i18nc("@info", "Import or unlock encryption keys from other devices.")
icon.name: "unlock"
onClicked: root.QQC2.ApplicationWindow.window.pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UnlockSSSSDialog'), {}, {
title: i18nc("@title:window", "Manage Secret Backup")
})
}
FormCard.FormDelegateSeparator {
above: secretBackupDelegate
below: importKeysDelegate
}
FormCard.FormButtonDelegate {
id: importKeysDelegate
text: i18nc("@action:button", "Import Keys")

View File

@@ -33,207 +33,10 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
title: i18nc("@title", "Privileged Users")
visible: !root.loading
title: i18nc("@title", "Power Levels")
}
FormCard.FormCard {
visible: !root.loading
Repeater {
id: permissions
model: KSortFilterProxyModel {
sourceModel: RoomManager.userListModel
sortRoleName: "powerLevel"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (source_row, source_parent) {
let powerLevelRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), UserListModel.PowerLevelRole);
return powerLevelRole != 0;
}
}
delegate: FormCard.FormTextDelegate {
id: privilegedUserDelegate
required property string userId
required property string name
required property int powerLevel
required property string powerLevelString
required property bool isCreator
text: name
textItem.textFormat: Text.PlainText
description: userId
contentItem.children: RowLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: powerLevelLabel
text: privilegedUserDelegate.powerLevelString
visible: (!root.room.canSendState("m.room.power_levels") || (root.room.memberEffectivePowerLevel(root.room.localMember.id) <= privilegedUserDelegate.powerLevel && privilegedUserDelegate.userId != root.room.localMember.id)) || privilegedUserDelegate.isCreator
color: Kirigami.Theme.disabledTextColor
}
QQC2.ComboBox {
focusPolicy: Qt.NoFocus // provided by parent
model: PowerLevelModel {}
textRole: "name"
valueRole: "value"
visible: !powerLevelLabel.visible
Component.onCompleted: {
let index = indexOfValue(privilegedUserDelegate.powerLevel)
if (index === -1) {
displayText = privilegedUserDelegate.powerLevelString;
} else {
currentIndex = index;
}
}
onActivated: {
root.room.setUserPowerLevel(privilegedUserDelegate.userId, currentValue);
}
}
}
}
}
FormCard.FormDelegateSeparator {
below: userListSearchCard
}
FormCard.AbstractFormDelegate {
id: userListSearchCard
visible: root.room.canSendState("m.room.power_levels")
contentItem: Kirigami.SearchField {
id: userListSearchField
autoAccept: false
Layout.fillWidth: true
Keys.onUpPressed: userListView.decrementCurrentIndex()
Keys.onDownPressed: userListView.incrementCurrentIndex()
onAccepted: (userListView.itemAtIndex(userListView.currentIndex) as Delegates.RoundedItemDelegate).action.trigger()
}
QQC2.Popup {
id: userListSearchPopup
x: userListSearchField.x
y: userListSearchField.y - height
width: userListSearchField.width
height: {
let maxHeight = userListSearchField.mapToGlobal(userListSearchField.x, userListSearchField.y).y - Kirigami.Units.largeSpacing * 3;
let minHeight = Kirigami.Units.gridUnit * 2 + userListSearchPopup.padding * 2;
let filterContentHeight = userListView.contentHeight + userListSearchPopup.padding * 2;
return Math.max(Math.min(filterContentHeight, maxHeight), minHeight);
}
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.smallSpacing / 2
rightPadding: Kirigami.Units.smallSpacing / 2
modal: false
onClosed: userListSearchField.text = ""
background: Kirigami.ShadowedRectangle {
property color borderColor: Kirigami.Theme.textColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
width: 1
}
shadow {
xOffset: 0
yOffset: 4
color: Qt.rgba(0, 0, 0, 0.3)
size: 8
}
}
contentItem: QQC2.ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: userListView
clip: true
model: UserFilterModel {
id: userListFilterModel
sourceModel: RoomManager.userListModel
filterText: userListSearchField.text
onFilterTextChanged: {
if (filterText.length > 0 && !userListSearchPopup.visible) {
userListSearchPopup.open();
} else if (filterText.length <= 0 && userListSearchPopup.visible) {
userListSearchPopup.close();
}
}
}
delegate: Delegates.RoundedItemDelegate {
id: userListItem
required property string userId
required property url avatar
required property string name
required property int powerLevel
required property string powerLevelString
text: name
contentItem: RowLayout {
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: userListItem.avatar
name: userListItem.name
}
Delegates.SubtitleContentItem {
itemDelegate: userListItem
subtitle: userListItem.userId
labelItem.textFormat: Text.PlainText
subtitleItem.textFormat: Text.PlainText
Layout.fillWidth: true
}
QQC2.Label {
visible: userListItem.powerLevel > 0
text: userListItem.powerLevelString
color: Kirigami.Theme.disabledTextColor
textFormat: Text.PlainText
wrapMode: Text.NoWrap
}
}
onClicked: {
userListSearchPopup.close();
(powerLevelDialog.createObject(root.QQC2.Overlay.overlay, {
room: root.room,
userId: userListItem.userId,
powerLevel: userListItem.powerLevel
}) as PowerLevelDialog).open();
}
Component {
id: powerLevelDialog
PowerLevelDialog {}
}
}
}
}
}
}
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Default permissions")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
@@ -269,11 +72,49 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Basic permissions")
title: i18nc("@title", "Messages")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
return sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsMessagePermissionRole);
}
}
delegate: FormCard.FormComboBoxDelegate {
required property string name
required property string subtitle
required property string type
required property int level
required property string levelName
text: name
description: subtitle
textRole: "name"
valueRole: "value"
model: root.powerLevelModel
Component.onCompleted: {
let index = indexOfValue(level)
if (index === -1) {
displayText = levelName;
} else {
currentIndex = index;
}
}
onCurrentValueChanged: if (root.room.canSendState("m.room.power_levels")) {
root.permissionsModel.setPowerLevel(type, currentValue);
}
}
}
}
FormCard.FormHeader {
title: i18nc("@title", "Moderation")
}
FormCard.FormCard {
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
@@ -309,18 +150,15 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Event permissions")
title: i18nc("@title", "General")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
let isBasicPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsBasicPermissionRole);
let isDefaultValueRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsDefaultValueRole);
return !isBasicPermissionRole && !isDefaultValueRole;
return sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsGeneralPermissionRole);
}
}
delegate: FormCard.FormComboBoxDelegate {
@@ -348,7 +186,59 @@ FormCard.FormCardPage {
}
}
}
}
FormCard.FormHeader {
title: i18nc("@title", "Other Events")
}
FormCard.FormCard {
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
id: otherEventsRepeater
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
let isBasicPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsBasicPermissionRole);
let isDefaultValueRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsDefaultValueRole);
let isMessagePermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsMessagePermissionRole);
let isGeneralPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsGeneralPermissionRole);
return !isBasicPermissionRole && !isDefaultValueRole && !isMessagePermissionRole && !isGeneralPermissionRole;
}
}
delegate: FormCard.FormComboBoxDelegate {
required property string name
required property string subtitle
required property string type
required property int level
required property string levelName
text: name
description: subtitle
textRole: "name"
valueRole: "value"
model: root.powerLevelModel
Component.onCompleted: {
let index = indexOfValue(level)
if (index === -1) {
displayText = levelName;
} else {
currentIndex = index;
}
}
onCurrentValueChanged: if (root.room.canSendState("m.room.power_levels")) {
root.permissionsModel.setPowerLevel(type, currentValue);
}
}
}
FormCard.FormDelegateSeparator {
below: addNewEventDelegate
visible: otherEventsRepeater.count > 0
}
FormCard.AbstractFormDelegate {
id: addNewEventDelegate
Layout.fillWidth: true
contentItem: RowLayout {

View File

@@ -28,7 +28,6 @@ FormCard.FormCardPage {
description: root.room.id
contentItem.children: QQC2.Button {
visible: roomIdDelegate.hovered
text: i18nc("@action:button", "Copy room ID to clipboard")
icon.name: "edit-copy"
display: QQC2.AbstractButton.IconOnly
@@ -42,14 +41,19 @@ FormCard.FormCardPage {
QQC2.ToolTip.visible: hovered
}
}
FormCard.FormDelegateSeparator {
above: roomIdDelegate
below: roomVersionDelegate
}
FormCard.FormTextDelegate {
id: roomVersionDelegate
text: i18nc("@info:label", "Room Version")
description: root.room.version
contentItem.children: QQC2.Button {
visible: root.room.canSwitchVersions()
enabled: root.room.version < root.room.maxRoomVersion
text: i18nc("@action:button", "Upgrade Room")
text: i18nc("@action:button", "Upgrade Room")
icon.name: "arrow-up-double"
onClicked: {

View File

@@ -55,6 +55,17 @@ KirigamiSettings.ConfigurationView {
};
}
},
KirigamiSettings.ConfigurationModule {
moduleId: "members"
text: i18nc("@title", "Members")
icon.name: "system-users-symbolic"
page: () => Qt.createComponent("org.kde.neochat.settings", "Members")
initialProperties: () => {
return {
room: root._room
};
}
},
KirigamiSettings.ConfigurationModule {
moduleId: "permissions"
text: i18nc("@title", "Permissions")

View File

@@ -50,12 +50,16 @@ static const QStringList knownPermissions = {
u"m.room.server_acl"_s,
u"m.space.child"_s,
u"m.space.parent"_s,
u"org.matrix.msc3672.beacon_info"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Alternate name text for default permissions.
static const QHash<QString, KLazyLocalizedString> permissionNames = {
{UsersDefaultKey, kli18nc("Room permission type", "Default user power level")},
{StateDefaultKey, kli18nc("Room permission type", "Default power level to set the room state")},
{UsersDefaultKey, kli18nc("Room permission type", "Default power level")},
{StateDefaultKey, kli18nc("Room permission type", "Default power level to change room state")},
{EventsDefaultKey, kli18nc("Room permission type", "Default power level to send messages")},
{InviteKey, kli18nc("Room permission type", "Invite users")},
{KickKey, kli18nc("Room permission type", "Kick users")},
@@ -70,25 +74,58 @@ static const QHash<QString, KLazyLocalizedString> permissionNames = {
{u"m.room.topic"_s, kli18nc("Room permission type", "Change the room topic")},
{u"m.room.encryption"_s, kli18nc("Room permission type", "Enable encryption for the room")},
{u"m.room.history_visibility"_s, kli18nc("Room permission type", "Change the room history visibility")},
{u"m.room.pinned_events"_s, kli18nc("Room permission type", "Set pinned events")},
{u"m.room.pinned_events"_s, kli18nc("Room permission type", "Pin and unpin messages")},
{u"m.room.tombstone"_s, kli18nc("Room permission type", "Upgrade the room")},
{u"m.room.server_acl"_s, kli18nc("Room permission type", "Set the room server access control list (ACL)")},
{u"m.space.child"_s, kli18nc("Room permission type", "Set the children of this space")},
{u"m.space.parent"_s, kli18nc("Room permission type", "Set the parent space of this room")},
{u"org.matrix.msc3672.beacon_info"_s, kli18nc("Room permission type", "Send live location updates")},
{u"org.matrix.msc3381.poll.start"_s, kli18nc("Room permission type", "Start polls")},
{u"org.matrix.msc3381.poll.response"_s, kli18nc("Room permission type", "Vote in polls")},
{u"org.matrix.msc3381.poll.end"_s, kli18nc("Room permission type", "Close polls")},
};
// Subtitles for the default values.
static const QHash<QString, KLazyLocalizedString> permissionSubtitles = {
{UsersDefaultKey, kli18nc("Room permission type", "This is the power level for all new users when joining the room")},
{StateDefaultKey, kli18nc("Room permission type", "This is used for all state events that do not have their own entry here")},
{EventsDefaultKey, kli18nc("Room permission type", "This is used for all message events that do not have their own entry here")},
{UsersDefaultKey, kli18nc("Room permission type", "This is the power level for all new users when joining the room.")},
{StateDefaultKey, kli18nc("Room permission type", "This is used for all state-type events that do not have their own entry.")},
{EventsDefaultKey, kli18nc("Room permission type", "This is used for all message-type events that do not have their own entry.")},
};
// Permissions that should use the event default.
// Permissions that should use the message event default.
static const QStringList eventPermissions = {
u"m.room.message"_s,
u"m.reaction"_s,
u"m.room.redaction"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Permissions related to messaging.
static const QStringList messagingPermissions = {
u"m.reaction"_s,
u"m.room.redaction"_s,
u"org.matrix.msc3672.beacon_info"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Permissions related to general room management.
static const QStringList generalPermissions = {
u"m.room.power_levels"_s,
u"m.room.name"_s,
u"m.room.avatar"_s,
u"m.room.canonical_alias"_s,
u"m.room.topic"_s,
u"m.room.encryption"_s,
u"m.room.history_visibility"_s,
u"m.room.pinned_events"_s,
u"m.room.tombstone"_s,
u"m.room.server_acl"_s,
u"m.space.child"_s,
u"m.space.parent"_s,
};
};
@@ -194,6 +231,12 @@ QVariant PermissionsModel::data(const QModelIndex &index, int role) const
if (role == IsBasicPermissionRole) {
return basicPermissions.contains(permission);
}
if (role == IsMessagePermissionRole) {
return messagingPermissions.contains(permission);
}
if (role == IsGeneralPermissionRole) {
return generalPermissions.contains(permission);
}
return {};
}
@@ -213,6 +256,8 @@ QHash<int, QByteArray> PermissionsModel::roleNames() const
roles[LevelNameRole] = "levelName";
roles[IsDefaultValueRole] = "isDefaultValue";
roles[IsBasicPermissionRole] = "isBasicPermission";
roles[IsMessagePermissionRole] = "isMessagePermission";
roles[IsGeneralPermissionRole] = "isGeneralPermission";
return roles;
}

View File

@@ -36,6 +36,8 @@ public:
LevelNameRole, /**< The current power level for the permission as a string. */
IsDefaultValueRole, /**< Whether the permission is a default value, e.g. for users. */
IsBasicPermissionRole, /**< Whether the permission is one of the basic ones, e.g. kick, ban, etc. */
IsMessagePermissionRole, /** Permissions related to messaging. */
IsGeneralPermissionRole, /** Permissions related to general room management. */
};
Q_ENUM(Roles)

View File

@@ -8,16 +8,24 @@ import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.neochat
RowLayout {
id: root
property var avatarSize: Kirigami.Units.iconSizes.small
property alias model: avatarFlowRepeater.model
property alias model: root.limiterModel.sourceModel
property string toolTipText
property LimiterModel limiterModel: LimiterModel {
maximumCount: 5
}
spacing: -avatarSize / 2
Repeater {
id: avatarFlowRepeater
model: root.limiterModel
delegate: KirigamiComponents.Avatar {
required property string displayName
required property url avatarUrl
@@ -39,11 +47,11 @@ RowLayout {
Layout.preferredHeight: Kirigami.Units.iconSizes.small + Kirigami.Units.smallSpacing
Layout.fillHeight: true
visible: text !== ""
visible: root.limiterModel.extraCount > 0
color: Kirigami.Theme.textColor
horizontalAlignment: Text.AlignHCenter
text: root.model?.excessReadMarkersString ?? ""
text: "+ " + root.limiterModel.extraCount
background: Kirigami.ShadowedRectangle {
color: Kirigami.Theme.backgroundColor

View File

@@ -132,14 +132,12 @@ KirigamiComponents.ConvergentContextMenu {
root.room.toggleReaction(root.eventId, emoji);
root.close();
});
dialog.closed.connect(() => {
root.close();
});
dialog.open();
return;
}
root.room.toggleReaction(root.eventId, modelData);
root.close();
}
}
}
@@ -161,7 +159,7 @@ KirigamiComponents.ConvergentContextMenu {
Kirigami.Action {
id: replyAction
visible: root.messageComponentType !== MessageComponentType.Other || NeoChatConfig.relateAnyEvent
visible: !root.room.readOnly && (root.messageComponentType !== MessageComponentType.Other || NeoChatConfig.relateAnyEvent)
text: i18nc("@action:inmenu", "Reply")
icon.name: "mail-replied-symbolic"
onTriggered: {
@@ -173,7 +171,7 @@ KirigamiComponents.ConvergentContextMenu {
Kirigami.Action {
id: replyThreadAction
visible: root.messageComponentType !== MessageComponentType.Other || NeoChatConfig.relateAnyEvent
visible: (root.messageComponentType !== MessageComponentType.Other || NeoChatConfig.relateAnyEvent) && NeoChatConfig.threads
text: i18nc("@action:inmenu", "Reply in Thread")
icon.name: "dialog-messages"
onTriggered: {
@@ -204,11 +202,13 @@ KirigamiComponents.ConvergentContextMenu {
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: {
let dialog = (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
let dialog = ((root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Remove Message"),
placeholder: i18nc("@info:placeholder", "Reason for removing this message"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for removing this message"),
actionText: i18nc("@action:button 'Remove' as in 'Remove this message'", "Remove"),
icon: "delete"
icon: "delete",
reporting: false,
connection: root.connection,
}, {
title: i18nc("@title:dialog", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
@@ -229,7 +229,7 @@ KirigamiComponents.ConvergentContextMenu {
text: i18nc("@action:inmenu As in 'Forward this message'", "Forward…")
icon.name: "mail-forward-symbolic"
onTriggered: {
let page = (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ChooseRoomDialog'), {
let page = ((root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ChooseRoomDialog'), {
connection: root.connection
}, {
title: i18nc("@title", "Forward Message"),
@@ -328,11 +328,13 @@ KirigamiComponents.ConvergentContextMenu {
icon.name: "dialog-warning-symbolic"
visible: !root.author.isLocalMember
onTriggered: {
let dialog = (root.Kirigami.PageStack.pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
let dialog = ((root.QQC2.ApplicationWindow.window as Kirigami.ApplicationWindow).pageStack as Kirigami.PageRow).pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Report Message"),
placeholder: i18nc("@info:placeholder", "Reason for reporting this message"),
placeholder: i18nc("@info:placeholder", "Optionally give a reason for reporting this message"),
icon: "dialog-warning-symbolic",
actionText: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
actionText: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report"),
reporting: true,
connection: root.connection,
}, {
title: i18nc("@title", "Report Message"),
width: Kirigami.Units.gridUnit * 25
@@ -371,7 +373,7 @@ KirigamiComponents.ConvergentContextMenu {
Kirigami.Action {
text: i18nc("@action:inmenu", "Configure Web Shortcuts…")
icon.name: "configure"
visible: !Controller.isFlatpak && webShortcutModel.enabled
visible: !Controller.isFlatpak && webShortcutModel.enabled && webShortcutModelAction.visible
onTriggered: webShortcutModel.configureWebShortcuts()
}

View File

@@ -21,16 +21,12 @@ DelegateChooser {
DelegateChoice {
roleValue: DelegateType.State
delegate: StateDelegate {
room: root.room
}
delegate: StateDelegate {}
}
DelegateChoice {
roleValue: DelegateType.Message
delegate: MessageDelegate {
room: root.room
}
delegate: MessageDelegate {}
}
DelegateChoice {
@@ -45,23 +41,17 @@ DelegateChooser {
DelegateChoice {
roleValue: DelegateType.Predecessor
delegate: PredecessorDelegate {
room: root.room
}
delegate: PredecessorDelegate {}
}
DelegateChoice {
roleValue: DelegateType.Successor
delegate: SuccessorDelegate {
room: root.room
}
delegate: SuccessorDelegate {}
}
DelegateChoice {
roleValue: DelegateType.TimelineEnd
delegate: TimelineEndDelegate {
room: root.room
}
delegate: TimelineEndDelegate {}
}
DelegateChoice {
@@ -75,9 +65,7 @@ DelegateChooser {
Component {
id: hiddenDelegate
HiddenDelegate {
room: root.room
}
HiddenDelegate {}
}
Component {
id: emptyDelegate

View File

@@ -92,8 +92,10 @@ TimelineDelegate {
QtObject {
id: _private
function showMessageMenu() {
RoomManager.viewEventMenu(root.eventId, root.room, "");
function showMessageMenu(): void {
let event = root.Message.room.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.room, "");
}
}
}

View File

@@ -208,6 +208,15 @@ MessageDelegateBase {
readMarkerComponent: AvatarFlow {
model: root.readMarkers
TapHandler {
onTapped: {
const dialog = Qt.createComponent("org.kde.neochat", "SeenByDialog").createObject(root, {
model: root.readMarkers
}) as SeenByDialog;
dialog.open();
}
}
}
compactBackgroundComponent: Rectangle {
@@ -218,13 +227,15 @@ MessageDelegateBase {
quickActionComponent: QuickActions {
room: root.room
eventId: root.eventId
author: root.author
}
QtObject {
id: _private
function showMessageMenu() {
RoomManager.viewEventMenu(root.eventId, root.room, root.Message.selectedText, root.Message.hoveredLink);
function showMessageMenu(): void {
let event = root.ListView.view.model.findEvent(root.eventId);
RoomManager.viewEventMenu(root.QQC2.Overlay.overlay, event, root.room, root.Message.selectedText, root.Message.hoveredLink);
}
}
}

View File

@@ -22,6 +22,15 @@ RowLayout {
*/
required property string eventId
/**
* @brief The message author.
*
* A Quotient::RoomMember object.
*
* @sa Quotient::RoomMember
*/
required property var author
property real availableWidth: 0.0
property bool reacting: false

View File

@@ -40,7 +40,7 @@ QQC2.ScrollView {
* @brief Shift the view to the given event ID.
*/
function goToEvent(eventId) {
const index = messageListView.model.indexforEventId(eventId)
const index = messageListView.model.indexForEventId(eventId)
if (!index.valid) {
messageListView.positionViewAtEnd();
return;
@@ -80,9 +80,30 @@ QQC2.ScrollView {
QQC2.ScrollBar.vertical.interactive: false
/**
* @brief Tell the view to resettle again as needed.
*/
function resetViewSettling() {
_private.viewHasSettled = false;
}
ListView {
id: messageListView
// HACK: Use this instead of atYEnd to handle cases like -643.2 at height of 643 not being counted as "at the beginning"
readonly property bool closeToYEnd: -Math.round(contentY) >= height
onHeightChanged: {
// HACK: Fix a bug where Qt doesn't resettle the view properly when the pinned messages changes our height
// We basically want to resettle back at the start if:
// * The user hasn't scrolled before (obviously) *and* that scroll is actually somewhere other than the beginning
// * This is the first height change
if (!_private.viewHasSettled && (!_private.hasScrolledUpBefore || closeToYEnd)) {
positionViewAtBeginning();
_private.viewHasSettled = true;
}
}
/**
* @brief Whether all unread messages in the timeline are visible.
*/
@@ -130,7 +151,7 @@ QQC2.ScrollView {
Shortcut {
sequences: [ StandardKey.Cancel ]
onActivated: {
if (!messageListView.atYEnd || !_private.room.partiallyReadStats.empty()) {
if (!messageListView.closeToYEnd || !_private.room.partiallyReadStats.empty()) {
messageListView.positionViewAtBeginning();
} else {
(root.Kirigami.PageStack.pageStack as Kirigami.PageRow).get(0).forceActiveFocus();
@@ -138,19 +159,13 @@ QQC2.ScrollView {
}
}
Component.onCompleted: {
positionViewAtBeginning();
}
Connections {
target: messageListView.model.sourceModel.timelineMessageModel
function onModelAboutToBeReset() {
(root.QQC2.ApplicationWindow.window as Main).hoverLinkIndicator.text = "";
_private.hasScrolledUpBefore = false;
}
function onModelResetComplete() {
messageListView.positionViewAtBeginning();
_private.viewHasSettled = false;
}
function onReadMarkerAdded() {
@@ -179,13 +194,20 @@ QQC2.ScrollView {
}
}
onAtYEndChanged: if (atYEnd && _private.hasScrolledUpBefore) {
if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) {
_private.room.markAllMessagesAsRead();
onAtYEndChanged: {
// Don't care about this until the view has settled first.
if (!_private.viewHasSettled) {
return;
}
if (closeToYEnd && _private.hasScrolledUpBefore) {
if (QQC2.ApplicationWindow.window && (QQC2.ApplicationWindow.window.visibility !== QQC2.ApplicationWindow.Hidden)) {
_private.room.markAllMessagesAsRead();
}
_private.hasScrolledUpBefore = false;
} else if (!closeToYEnd) {
_private.hasScrolledUpBefore = true;
}
_private.hasScrolledUpBefore = false;
} else if (!atYEnd) {
_private.hasScrolledUpBefore = true;
}
model: root.messageFilterModel
@@ -269,7 +291,7 @@ QQC2.ScrollView {
padding: Kirigami.Units.largeSpacing
z: 2
visible: !messageListView.atYEnd
visible: !messageListView.closeToYEnd
text: i18nc("@action:button", "Jump to latest message")
@@ -368,5 +390,8 @@ QQC2.ScrollView {
// Used to determine if scrolling to the bottom should mark the message as unread
property bool hasScrolledUpBefore: false
// Used to determine if the view has settled and should stop moving
property bool viewHasSettled: false
}
}

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