Compare commits

...

1 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
3 changed files with 138 additions and 52 deletions

View File

@@ -220,6 +220,7 @@ target_link_libraries(neochat PUBLIC
KF6::ItemModels KF6::ItemModels
KF6::I18nQml KF6::I18nQml
KirigamiApp KirigamiApp
KirigamiAddonsComponents
QuotientQt6 QuotientQt6
Login Login
Rooms Rooms

View File

@@ -11,6 +11,7 @@
#include <KNotification> #include <KNotification>
#include <KNotificationPermission> #include <KNotificationPermission>
#include <KNotificationReplyAction> #include <KNotificationReplyAction>
#include <KirigamiAddons/Components/NameUtils>
#include <QPainter> #include <QPainter>
#include <Quotient/accountregistry.h> #include <Quotient/accountregistry.h>
@@ -144,16 +145,9 @@ void NotificationsManager::processNotificationJob(QPointer<NeoChatConnection> co
body = notification["event"_L1]["content"_L1]["body"_L1].toString(); 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), postNotification(dynamic_cast<NeoChatRoom *>(room),
sender.displayName(), room->member(sender.id()),
body, body,
avatar_image,
notification["event"_L1].toObject()["event_id"_L1].toString(), notification["event"_L1].toObject()["event_id"_L1].toString(),
true, true,
pair.first); pair.first);
@@ -195,9 +189,8 @@ bool NotificationsManager::shouldPostNotification(QPointer<NeoChatConnection> co
} }
void NotificationsManager::postNotification(NeoChatRoom *room, void NotificationsManager::postNotification(NeoChatRoom *room,
const QString &sender, const RoomMember &member,
const QString &text, const QString &text,
const QImage &icon,
const QString &replyEventId, const QString &replyEventId,
bool canReply, bool canReply,
qint64 timestamp) qint64 timestamp)
@@ -222,11 +215,11 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
if (room->isDirectChat()) { if (room->isDirectChat()) {
entry = text.toHtmlEscaped(); entry = text.toHtmlEscaped();
} else { } else {
entry = i18n("%1: %2", sender, text.toHtmlEscaped()); entry = i18n("%1: %2", member.displayName(), text.toHtmlEscaped());
} }
notification->setText(entry); notification->setText(entry);
notification->setPixmap(createNotificationImage(icon, room)); notification->setPixmap(createNotificationImage(member, room));
auto defaultAction = notification->addDefaultAction(i18n("Open NeoChat in this room")); auto defaultAction = notification->addDefaultAction(i18n("Open NeoChat in this room"));
connect(defaultAction, &KNotificationAction::activated, this, [notification, 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()); 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); KNotification *notification = new KNotification(u"invite"_s);
notification->setText(i18n("%1 invited you to a room", sender.htmlSafeDisplayName())); notification->setText(i18n("%1 invited you to a room", sender.htmlSafeDisplayName()));
notification->setTitle(room->displayName()); 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")); auto defaultAction = notification->addDefaultAction(i18n("Open this invitation in NeoChat"));
connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() { connect(defaultAction, &KNotificationAction::activated, this, [notification, room]() {
if (!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 // Handle avatars that are lopsided in one dimension
const int biggestDimension = std::max(icon.width(), icon.height()); const int biggestDimension = std::max(senderIcon.width(), senderIcon.height());
const QRect imageRect{0, 0, biggestDimension, biggestDimension}; 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); roundedImage.fill(Qt::transparent);
QPainter painter(&roundedImage); QPainter painter(&roundedImage);
painter.setRenderHint(QPainter::SmoothPixmapTransform); painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.setPen(Qt::NoPen); painter.setPen(Qt::NoPen);
// Fill background for transparent avatars if (senderIconIsPlaceholder) {
painter.setBrush(Qt::white); painter.drawImage(imageRect, senderIcon);
painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height()); } 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(senderIcon.scaledToHeight(biggestDimension));
painter.setBrush(brush); painter.drawRoundedRect(imageRect, imageRect.width(), imageRect.height());
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());
}
} }
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" #include "moc_notificationsmanager.cpp"

View File

@@ -13,6 +13,11 @@
#include <Quotient/csapi/notifications.h> #include <Quotient/csapi/notifications.h>
#include <Quotient/jobs/basejob.h> #include <Quotient/jobs/basejob.h>
namespace Quotient
{
class RoomMember;
}
class NeoChatConnection; class NeoChatConnection;
class KNotification; class KNotification;
class NeoChatRoom; class NeoChatRoom;
@@ -67,15 +72,24 @@ private:
QStringList m_connActiveJob; QStringList m_connActiveJob;
void startNotificationJob(QPointer<NeoChatConnection> connection); 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); bool shouldPostNotification(QPointer<NeoChatConnection> connection, const QJsonValue &notification);
void postNotification(NeoChatRoom *room, void
const QString &sender, postNotification(NeoChatRoom *room, const Quotient::RoomMember &member, const QString &text, const QString &replyEventId, bool canReply, qint64 timestamp);
const QString &text,
const QImage &icon,
const QString &replyEventId,
bool canReply,
qint64 timestamp);
void doPostInviteNotification(QPointer<NeoChatRoom> room); void doPostInviteNotification(QPointer<NeoChatRoom> room);
@@ -84,6 +98,8 @@ private:
bool permissionAsked = false; bool permissionAsked = false;
static constexpr int avatarDimension = 128;
private Q_SLOTS: private Q_SLOTS:
void processNotificationJob(QPointer<NeoChatConnection> connection, Quotient::GetNotificationsJob *job, bool initialization); void processNotificationJob(QPointer<NeoChatConnection> connection, Quotient::GetNotificationsJob *job, bool initialization);
}; };