File indexing completed on 2024-03-24 17:23:08

0001 // SPDX-License-Identifier: GPL-3.0-or-later
0002 /*
0003   Copyright 2017 - 2022 Martin Koller, kollix@aon.at
0004 
0005   This file is part of liquidshell.
0006 
0007   liquidshell is free software: you can redistribute it and/or modify
0008   it under the terms of the GNU General Public License as published by
0009   the Free Software Foundation, either version 3 of the License, or
0010   (at your option) any later version.
0011 
0012   liquidshell is distributed in the hope that it will be useful,
0013   but WITHOUT ANY WARRANTY; without even the implied warranty of
0014   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
0015   GNU General Public License for more details.
0016 
0017   You should have received a copy of the GNU General Public License
0018   along with liquidshell.  If not, see <http://www.gnu.org/licenses/>.
0019 */
0020 
0021 #include <NotificationList.hxx>
0022 #include <NotificationServer.hxx>
0023 #include <DesktopWidget.hxx>
0024 
0025 #include <QVBoxLayout>
0026 #include <QHBoxLayout>
0027 #include <QScrollArea>
0028 #include <QToolButton>
0029 #include <QPushButton>
0030 #include <QProgressBar>
0031 #include <QPointer>
0032 #include <QTimer>
0033 #include <QTime>
0034 #include <QVariant>
0035 #include <QVariantMap>
0036 
0037 #include <KRun>
0038 #include <KLocalizedString>
0039 #include <KWindowSystem>
0040 #include <KConfig>
0041 #include <KConfigGroup>
0042 
0043 static const Qt::WindowFlags POPUP_FLAGS = Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint;
0044 
0045 //--------------------------------------------------------------------------------
0046 
0047 NotifyItem::NotifyItem(QWidget *parent, NotificationServer *server, uint theId, const QString &app,
0048                        const QString &theSummary, const QString &theBody, const QIcon &icon,
0049                        const QStringList &theActions)
0050   : QFrame(parent, POPUP_FLAGS), id(theId), appName(app), summary(theSummary), body(theBody), actions(theActions)
0051 {
0052   setAttribute(Qt::WA_ShowWithoutActivating);  // avoid focus stealing
0053 
0054   setFrameShape(QFrame::StyledPanel);
0055   QMargins margins = contentsMargins();
0056   margins.setRight(0); // allow the close button to reach to the right screen border - easier to click
0057   setContentsMargins(margins);
0058 
0059   QVBoxLayout *vbox = new QVBoxLayout;
0060   timeLabel = new QLabel;
0061   iconLabel = new QLabel;
0062   timeoutBar = new QProgressBar;
0063   timeoutBar->setTextVisible(false);
0064   timeoutBar->setFixedSize(50, 10);
0065   timeoutBar->hide();
0066   vbox->addWidget(timeLabel, 0, Qt::AlignTop | Qt::AlignHCenter);
0067   vbox->addWidget(timeoutBar, 0, Qt::AlignTop | Qt::AlignLeft);
0068   vbox->addWidget(iconLabel, 1, Qt::AlignVCenter | Qt::AlignHCenter);
0069 
0070   QVBoxLayout *centerBox = new QVBoxLayout;
0071 
0072   QHBoxLayout *hbox = new QHBoxLayout(this);
0073   margins = hbox->contentsMargins();
0074   margins.setRight(0); // allow the close button to reach to the right screen border - easier to click
0075   hbox->setContentsMargins(margins);
0076 
0077   textLabel = new QLabel;
0078   QToolButton *closeButton = new QToolButton;
0079   // easier to click with larger size
0080   closeButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
0081   closeButton->setMinimumWidth(40);
0082   closeButton->setAutoRaise(true);
0083   closeButton->setIcon(QIcon::fromTheme("window-close"));
0084   connect(closeButton, &QToolButton::clicked, this,
0085           [this, server]()
0086           {
0087             emit server->NotificationClosed(id, NotificationServer::CloseReason::Dismissed);
0088             deleteLater();
0089           });
0090 
0091   textLabel->setWordWrap(true);
0092   textLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
0093   textLabel->setOpenExternalLinks(true);
0094   textLabel->setMinimumWidth(300);
0095   textLabel->setMaximumWidth(600);
0096   textLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
0097 
0098   centerBox->addWidget(textLabel);
0099 
0100   if ( actions.count() )
0101   {
0102     QHBoxLayout *actionBox = new QHBoxLayout;
0103     centerBox->addLayout(actionBox);
0104 
0105     for (int i = 0; i < actions.count(); i++)
0106     {
0107       if ( ((i % 2) != 0) && !actions[i].isEmpty() )  // id
0108       {
0109         QPushButton *button = new QPushButton;
0110         button->setText(actions[i]);
0111         actionBox->addWidget(button);
0112         QString key = actions[i - 1];
0113 
0114         connect(button, &QPushButton::clicked, this,
0115                 [this, server, key]()
0116                 {
0117                   emit server->ActionInvoked(id, key);
0118                 });
0119       }
0120     }
0121   }
0122 
0123   hbox->addLayout(vbox);
0124   hbox->addLayout(centerBox);
0125   hbox->addWidget(closeButton);
0126 
0127   iconLabel->setFixedSize(32, 32);
0128   iconLabel->setPixmap(icon.pixmap(iconLabel->size()));
0129 
0130   QString title = (appName == summary) ? appName : (appName + ": " + summary);
0131   textLabel->setText(QString("<html><center><b>%1</b></center><br>%2</html>")
0132                      .arg(title)
0133                      .arg(body));
0134 
0135   timeLabel->setText(QTime::currentTime().toString(Qt::SystemLocaleShortDate));
0136 }
0137 
0138 //--------------------------------------------------------------------------------
0139 
0140 void NotifyItem::destroySysResources()
0141 {
0142   destroy();
0143 }
0144 
0145 //--------------------------------------------------------------------------------
0146 
0147 void NotifyItem::setTimeout(int milliSecs)
0148 {
0149   if ( milliSecs == 0 )
0150     return;
0151 
0152   timeoutBar->setMaximum(milliSecs);
0153   timeoutBar->setValue(milliSecs);
0154   timeoutBar->show();
0155 
0156   const int STEP = milliSecs / timeoutBar->width();
0157 
0158   QTimer *timer = new QTimer(this);
0159   timer->setInterval(STEP);
0160   connect(timer, &QTimer::timeout, this, [this, STEP]()
0161           {
0162             if ( timeoutBar->value() >= STEP )
0163               timeoutBar->setValue(timeoutBar->value() - STEP);
0164           });
0165 
0166   timer->start();
0167 }
0168 
0169 
0170 //--------------------------------------------------------------------------------
0171 //--------------------------------------------------------------------------------
0172 //--------------------------------------------------------------------------------
0173 
0174 NotificationList::NotificationList(NotificationServer *parent)
0175   : QWidget(parent), server(parent)
0176 {
0177   setWindowFlags(windowFlags() | Qt::Tool);
0178   setWindowTitle(i18n("Notifications"));
0179 
0180   scrollArea = new QScrollArea;
0181   scrollArea->setWidgetResizable(true);
0182 
0183   QWidget *listWidget = new QWidget;
0184   listVbox = new QVBoxLayout(listWidget);
0185   listVbox->setContentsMargins(QMargins());
0186   listVbox->addStretch();
0187 
0188   scrollArea->setWidget(listWidget);
0189 
0190   QVBoxLayout *vbox = new QVBoxLayout(this);
0191   vbox->setContentsMargins(QMargins());
0192   vbox->addWidget(scrollArea);
0193 
0194   QPushButton *clearButton = new QPushButton;
0195   clearButton->setIcon(QIcon::fromTheme("edit-clear-list"));
0196   connect(clearButton, &QPushButton::clicked, this, [this]()
0197           {
0198             for (NotifyItem *item : items)
0199              {
0200                emit server->NotificationClosed(item->id, NotificationServer::CloseReason::Dismissed);
0201                item->deleteLater();
0202              }
0203           });
0204   vbox->addWidget(clearButton);
0205 
0206   resize(500, 300);
0207 
0208   KConfig config;
0209   KConfigGroup group = config.group("Notifications");
0210   avoidPopup = group.readEntry<bool>("avoidPopup", avoidPopup);
0211 }
0212 
0213 //--------------------------------------------------------------------------------
0214 
0215 NotificationList::~NotificationList()
0216 {
0217   for (NotifyItem *item : items)
0218     item->disconnect();  // make sure destroyed() is no longer triggered
0219 }
0220 
0221 //--------------------------------------------------------------------------------
0222 
0223 void NotificationList::addItem(uint id, const QString &appName, const QString &summary, const QString &body,
0224                                const QIcon &icon, const QStringList &actions, const QVariantMap &hints, int timeout)
0225 {
0226   QPointer<NotifyItem> item = new NotifyItem(nullptr, server, id, appName, summary, body, icon, actions);
0227   item->resize(500, item->sizeHint().height());
0228   KWindowSystem::setState(item->winId(), NET::SkipTaskbar | NET::SkipPager);
0229 
0230   items.append(item.data());
0231 
0232   placeItems();
0233 
0234 #if QT_VERSION >= QT_VERSION_CHECK(5,14,0)
0235   int wordCount = body.splitRef(' ', Qt::SkipEmptyParts).count();
0236 #else
0237   int wordCount = body.splitRef(' ', QString::SkipEmptyParts).count();
0238 #endif
0239 
0240   bool neverExpires = timeout == 0;  // according to spec
0241 
0242   if ( timeout <= 0 )
0243   {
0244     timeout = 4000 + 250 * wordCount;
0245 
0246     if ( actions.count() )
0247       timeout += 15000;  // give user more time to think ...
0248   }
0249 
0250   // 0=low, 1=normal, 2=critical
0251   if ( hints.contains("urgency") && (hints["urgency"].toInt() == 2) )
0252   {
0253     neverExpires = true;
0254     timeout = 20000;  // just show it longer since its urgent
0255   }
0256 
0257   bool transient = hints.contains("transient") ? hints["transient"].toBool() : false;
0258 
0259   // if there are actions, show it longer
0260   if ( actions.count() || neverExpires )
0261     transient = false;
0262 
0263   // exceptions ...
0264   if ( transient && hints.contains("x-kde-eventId") )
0265   {
0266     // I'd like to keep this much longer
0267     if ( hints["x-kde-eventId"].toString() == "new-email" )
0268       transient = false;
0269   }
0270 
0271   // I often get the same notification multiple times (e.g. KDE-connect or EWS akonadi resource)
0272   // but I don't want to fill up the screen with useless duplicate information. Therefore
0273   // whenever the same notification is received while another instance of it is still visible,
0274   // only put it in the list-view (it has a different id, therefore still store it), but don't show it as popup
0275   for (NotifyItem *it : items)
0276   {
0277     if ( (it != item) &&         // not the new item already in items
0278          !it->parentWidget() &&  // temporary item not in the list-view yet
0279          (appName == it->appName) && (summary == it->summary) &&
0280          (body == it->body) && (actions == it->actions) )
0281     {
0282       timeout = 0;
0283     }
0284   }
0285 
0286   if ( avoidPopup )
0287     timeout = 0;
0288 
0289   emit itemsCountChanged();
0290   connect(item.data(), &NotifyItem::destroyed, this, &NotificationList::itemDestroyed);
0291 
0292   item->setTimeout(timeout);
0293 
0294   QTimer::singleShot(timeout,
0295                      [=]() mutable
0296                      {
0297                        if ( item )
0298                        {
0299                          // I must create a new item since otherwise when closing the NotificationList
0300                          // and moving this toplevel item into the list, weird behavior happens
0301                          // e.g. no longer showing anything in the list, appearing at wrong position when shown...
0302                          item->hide();
0303                          item->deleteLater();
0304                          item = new NotifyItem(this, server, id, appName, summary, body, icon, actions);
0305                          items.append(item.data());
0306                          emit itemsCountChanged();
0307                          connect(item.data(), &NotifyItem::destroyed, this, &NotificationList::itemDestroyed);
0308 
0309                          listVbox->insertWidget(listVbox->count() - 1, item);  // insert before stretch
0310                          item->show();
0311 
0312                          placeItems();  // reorder the remaining ones
0313 
0314                          scrollArea->setWidgetResizable(true);  // to update scrollbars, else next line does not work
0315                          scrollArea->ensureWidgetVisible(item);
0316 
0317                          if ( !neverExpires )
0318                          {
0319                            if ( !appTimeouts.contains(appName) )
0320                              appTimeouts.insert(appName, 10);  // default: 10 minutes lifetime
0321 
0322                            int expireTimeout;
0323 
0324                            if ( transient )
0325                              expireTimeout = 2 * 60 * 1000;  // still keep it a short time
0326                            else
0327                              expireTimeout = appTimeouts[appName] * 60 * 1000;
0328 
0329                            QTimer::singleShot(expireTimeout, item.data(),
0330                                               [item, this]()
0331                                               {
0332                                                 emit server->NotificationClosed(item->id, NotificationServer::CloseReason::Expired);
0333                                                 item->NotifyItem::deleteLater();
0334                                               });
0335                          }
0336                        }
0337                      }
0338                     );
0339 }
0340 
0341 //--------------------------------------------------------------------------------
0342 
0343 void NotificationList::itemDestroyed(QObject *obj)
0344 {
0345   items.removeOne(static_cast<NotifyItem *>(obj));
0346   placeItems();
0347 
0348   if ( items.isEmpty() )
0349   {
0350     hide();
0351     emit listNowEmpty();
0352   }
0353   else
0354     emit itemsCountChanged();
0355 }
0356 
0357 //--------------------------------------------------------------------------------
0358 
0359 void NotificationList::closeItem(uint id)
0360 {
0361   for (int i = 0; i < items.count(); i++)
0362   {
0363     if ( items[i]->id == id )
0364     {
0365       items.takeAt(i)->deleteLater();
0366       break;
0367     }
0368   }
0369 }
0370 
0371 //--------------------------------------------------------------------------------
0372 
0373 void NotificationList::placeItems()
0374 {
0375   QRect screen = DesktopWidget::availableGeometry();
0376   QPoint point = parentWidget()->mapToGlobal(parentWidget()->pos());
0377   int x = point.x();
0378   int y = screen.bottom();
0379 
0380   for (NotifyItem *item : items)
0381   {
0382     if ( !item->parentWidget() )  // temporary item not in the list yet
0383     {
0384       y -= item->sizeHint().height();
0385       y -= 5; // a small space
0386 
0387       point.setX(std::min(x, screen.x() + screen.width() - item->sizeHint().width()));
0388       point.setY(y);
0389       item->move(point);
0390       item->show();
0391     }
0392   }
0393 }
0394 
0395 //--------------------------------------------------------------------------------
0396 
0397 void NotificationList::setAvoidPopup(bool on)
0398 {
0399   avoidPopup = on;
0400 
0401   KConfig config;
0402   KConfigGroup group = config.group("Notifications");
0403   group.writeEntry("avoidPopup", avoidPopup);
0404 }
0405 
0406 //--------------------------------------------------------------------------------