mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
feat(notifications): persist history, filters, icons, and compact image assets
This commit is contained in:
@@ -329,6 +329,14 @@
|
||||
"notifications": {
|
||||
"empty-title": "No notifications",
|
||||
"empty-body": "Recent notifications will show here.",
|
||||
"filter-empty-title": "Nothing here",
|
||||
"filter-empty-body": "No notifications match this filter.",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"older": "Older"
|
||||
},
|
||||
"untitled": "Untitled notification",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
|
||||
@@ -368,6 +368,7 @@ _noctalia_sources = files(
|
||||
'src/net/http_client.cpp',
|
||||
'src/net/uri.cpp',
|
||||
'src/notification/notification_manager.cpp',
|
||||
'src/notification/notification_history_store.cpp',
|
||||
'src/pipewire/pipewire_service.cpp',
|
||||
'src/pipewire/pipewire_spectrum.cpp',
|
||||
'src/pipewire/sound_player.cpp',
|
||||
|
||||
@@ -112,6 +112,7 @@ namespace {
|
||||
} // namespace
|
||||
|
||||
Application::Application() : m_weatherService(m_configService, m_httpClient) {
|
||||
m_notificationManager.loadPersistedHistory();
|
||||
notify::setInstance(&m_notificationManager);
|
||||
LockScreen::setInstance(&m_lockScreen);
|
||||
|
||||
@@ -150,6 +151,7 @@ Application::Application() : m_weatherService(m_configService, m_httpClient) {
|
||||
}
|
||||
|
||||
Application::~Application() {
|
||||
m_notificationManager.flushPersistedHistory();
|
||||
m_wayland.setClipboardService(nullptr);
|
||||
m_wayland.setVirtualKeyboardService(nullptr);
|
||||
LockScreen::setInstance(nullptr);
|
||||
|
||||
@@ -26,6 +26,8 @@ enum class NotificationOrigin : uint8_t {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using TimePoint = Clock::time_point;
|
||||
using WallClock = std::chrono::system_clock;
|
||||
using WallTimePoint = WallClock::time_point;
|
||||
|
||||
struct NotificationImageData {
|
||||
std::int32_t width = 0;
|
||||
@@ -54,4 +56,8 @@ struct Notification {
|
||||
std::optional<std::string> desktopEntry;
|
||||
TimePoint receivedTime; // add/replace time used for duplicate burst suppression
|
||||
std::optional<TimePoint> expiryTime; // absent = never expires
|
||||
/// Wall-clock receive time (history UI, persistence). Optional for pre-migration / corrupt rows.
|
||||
std::optional<WallTimePoint> receivedWallClock;
|
||||
/// Wall-clock expiry when timeout-based; mirrors expiryTime where applicable.
|
||||
std::optional<WallTimePoint> expiryWallClock;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
#include "notification/notification_history_store.h"
|
||||
|
||||
#include "core/log.h"
|
||||
#include "notification/notification_manager.h"
|
||||
#include "render/core/image_decoder.h"
|
||||
#include "util/file_utils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <json.hpp>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
#include <webp/encode.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr Logger kLog("notification-history");
|
||||
|
||||
constexpr std::string_view kOriginExternal = "external";
|
||||
constexpr std::string_view kOriginInternal = "internal";
|
||||
|
||||
constexpr std::string_view kUrgencyLow = "low";
|
||||
constexpr std::string_view kUrgencyNormal = "normal";
|
||||
constexpr std::string_view kUrgencyCritical = "critical";
|
||||
|
||||
constexpr std::string_view kCloseExpired = "expired";
|
||||
constexpr std::string_view kCloseDismissed = "dismissed";
|
||||
constexpr std::string_view kCloseByCall = "closed_by_call";
|
||||
|
||||
std::optional<std::chrono::system_clock::time_point> millisToWall(int64_t ms) {
|
||||
if (ms <= 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::chrono::system_clock::time_point{std::chrono::milliseconds{ms}};
|
||||
}
|
||||
|
||||
int64_t wallToMillis(const std::optional<std::chrono::system_clock::time_point>& tp) {
|
||||
if (!tp.has_value()) {
|
||||
return 0;
|
||||
}
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(tp->time_since_epoch()).count();
|
||||
}
|
||||
|
||||
std::string_view urgencyStr(Urgency u) noexcept {
|
||||
switch (u) {
|
||||
case Urgency::Low:
|
||||
return kUrgencyLow;
|
||||
case Urgency::Normal:
|
||||
return kUrgencyNormal;
|
||||
case Urgency::Critical:
|
||||
return kUrgencyCritical;
|
||||
}
|
||||
return kUrgencyNormal;
|
||||
}
|
||||
|
||||
Urgency urgencyFrom(std::string_view s) noexcept {
|
||||
if (s == kUrgencyLow) {
|
||||
return Urgency::Low;
|
||||
}
|
||||
if (s == kUrgencyCritical) {
|
||||
return Urgency::Critical;
|
||||
}
|
||||
return Urgency::Normal;
|
||||
}
|
||||
|
||||
std::string_view originStr(NotificationOrigin o) noexcept {
|
||||
return o == NotificationOrigin::Internal ? kOriginInternal : kOriginExternal;
|
||||
}
|
||||
|
||||
NotificationOrigin originFrom(std::string_view s) noexcept {
|
||||
return s == kOriginInternal ? NotificationOrigin::Internal : NotificationOrigin::External;
|
||||
}
|
||||
|
||||
std::optional<CloseReason> closeReasonFrom(std::string_view s) noexcept {
|
||||
if (s == kCloseExpired) {
|
||||
return CloseReason::Expired;
|
||||
}
|
||||
if (s == kCloseDismissed) {
|
||||
return CloseReason::Dismissed;
|
||||
}
|
||||
if (s == kCloseByCall) {
|
||||
return CloseReason::ClosedByCall;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string_view closeReasonStr(CloseReason r) noexcept {
|
||||
switch (r) {
|
||||
case CloseReason::Expired:
|
||||
return kCloseExpired;
|
||||
case CloseReason::Dismissed:
|
||||
return kCloseDismissed;
|
||||
case CloseReason::ClosedByCall:
|
||||
return kCloseByCall;
|
||||
}
|
||||
return kCloseByCall;
|
||||
}
|
||||
|
||||
static const char kBase64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
std::string base64Encode(const std::vector<std::uint8_t>& data) {
|
||||
std::string out;
|
||||
out.reserve(((data.size() + 2) / 3) * 4);
|
||||
for (std::size_t i = 0; i < data.size(); i += 3) {
|
||||
const std::size_t n = std::min<std::size_t>(3, data.size() - i);
|
||||
std::uint32_t chunk = 0;
|
||||
for (std::size_t j = 0; j < n; ++j) {
|
||||
chunk |= static_cast<std::uint32_t>(data[i + j]) << static_cast<unsigned>((16 - static_cast<int>(j * 8)));
|
||||
}
|
||||
out.push_back(kBase64Chars[(chunk >> 18) & 63]);
|
||||
out.push_back(kBase64Chars[(chunk >> 12) & 63]);
|
||||
out.push_back(n > 1 ? kBase64Chars[(chunk >> 6) & 63] : '=');
|
||||
out.push_back(n > 2 ? kBase64Chars[chunk & 63] : '=');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> base64Decode(std::string_view in) {
|
||||
std::vector<int> decodeTable(256, -1);
|
||||
for (int b = 0; b < 64; ++b) {
|
||||
decodeTable[static_cast<unsigned char>(kBase64Chars[b])] = b;
|
||||
}
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(in.size() * 3 / 4);
|
||||
int val = 0;
|
||||
int valb = -8;
|
||||
for (unsigned char c : in) {
|
||||
if (c == '=') {
|
||||
break;
|
||||
}
|
||||
const int d = decodeTable[c];
|
||||
if (d < 0) {
|
||||
continue;
|
||||
}
|
||||
val = (val << 6) + d;
|
||||
valb += 6;
|
||||
if (valb >= 0) {
|
||||
out.push_back(static_cast<std::uint8_t>((val >> valb) & 0xFF));
|
||||
valb -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
constexpr std::string_view kAssetsDirName = "notification_history_assets";
|
||||
|
||||
/// History list only needs small previews; keeps WebP sidecars tiny.
|
||||
constexpr int kMaxPersistImageSide = 96;
|
||||
constexpr float kPersistWebPQuality = 65.0f;
|
||||
|
||||
std::filesystem::path assetsDirectoryForJson(const std::filesystem::path& jsonFilePath) {
|
||||
return jsonFilePath.parent_path() / kAssetsDirName;
|
||||
}
|
||||
|
||||
/// 32 lowercase hex chars — content-addressed asset names (`i_<hex>.webp`).
|
||||
std::string hashBytesToHex32(const std::uint8_t* p, std::size_t n) {
|
||||
std::uint64_t h0 = 14695981039346656037ULL;
|
||||
std::uint64_t h1 = 13166748625691186689ULL;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
h0 ^= p[i];
|
||||
h0 *= 1099511628211ULL;
|
||||
h1 ^= static_cast<std::uint64_t>(p[i]) << ((i % 8) * 8);
|
||||
h1 *= 11400714819323198485ULL;
|
||||
}
|
||||
h0 ^= n;
|
||||
h1 ^= n * 0x9e3779b97f4a7c15ULL;
|
||||
char buf[33];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx", static_cast<unsigned long long>(h0),
|
||||
static_cast<unsigned long long>(h1));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string contentAddressedWebpName(const std::uint8_t* rgba, std::size_t rgbaBytes) {
|
||||
return std::string("i_") + hashBytesToHex32(rgba, rgbaBytes) + ".webp";
|
||||
}
|
||||
|
||||
std::string contentAddressedRgbaName(const std::uint8_t* bytes, std::size_t byteCount) {
|
||||
return std::string("i_") + hashBytesToHex32(bytes, byteCount) + ".rgba";
|
||||
}
|
||||
|
||||
bool writeRawRgbaBlob(const std::filesystem::path& assetsDir, const std::string& baseFileName,
|
||||
const std::vector<std::uint8_t>& bytes) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(assetsDir, ec);
|
||||
const auto path = assetsDir / baseFileName;
|
||||
if (!bytes.empty() && std::filesystem::exists(path, ec) && std::filesystem::file_size(path, ec) == bytes.size()) {
|
||||
return true;
|
||||
}
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
return static_cast<bool>(f);
|
||||
}
|
||||
|
||||
bool writeWebpBlobIfAbsent(const std::filesystem::path& path, const std::uint8_t* encoded, std::size_t encodedSize) {
|
||||
std::error_code ec;
|
||||
if (encoded == nullptr || encodedSize == 0) {
|
||||
return false;
|
||||
}
|
||||
if (std::filesystem::exists(path, ec)) {
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (!ec && sz == static_cast<std::uint64_t>(encodedSize)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
std::ofstream wf(path, std::ios::binary | std::ios::trunc);
|
||||
if (!wf) {
|
||||
return false;
|
||||
}
|
||||
wf.write(reinterpret_cast<const char*>(encoded), static_cast<std::streamsize>(encodedSize));
|
||||
return static_cast<bool>(wf);
|
||||
}
|
||||
|
||||
/// Icon preset + slow method trades CPU once-per-image for smaller files than WebPEncodeRGBA alone.
|
||||
std::optional<std::vector<std::uint8_t>> encodeWebpForHistory(const std::uint8_t* rgba, int w, int h, int stride) {
|
||||
WebPConfig config;
|
||||
if (!WebPConfigPreset(&config, WEBP_PRESET_ICON, kPersistWebPQuality)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
config.method = 6;
|
||||
config.alpha_quality = 70;
|
||||
if (!WebPValidateConfig(&config)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
WebPPicture picture;
|
||||
if (!WebPPictureInit(&picture)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
picture.width = w;
|
||||
picture.height = h;
|
||||
if (!WebPPictureImportRGBA(&picture, rgba, stride)) {
|
||||
WebPPictureFree(&picture);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
WebPMemoryWriter writer;
|
||||
WebPMemoryWriterInit(&writer);
|
||||
picture.writer = WebPMemoryWrite;
|
||||
picture.custom_ptr = &writer;
|
||||
|
||||
if (!WebPEncode(&config, &picture)) {
|
||||
WebPPictureFree(&picture);
|
||||
WebPMemoryWriterClear(&writer);
|
||||
return std::nullopt;
|
||||
}
|
||||
WebPPictureFree(&picture);
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
if (writer.size > 0 && writer.mem != nullptr) {
|
||||
out.assign(writer.mem, writer.mem + writer.size);
|
||||
}
|
||||
WebPMemoryWriterClear(&writer);
|
||||
if (out.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool packContiguousRgba(const NotificationImageData& img, std::vector<std::uint8_t>& outRgba, int& outW, int& outH) {
|
||||
if (img.width <= 0 || img.height <= 0 || img.data.empty()) {
|
||||
return false;
|
||||
}
|
||||
if (img.bitsPerSample != 8 || (img.channels != 3 && img.channels != 4)) {
|
||||
return false;
|
||||
}
|
||||
outW = img.width;
|
||||
outH = img.height;
|
||||
const int c = img.channels;
|
||||
const int rs = (img.rowStride > 0) ? img.rowStride : (outW * c);
|
||||
outRgba.resize(static_cast<std::size_t>(outW) * outH * 4);
|
||||
for (int y = 0; y < outH; ++y) {
|
||||
const std::uint8_t* row = img.data.data() + static_cast<std::size_t>(y) * rs;
|
||||
std::uint8_t* dst = outRgba.data() + static_cast<std::size_t>(y) * outW * 4;
|
||||
if (c == 4) {
|
||||
std::memcpy(dst, row, static_cast<std::size_t>(outW) * 4);
|
||||
} else {
|
||||
for (int x = 0; x < outW; ++x) {
|
||||
dst[x * 4 + 0] = row[x * 3 + 0];
|
||||
dst[x * 4 + 1] = row[x * 3 + 1];
|
||||
dst[x * 4 + 2] = row[x * 3 + 2];
|
||||
dst[x * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void downscaleRgbaIfNeeded(std::vector<std::uint8_t>& rgba, int& w, int& h, int maxSide) {
|
||||
if (w <= maxSide && h <= maxSide) {
|
||||
return;
|
||||
}
|
||||
const float scale = std::min(static_cast<float>(maxSide) / static_cast<float>(w),
|
||||
static_cast<float>(maxSide) / static_cast<float>(h));
|
||||
const int nw = std::max(1, static_cast<int>(std::lround(static_cast<float>(w) * scale)));
|
||||
const int nh = std::max(1, static_cast<int>(std::lround(static_cast<float>(h) * scale)));
|
||||
std::vector<std::uint8_t> dst(static_cast<std::size_t>(nw) * nh * 4);
|
||||
for (int y = 0; y < nh; ++y) {
|
||||
const int sy = y * h / nh;
|
||||
for (int x = 0; x < nw; ++x) {
|
||||
const int sx = x * w / nw;
|
||||
const std::uint8_t* srcPx = rgba.data() + (static_cast<std::size_t>(sy) * w + sx) * 4;
|
||||
std::uint8_t* dstPx = dst.data() + (static_cast<std::size_t>(y) * nw + x) * 4;
|
||||
std::memcpy(dstPx, srcPx, 4);
|
||||
}
|
||||
}
|
||||
rgba = std::move(dst);
|
||||
w = nw;
|
||||
h = nh;
|
||||
}
|
||||
|
||||
std::optional<NotificationImageData> imageFromJson(const nlohmann::json& j,
|
||||
const std::filesystem::path& jsonFilePath) {
|
||||
if (!j.is_object()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
NotificationImageData img;
|
||||
img.width = j.value("width", 0);
|
||||
img.height = j.value("height", 0);
|
||||
img.rowStride = j.value("row_stride", 0);
|
||||
img.hasAlpha = j.value("has_alpha", true);
|
||||
img.bitsPerSample = j.value("bits_per_sample", 8);
|
||||
img.channels = j.value("channels", 4);
|
||||
|
||||
const auto fileOnly = j.value("image_file", std::string());
|
||||
if (!fileOnly.empty()) {
|
||||
const auto blobPath = assetsDirectoryForJson(jsonFilePath) / fileOnly;
|
||||
img.data = FileUtils::readBinaryFile(blobPath.string());
|
||||
if (!img.data.empty()) {
|
||||
std::string decErr;
|
||||
if (auto decoded = decodeRasterImage(img.data.data(), img.data.size(), &decErr)) {
|
||||
img.width = decoded->width;
|
||||
img.height = decoded->height;
|
||||
img.rowStride = img.width * 4;
|
||||
img.channels = 4;
|
||||
img.hasAlpha = true;
|
||||
img.bitsPerSample = 8;
|
||||
img.data = std::move(decoded->pixels);
|
||||
return img;
|
||||
}
|
||||
// Legacy sidecar: raw RGBA bytes (not a supported container format).
|
||||
if (img.width > 0 && img.height > 0 && img.channels >= 3) {
|
||||
const std::size_t expected =
|
||||
static_cast<std::size_t>(img.width) * img.height * static_cast<std::size_t>(img.channels);
|
||||
if (img.data.size() >= expected) {
|
||||
return img;
|
||||
}
|
||||
}
|
||||
kLog.warn("could not decode notification image blob {} ({})", blobPath.string(), decErr);
|
||||
} else if (img.width > 0 && img.height > 0) {
|
||||
kLog.warn("missing or empty image blob {}", blobPath.string());
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
const auto b64 = j.value("data_b64", std::string());
|
||||
if (!b64.empty()) {
|
||||
img.data = base64Decode(b64);
|
||||
return img;
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
nlohmann::json imageToJson(const NotificationImageData& img, const std::filesystem::path& jsonFilePath,
|
||||
uint32_t notificationId) {
|
||||
nlohmann::json j;
|
||||
j["has_alpha"] = img.hasAlpha;
|
||||
j["bits_per_sample"] = img.bitsPerSample;
|
||||
j["channels"] = img.channels;
|
||||
|
||||
if (img.data.empty() || img.width <= 0 || img.height <= 0) {
|
||||
j["width"] = img.width;
|
||||
j["height"] = img.height;
|
||||
j["row_stride"] = img.rowStride;
|
||||
return j;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> rgba;
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
if (!packContiguousRgba(img, rgba, w, h)) {
|
||||
kLog.warn("could not pack notification image pixels for id {}", notificationId);
|
||||
j["width"] = img.width;
|
||||
j["height"] = img.height;
|
||||
j["row_stride"] = img.rowStride;
|
||||
j["data_b64"] = base64Encode(img.data);
|
||||
return j;
|
||||
}
|
||||
|
||||
downscaleRgbaIfNeeded(rgba, w, h, kMaxPersistImageSide);
|
||||
|
||||
const auto assetsDir = assetsDirectoryForJson(jsonFilePath);
|
||||
std::error_code ecMk;
|
||||
std::filesystem::create_directories(assetsDir, ecMk);
|
||||
|
||||
const std::string webpBase = contentAddressedWebpName(rgba.data(), rgba.size());
|
||||
const auto webpPath = assetsDir / webpBase;
|
||||
|
||||
// Same normalized pixels → same filename: skip WebP encode and skip rewriting the file.
|
||||
if (std::filesystem::exists(webpPath, ecMk)) {
|
||||
j["width"] = w;
|
||||
j["height"] = h;
|
||||
j["row_stride"] = w * 4;
|
||||
j["has_alpha"] = true;
|
||||
j["bits_per_sample"] = 8;
|
||||
j["channels"] = 4;
|
||||
j["image_file"] = webpBase;
|
||||
return j;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> encodedBuf;
|
||||
if (auto advanced = encodeWebpForHistory(rgba.data(), w, h, w * 4); advanced.has_value()) {
|
||||
encodedBuf = std::move(*advanced);
|
||||
} else {
|
||||
std::uint8_t* encoded = nullptr;
|
||||
const std::size_t encodedSize = WebPEncodeRGBA(rgba.data(), w, h, w * 4, kPersistWebPQuality, &encoded);
|
||||
if (encoded != nullptr && encodedSize > 0) {
|
||||
encodedBuf.assign(encoded, encoded + encodedSize);
|
||||
WebPFree(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
if (!encodedBuf.empty() && writeWebpBlobIfAbsent(webpPath, encodedBuf.data(), encodedBuf.size())) {
|
||||
j["width"] = w;
|
||||
j["height"] = h;
|
||||
j["row_stride"] = w * 4;
|
||||
j["has_alpha"] = true;
|
||||
j["bits_per_sample"] = 8;
|
||||
j["channels"] = 4;
|
||||
j["image_file"] = webpBase;
|
||||
return j;
|
||||
}
|
||||
if (encodedBuf.empty()) {
|
||||
kLog.warn("WebP encode failed for notification image id {}", notificationId);
|
||||
} else {
|
||||
kLog.warn("failed to write WebP notification image for id {}", notificationId);
|
||||
}
|
||||
|
||||
const std::string rawBase = contentAddressedRgbaName(img.data.data(), img.data.size());
|
||||
if (writeRawRgbaBlob(assetsDir, rawBase, img.data)) {
|
||||
j["width"] = img.width;
|
||||
j["height"] = img.height;
|
||||
j["row_stride"] = img.rowStride;
|
||||
j["image_file"] = rawBase;
|
||||
return j;
|
||||
}
|
||||
|
||||
kLog.warn("failed to write notification image blob for id {}", notificationId);
|
||||
j["width"] = img.width;
|
||||
j["height"] = img.height;
|
||||
j["row_stride"] = img.rowStride;
|
||||
j["data_b64"] = base64Encode(img.data);
|
||||
return j;
|
||||
}
|
||||
|
||||
nlohmann::json notificationToJson(const Notification& n, const std::filesystem::path& jsonFilePath) {
|
||||
nlohmann::json j;
|
||||
j["id"] = n.id;
|
||||
j["origin"] = std::string(originStr(n.origin));
|
||||
j["app_name"] = n.appName;
|
||||
j["summary"] = n.summary;
|
||||
j["body"] = n.body;
|
||||
j["timeout"] = n.timeout;
|
||||
j["urgency"] = std::string(urgencyStr(n.urgency));
|
||||
j["actions"] = n.actions;
|
||||
if (n.icon.has_value()) {
|
||||
j["icon"] = *n.icon;
|
||||
} else {
|
||||
j["icon"] = nullptr;
|
||||
}
|
||||
if (n.imageData.has_value()) {
|
||||
j["image_data"] = imageToJson(*n.imageData, jsonFilePath, n.id);
|
||||
} else {
|
||||
j["image_data"] = nullptr;
|
||||
}
|
||||
if (n.category.has_value()) {
|
||||
j["category"] = *n.category;
|
||||
} else {
|
||||
j["category"] = nullptr;
|
||||
}
|
||||
if (n.desktopEntry.has_value()) {
|
||||
j["desktop_entry"] = *n.desktopEntry;
|
||||
} else {
|
||||
j["desktop_entry"] = nullptr;
|
||||
}
|
||||
j["received_wall_ms"] = wallToMillis(n.receivedWallClock);
|
||||
j["expiry_wall_ms"] = wallToMillis(n.expiryWallClock);
|
||||
return j;
|
||||
}
|
||||
|
||||
Notification notificationFromJson(const nlohmann::json& j, const std::filesystem::path& jsonFilePath) {
|
||||
Notification n{};
|
||||
n.id = j.value("id", 0U);
|
||||
n.origin = originFrom(j.value("origin", std::string(kOriginExternal)));
|
||||
n.appName = j.value("app_name", std::string());
|
||||
n.summary = j.value("summary", std::string());
|
||||
n.body = j.value("body", std::string());
|
||||
n.timeout = j.value("timeout", 0);
|
||||
n.urgency = urgencyFrom(j.value("urgency", std::string(kUrgencyNormal)));
|
||||
if (j.contains("actions") && j["actions"].is_array()) {
|
||||
for (const auto& a : j["actions"]) {
|
||||
if (a.is_string()) {
|
||||
n.actions.push_back(a.get<std::string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (j.contains("icon") && !j["icon"].is_null()) {
|
||||
n.icon = j["icon"].get<std::string>();
|
||||
}
|
||||
if (j.contains("image_data") && j["image_data"].is_object()) {
|
||||
n.imageData = imageFromJson(j["image_data"], jsonFilePath);
|
||||
}
|
||||
if (j.contains("category") && !j["category"].is_null()) {
|
||||
n.category = j["category"].get<std::string>();
|
||||
}
|
||||
if (j.contains("desktop_entry") && !j["desktop_entry"].is_null()) {
|
||||
n.desktopEntry = j["desktop_entry"].get<std::string>();
|
||||
}
|
||||
const int64_t rw = j.value("received_wall_ms", int64_t{0});
|
||||
if (rw > 0) {
|
||||
n.receivedWallClock = millisToWall(rw);
|
||||
}
|
||||
const int64_t ew = j.value("expiry_wall_ms", int64_t{0});
|
||||
if (ew > 0) {
|
||||
n.expiryWallClock = millisToWall(ew);
|
||||
}
|
||||
const auto steadyNow = Clock::now();
|
||||
n.receivedTime = steadyNow;
|
||||
n.expiryTime.reset();
|
||||
return n;
|
||||
}
|
||||
|
||||
void collectReferencedImageFiles(const nlohmann::json& root, std::unordered_set<std::string>& out) {
|
||||
const auto entries = root.find("entries");
|
||||
if (entries == root.end() || !entries->is_array()) {
|
||||
return;
|
||||
}
|
||||
for (const auto& item : *entries) {
|
||||
if (!item.is_object() || !item.contains("notification")) {
|
||||
continue;
|
||||
}
|
||||
const auto& n = item["notification"];
|
||||
if (!n.contains("image_data") || !n["image_data"].is_object()) {
|
||||
continue;
|
||||
}
|
||||
const auto f = n["image_data"].value("image_file", std::string());
|
||||
if (!f.empty()) {
|
||||
out.insert(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pruneOrphanImageBlobs(const std::filesystem::path& jsonFilePath,
|
||||
const std::unordered_set<std::string>& keepFiles) {
|
||||
const auto assetsDir = assetsDirectoryForJson(jsonFilePath);
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_directory(assetsDir, ec)) {
|
||||
return;
|
||||
}
|
||||
for (const auto& ent : std::filesystem::directory_iterator(assetsDir, ec)) {
|
||||
if (ec || !ent.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
const std::string name = ent.path().filename().string();
|
||||
const auto dot = name.rfind('.');
|
||||
if (dot == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
const std::string ext = name.substr(dot);
|
||||
if (ext != ".rgba" && ext != ".webp") {
|
||||
continue;
|
||||
}
|
||||
const bool legacyPerId = name.size() >= 7 && name[0] == 'n' && name[1] == '_';
|
||||
const bool contentAddressed = name.size() >= 7 && name[0] == 'i' && name[1] == '_';
|
||||
if (!legacyPerId && !contentAddressed) {
|
||||
continue;
|
||||
}
|
||||
if (keepFiles.find(name) == keepFiles.end()) {
|
||||
std::filesystem::remove(ent.path(), ec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool loadNotificationHistoryFromFile(const std::filesystem::path& path, std::deque<NotificationHistoryEntry>& out,
|
||||
std::uint32_t& outNextId, std::uint64_t& outChangeSerial) {
|
||||
out.clear();
|
||||
outNextId = 1;
|
||||
outChangeSerial = 0;
|
||||
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(path, ec)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) {
|
||||
kLog.warn("could not open notification history {}", path.string());
|
||||
return false;
|
||||
}
|
||||
std::stringstream buffer;
|
||||
buffer << in.rdbuf();
|
||||
nlohmann::json root;
|
||||
try {
|
||||
root = nlohmann::json::parse(buffer.str());
|
||||
} catch (const std::exception& e) {
|
||||
kLog.warn("notification history parse failed: {}", e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.is_object()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
outNextId = root.value("next_id", 1U);
|
||||
outChangeSerial = root.value("change_serial", std::uint64_t{0});
|
||||
|
||||
const auto entries = root.find("entries");
|
||||
if (entries == root.end() || !entries->is_array()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint32_t maxId = 0;
|
||||
std::uint64_t maxSerial = 0;
|
||||
|
||||
for (const auto& item : *entries) {
|
||||
if (!item.is_object()) {
|
||||
continue;
|
||||
}
|
||||
NotificationHistoryEntry he;
|
||||
he.notification = notificationFromJson(item.at("notification"), path);
|
||||
he.active = item.value("active", false);
|
||||
if (item.contains("close_reason") && !item["close_reason"].is_null()) {
|
||||
const auto crs = item["close_reason"].get<std::string>();
|
||||
he.closeReason = closeReasonFrom(crs);
|
||||
}
|
||||
he.eventSerial = item.value("event_serial", std::uint64_t{0});
|
||||
|
||||
maxId = std::max(maxId, he.notification.id);
|
||||
maxSerial = std::max(maxSerial, he.eventSerial);
|
||||
|
||||
out.push_back(std::move(he));
|
||||
}
|
||||
|
||||
outNextId = std::max(outNextId, maxId + 1);
|
||||
outChangeSerial = std::max(outChangeSerial, maxSerial);
|
||||
|
||||
constexpr std::size_t kMaxHistoryEntries = 100;
|
||||
while (out.size() > kMaxHistoryEntries) {
|
||||
out.pop_front();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveNotificationHistoryToFile(const std::filesystem::path& path,
|
||||
const std::deque<NotificationHistoryEntry>& entries, std::uint32_t nextId,
|
||||
std::uint64_t changeSerial) {
|
||||
nlohmann::json root;
|
||||
root["version"] = 2;
|
||||
root["next_id"] = nextId;
|
||||
root["change_serial"] = changeSerial;
|
||||
auto& arr = root["entries"] = nlohmann::json::array();
|
||||
|
||||
for (const auto& he : entries) {
|
||||
nlohmann::json je;
|
||||
je["notification"] = notificationToJson(he.notification, path);
|
||||
je["active"] = he.active;
|
||||
if (he.closeReason.has_value()) {
|
||||
je["close_reason"] = std::string(closeReasonStr(*he.closeReason));
|
||||
} else {
|
||||
je["close_reason"] = nullptr;
|
||||
}
|
||||
je["event_serial"] = he.eventSerial;
|
||||
arr.push_back(std::move(je));
|
||||
}
|
||||
|
||||
const std::string tmpPath = path.string() + ".tmp";
|
||||
std::ofstream out(tmpPath, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
kLog.warn("could not write notification history tmp {}", tmpPath);
|
||||
return false;
|
||||
}
|
||||
out << root.dump(2);
|
||||
out.close();
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(tmpPath, path, ec);
|
||||
if (ec) {
|
||||
kLog.warn("could not rename notification history file: {}", ec.message());
|
||||
return false;
|
||||
}
|
||||
std::unordered_set<std::string> keepImageFiles;
|
||||
collectReferencedImageFiles(root, keepImageFiles);
|
||||
pruneOrphanImageBlobs(path, keepImageFiles);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
|
||||
struct NotificationHistoryEntry;
|
||||
|
||||
bool loadNotificationHistoryFromFile(const std::filesystem::path& path, std::deque<NotificationHistoryEntry>& out,
|
||||
std::uint32_t& outNextId, std::uint64_t& outChangeSerial);
|
||||
|
||||
bool saveNotificationHistoryToFile(const std::filesystem::path& path,
|
||||
const std::deque<NotificationHistoryEntry>& entries, std::uint32_t nextId,
|
||||
std::uint64_t changeSerial);
|
||||
@@ -1,8 +1,12 @@
|
||||
#include "notification_manager.h"
|
||||
|
||||
#include "core/deferred_call.h"
|
||||
#include "core/log.h"
|
||||
#include "notification/notification_history_store.h"
|
||||
#include "pipewire/sound_player.h"
|
||||
#include "util/file_utils.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
@@ -39,6 +43,13 @@ namespace {
|
||||
return std::nullopt; // 0 = persistent, -1 = server default (treat as persistent for now)
|
||||
}
|
||||
|
||||
std::optional<WallTimePoint> schedule_expiry_wall(WallTimePoint wallNow, int32_t timeout_ms) noexcept {
|
||||
if (timeout_ms > 0) {
|
||||
return wallNow + std::chrono::milliseconds(timeout_ms);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool has_same_content(const Notification& notification, const std::string& appName, const std::string& summary,
|
||||
const std::string& body) {
|
||||
return notification.appName == appName && notification.summary == summary && notification.body == body;
|
||||
@@ -72,6 +83,7 @@ void NotificationManager::upsertHistory(const Notification& notification, bool a
|
||||
}
|
||||
|
||||
rebuildHistoryIndex();
|
||||
schedulePersistHistory();
|
||||
}
|
||||
|
||||
int NotificationManager::addEventCallback(EventCallback callback) {
|
||||
@@ -92,6 +104,7 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
|
||||
std::optional<std::string> category,
|
||||
std::optional<std::string> desktop_entry) {
|
||||
const auto now = Clock::now();
|
||||
const auto wallNow = WallClock::now();
|
||||
auto log_notification = [](const Notification& n, std::string_view action) {
|
||||
kLog.debug("notification {} #{} origin={} from=\"{}\" urgency={} summary=\"{}\" body=\"{}\" timeout={}ms", action,
|
||||
n.id, origin_str(n.origin), n.appName, urgency_str(n.urgency), n.summary, n.body, n.timeout);
|
||||
@@ -119,6 +132,8 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
|
||||
n.desktopEntry = std::move(desktop_entry);
|
||||
n.receivedTime = now;
|
||||
n.expiryTime = schedule_expiry(now, timeout);
|
||||
n.receivedWallClock = wallNow;
|
||||
n.expiryWallClock = schedule_expiry_wall(wallNow, timeout);
|
||||
|
||||
log_notification(n, "updated");
|
||||
upsertHistory(n, true, std::nullopt);
|
||||
@@ -158,6 +173,8 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
|
||||
.desktopEntry = std::move(desktop_entry),
|
||||
.receivedTime = now,
|
||||
.expiryTime = schedule_expiry(now, timeout),
|
||||
.receivedWallClock = wallNow,
|
||||
.expiryWallClock = schedule_expiry_wall(wallNow, timeout),
|
||||
});
|
||||
m_idToIndex.emplace(id, m_notifications.size() - 1);
|
||||
|
||||
@@ -261,6 +278,7 @@ void NotificationManager::removeHistoryEntry(uint32_t id) {
|
||||
m_history.erase(m_history.begin() + static_cast<std::ptrdiff_t>(it->second));
|
||||
++m_changeSerial;
|
||||
rebuildHistoryIndex();
|
||||
schedulePersistHistory();
|
||||
}
|
||||
|
||||
void NotificationManager::clearHistory() {
|
||||
@@ -272,6 +290,7 @@ void NotificationManager::clearHistory() {
|
||||
m_historyIndex.clear();
|
||||
++m_changeSerial;
|
||||
markNotificationHistorySeen();
|
||||
schedulePersistHistory();
|
||||
}
|
||||
|
||||
std::vector<uint32_t> NotificationManager::expiredIds() const {
|
||||
@@ -312,6 +331,7 @@ void NotificationManager::pauseExpiry(uint32_t id) {
|
||||
return;
|
||||
}
|
||||
m_notifications[it->second].expiryTime.reset();
|
||||
m_notifications[it->second].expiryWallClock.reset();
|
||||
}
|
||||
|
||||
void NotificationManager::resumeExpiry(uint32_t id, int32_t remainingMs) {
|
||||
@@ -321,9 +341,13 @@ void NotificationManager::resumeExpiry(uint32_t id, int32_t remainingMs) {
|
||||
}
|
||||
if (remainingMs <= 0) {
|
||||
m_notifications[it->second].expiryTime = Clock::now();
|
||||
m_notifications[it->second].expiryWallClock = WallClock::now();
|
||||
return;
|
||||
}
|
||||
m_notifications[it->second].expiryTime = Clock::now() + std::chrono::milliseconds(remainingMs);
|
||||
const auto steadyResume = Clock::now();
|
||||
const auto wallResume = WallClock::now();
|
||||
m_notifications[it->second].expiryTime = steadyResume + std::chrono::milliseconds(remainingMs);
|
||||
m_notifications[it->second].expiryWallClock = wallResume + std::chrono::milliseconds(remainingMs);
|
||||
}
|
||||
|
||||
void NotificationManager::setDoNotDisturb(bool enabled) {
|
||||
@@ -358,3 +382,47 @@ void NotificationManager::markNotificationHistorySeen() {
|
||||
m_stateCallback();
|
||||
}
|
||||
}
|
||||
|
||||
void NotificationManager::schedulePersistHistory() {
|
||||
if (m_persistScheduled) {
|
||||
return;
|
||||
}
|
||||
m_persistScheduled = true;
|
||||
DeferredCall::callLater([this]() {
|
||||
m_persistScheduled = false;
|
||||
persistHistoryToDisk();
|
||||
});
|
||||
}
|
||||
|
||||
void NotificationManager::persistHistoryToDisk() {
|
||||
const std::string dir = FileUtils::stateDir();
|
||||
if (dir.empty()) {
|
||||
return;
|
||||
}
|
||||
std::filesystem::path path(dir);
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path, ec);
|
||||
path /= "notification_history.json";
|
||||
(void)saveNotificationHistoryToFile(path, m_history, m_nextId, m_changeSerial);
|
||||
}
|
||||
|
||||
void NotificationManager::loadPersistedHistory() {
|
||||
const std::string dir = FileUtils::stateDir();
|
||||
if (dir.empty()) {
|
||||
return;
|
||||
}
|
||||
std::filesystem::path path(dir);
|
||||
path /= "notification_history.json";
|
||||
std::uint32_t nextId = m_nextId;
|
||||
std::uint64_t serial = m_changeSerial;
|
||||
std::deque<NotificationHistoryEntry> loaded;
|
||||
if (!loadNotificationHistoryFromFile(path, loaded, nextId, serial)) {
|
||||
return;
|
||||
}
|
||||
m_history = std::move(loaded);
|
||||
m_nextId = nextId;
|
||||
m_changeSerial = serial;
|
||||
rebuildHistoryIndex();
|
||||
}
|
||||
|
||||
void NotificationManager::flushPersistedHistory() { persistHistoryToDisk(); }
|
||||
|
||||
@@ -91,9 +91,18 @@ public:
|
||||
[[nodiscard]] bool hasUnreadNotificationHistory() const noexcept;
|
||||
void markNotificationHistorySeen();
|
||||
|
||||
/// Loads persisted history from disk (call once at startup before emitting events).
|
||||
void loadPersistedHistory();
|
||||
/// Writes pending history to disk immediately (e.g. shutdown).
|
||||
void flushPersistedHistory();
|
||||
|
||||
private:
|
||||
void upsertHistory(const Notification& notification, bool active, std::optional<CloseReason> closeReason);
|
||||
void rebuildHistoryIndex();
|
||||
void schedulePersistHistory();
|
||||
void persistHistoryToDisk();
|
||||
|
||||
bool m_persistScheduled = false;
|
||||
|
||||
std::deque<Notification> m_notifications;
|
||||
std::unordered_map<uint32_t, size_t> m_idToIndex;
|
||||
|
||||
@@ -2,26 +2,78 @@
|
||||
|
||||
#include "core/ui_phase.h"
|
||||
#include "i18n/i18n.h"
|
||||
#include "net/uri.h"
|
||||
#include "notification/notification.h"
|
||||
#include "notification/notification_manager.h"
|
||||
#include "render/core/renderer.h"
|
||||
#include "render/core/texture_manager.h"
|
||||
#include "render/scene/node.h"
|
||||
#include "shell/panel/panel_manager.h"
|
||||
#include "time/time_format.h"
|
||||
#include "ui/controls/button.h"
|
||||
#include "ui/controls/flex.h"
|
||||
#include "ui/controls/glyph.h"
|
||||
#include "ui/controls/image.h"
|
||||
#include "ui/controls/label.h"
|
||||
#include "ui/controls/scroll_view.h"
|
||||
#include "ui/controls/segmented.h"
|
||||
#include "ui/palette.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
using namespace control_center;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float kHistoryIconSize = 36.0f;
|
||||
constexpr float kHistoryIconRadius = 8.0f;
|
||||
constexpr float kHistoryIconGlyphSize = 22.0f;
|
||||
|
||||
constexpr float kNotificationActionButtonSize = Style::controlHeightSm;
|
||||
|
||||
std::filesystem::path remoteNotificationIconCachePath(std::string_view url) {
|
||||
return std::filesystem::path("/tmp") / "noctalia-notification-icons" /
|
||||
(std::to_string(std::hash<std::string_view>{}(url)) + ".img");
|
||||
}
|
||||
|
||||
std::string normalizeLocalIconPath(std::string_view iconValue) { return uri::normalizeFileUrl(iconValue); }
|
||||
|
||||
std::string resolveHistoryIconPath(const Notification& n, IconResolver& resolver) {
|
||||
if (!n.icon.has_value() || n.icon->empty()) {
|
||||
return {};
|
||||
}
|
||||
const std::string& iconValue = *n.icon;
|
||||
if (uri::isRemoteUrl(iconValue)) {
|
||||
const auto cached = remoteNotificationIconCachePath(iconValue);
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(cached, ec) && std::filesystem::file_size(cached, ec) > 0) {
|
||||
return cached.string();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::string localPath = normalizeLocalIconPath(iconValue);
|
||||
if (!localPath.empty() && localPath.front() == '/') {
|
||||
if (access(localPath.c_str(), R_OK) == 0) {
|
||||
return localPath;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (localPath.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::string& resolved = resolver.resolve(localPath);
|
||||
return resolved.empty() ? std::string() : resolved;
|
||||
}
|
||||
constexpr int kSummaryMaxLines = 2;
|
||||
constexpr int kBodyMaxLines = 3;
|
||||
constexpr int kExpandedMaxLines = 500;
|
||||
@@ -56,6 +108,45 @@ namespace {
|
||||
|
||||
void applyNotificationCardStyle(Flex& card, float scale) { applySectionCardStyle(card, scale); }
|
||||
|
||||
std::string relativeMetaLine(const Notification& n) {
|
||||
if (n.receivedWallClock.has_value()) {
|
||||
return formatTimeAgo(*n.receivedWallClock);
|
||||
}
|
||||
return formatElapsedSince(n.receivedTime);
|
||||
}
|
||||
|
||||
bool matchesHistoryFilter(const NotificationHistoryEntry& e, std::size_t filterIndex) {
|
||||
if (filterIndex == 0) {
|
||||
return true;
|
||||
}
|
||||
if (!e.notification.receivedWallClock.has_value()) {
|
||||
return false;
|
||||
}
|
||||
const std::time_t entryT = WallClock::to_time_t(*e.notification.receivedWallClock);
|
||||
const std::time_t nowT = WallClock::to_time_t(WallClock::now());
|
||||
std::tm entryL{};
|
||||
std::tm nowL{};
|
||||
localtime_r(&entryT, &entryL);
|
||||
localtime_r(&nowT, &nowL);
|
||||
const bool isToday = entryL.tm_year == nowL.tm_year && entryL.tm_yday == nowL.tm_yday;
|
||||
std::tm yRef = nowL;
|
||||
yRef.tm_hour = 12;
|
||||
yRef.tm_min = 0;
|
||||
yRef.tm_sec = 0;
|
||||
yRef.tm_mday -= 1;
|
||||
mktime(&yRef);
|
||||
const bool isYesterday = entryL.tm_year == yRef.tm_year && entryL.tm_yday == yRef.tm_yday;
|
||||
|
||||
if (filterIndex == 1) {
|
||||
return isToday;
|
||||
}
|
||||
if (filterIndex == 2) {
|
||||
return isYesterday;
|
||||
}
|
||||
// Older
|
||||
return !isToday && !isYesterday;
|
||||
}
|
||||
|
||||
bool canExpandText(Renderer& renderer, std::string_view text, float fontSize, bool bold, float maxWidth,
|
||||
int collapsedMaxLines) {
|
||||
if (text.empty()) {
|
||||
@@ -81,6 +172,23 @@ std::unique_ptr<Flex> NotificationsTab::create() {
|
||||
tab->setGap(Style::spaceSm * scale);
|
||||
m_root = tab.get();
|
||||
|
||||
auto filter = std::make_unique<Segmented>();
|
||||
filter->setScale(scale);
|
||||
filter->setFontSize(Style::fontSizeCaption * scale);
|
||||
filter->addOption(i18n::tr("control-center.notifications.filter.all"));
|
||||
filter->addOption(i18n::tr("control-center.notifications.filter.today"));
|
||||
filter->addOption(i18n::tr("control-center.notifications.filter.yesterday"));
|
||||
filter->addOption(i18n::tr("control-center.notifications.filter.older"));
|
||||
filter->setEqualSegmentWidths(true);
|
||||
filter->setSelectedIndex(m_filterIndex);
|
||||
filter->setOnChange([this](std::size_t idx) {
|
||||
m_filterIndex = idx;
|
||||
m_lastSerial = 0;
|
||||
PanelManager::instance().refresh();
|
||||
});
|
||||
m_filter = filter.get();
|
||||
tab->addChild(std::move(filter));
|
||||
|
||||
auto scroll = std::make_unique<ScrollView>();
|
||||
scroll->setScrollbarVisible(true);
|
||||
scroll->setViewportPaddingH(0.0f);
|
||||
@@ -120,7 +228,7 @@ std::unique_ptr<Flex> NotificationsTab::createHeaderActions() {
|
||||
}
|
||||
|
||||
void NotificationsTab::doLayout(Renderer& renderer, float contentWidth, float bodyHeight) {
|
||||
if (m_root == nullptr || m_scroll == nullptr) {
|
||||
if (m_root == nullptr || m_scroll == nullptr || m_filter == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,6 +265,7 @@ void NotificationsTab::onClose() {
|
||||
m_root = nullptr;
|
||||
m_scroll = nullptr;
|
||||
m_list = nullptr;
|
||||
m_filter = nullptr;
|
||||
m_clearAllButton = nullptr;
|
||||
m_expandedIds.clear();
|
||||
m_lastSerial = 0;
|
||||
@@ -220,7 +329,8 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
const std::int64_t relativeSlot =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count() /
|
||||
15;
|
||||
if (serial == m_lastSerial && std::abs(width - m_lastWidth) < 0.5f && relativeSlot == m_lastRelativeTimeSlot) {
|
||||
if (serial == m_lastSerial && std::abs(width - m_lastWidth) < 0.5f && relativeSlot == m_lastRelativeTimeSlot &&
|
||||
m_filterIndex == m_lastRebuildFilterIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -261,25 +371,68 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
m_lastSerial = serial;
|
||||
m_lastWidth = width;
|
||||
m_lastRelativeTimeSlot = relativeSlot;
|
||||
m_lastRebuildFilterIndex = m_filterIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<const NotificationHistoryEntry*> filtered;
|
||||
filtered.reserve(m_notifications->history().size());
|
||||
for (auto it = m_notifications->history().rbegin(); it != m_notifications->history().rend(); ++it) {
|
||||
const std::string summaryText =
|
||||
it->notification.summary.empty() ? i18n::tr("control-center.notifications.untitled") : it->notification.summary;
|
||||
const std::string& bodyText = it->notification.body;
|
||||
if (matchesHistoryFilter(*it, m_filterIndex)) {
|
||||
filtered.push_back(&*it);
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.empty()) {
|
||||
auto empty = std::make_unique<Flex>();
|
||||
applyNotificationCardStyle(*empty, scale);
|
||||
empty->setAlign(FlexAlign::Center);
|
||||
empty->setGap(Style::spaceSm * scale);
|
||||
empty->setPadding(Style::spaceLg * scale, Style::spaceMd * scale);
|
||||
empty->setMinWidth(cardWidth);
|
||||
|
||||
auto title = std::make_unique<Label>();
|
||||
title->setText(i18n::tr("control-center.notifications.filter-empty-title"));
|
||||
title->setBold(true);
|
||||
title->setFontSize(Style::fontSizeBody * scale);
|
||||
title->setColor(colorSpecFromRole(ColorRole::OnSurface));
|
||||
empty->addChild(std::move(title));
|
||||
|
||||
auto body = std::make_unique<Label>();
|
||||
body->setText(i18n::tr("control-center.notifications.filter-empty-body"));
|
||||
body->setCaptionStyle();
|
||||
body->setFontSize(Style::fontSizeCaption * scale);
|
||||
body->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
||||
empty->addChild(std::move(body));
|
||||
|
||||
m_list->addChild(std::move(empty));
|
||||
m_lastSerial = serial;
|
||||
m_lastWidth = width;
|
||||
m_lastRelativeTimeSlot = relativeSlot;
|
||||
m_lastRebuildFilterIndex = m_filterIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const NotificationHistoryEntry* entry : filtered) {
|
||||
const std::string summaryText = entry->notification.summary.empty()
|
||||
? i18n::tr("control-center.notifications.untitled")
|
||||
: entry->notification.summary;
|
||||
const std::string& bodyText = entry->notification.body;
|
||||
const bool summaryExpandable =
|
||||
canExpandText(renderer, summaryText, Style::fontSizeBody * scale, true, cardTextWidth, kSummaryMaxLines);
|
||||
const bool bodyExpandable =
|
||||
canExpandText(renderer, bodyText, Style::fontSizeBody * scale, false, cardTextWidth, kBodyMaxLines);
|
||||
const bool canExpand = summaryExpandable || bodyExpandable;
|
||||
const bool expanded = canExpand && m_expandedIds.contains(it->notification.id);
|
||||
const bool expanded = canExpand && m_expandedIds.contains(entry->notification.id);
|
||||
if (!canExpand) {
|
||||
m_expandedIds.erase(it->notification.id);
|
||||
m_expandedIds.erase(entry->notification.id);
|
||||
}
|
||||
|
||||
const float iconPx = kHistoryIconSize * scale;
|
||||
const float iconColumn = iconPx + Style::spaceSm * scale;
|
||||
const float headerActionsWidth = actionButtonSize + (canExpand ? (actionButtonsGap + actionButtonSize) : 0.0f);
|
||||
const float metaTextWidth = std::max(0.0f, cardTextWidth - headerActionsWidth - Style::spaceSm * scale);
|
||||
const float leftClusterWidth = cardTextWidth - headerActionsWidth;
|
||||
const float metaTextWidth = std::max(0.0f, leftClusterWidth - iconColumn);
|
||||
|
||||
auto card = std::make_unique<Flex>();
|
||||
applyNotificationCardStyle(*card, scale);
|
||||
@@ -291,20 +444,71 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
header->setJustify(FlexJustify::SpaceBetween);
|
||||
header->setGap(Style::spaceSm * scale);
|
||||
|
||||
auto leftCluster = std::make_unique<Flex>();
|
||||
leftCluster->setDirection(FlexDirection::Horizontal);
|
||||
leftCluster->setAlign(FlexAlign::Center);
|
||||
leftCluster->setGap(Style::spaceSm * scale);
|
||||
leftCluster->setFlexGrow(1.0f);
|
||||
|
||||
auto iconSlot = std::make_unique<Node>();
|
||||
iconSlot->setSize(iconPx, iconPx);
|
||||
bool iconAssigned = false;
|
||||
const std::string iconPath = resolveHistoryIconPath(entry->notification, m_iconResolver);
|
||||
if (!iconPath.empty()) {
|
||||
auto appIcon = std::make_unique<Image>();
|
||||
appIcon->setSize(iconPx, iconPx);
|
||||
appIcon->setPosition(0.0f, 0.0f);
|
||||
appIcon->setRadius(kHistoryIconRadius * scale);
|
||||
appIcon->setFit(ImageFit::Cover);
|
||||
if (appIcon->setSourceFile(renderer, iconPath, static_cast<int>(std::round(iconPx)))) {
|
||||
iconSlot->addChild(std::move(appIcon));
|
||||
iconAssigned = true;
|
||||
}
|
||||
} else if (entry->notification.imageData.has_value()) {
|
||||
const auto& image = *entry->notification.imageData;
|
||||
if (image.width > 0 && image.height > 0 && !image.data.empty()) {
|
||||
auto appIcon = std::make_unique<Image>();
|
||||
appIcon->setSize(iconPx, iconPx);
|
||||
appIcon->setPosition(0.0f, 0.0f);
|
||||
appIcon->setRadius(kHistoryIconRadius * scale);
|
||||
appIcon->setFit(ImageFit::Cover);
|
||||
const bool validImageMetadata = image.bitsPerSample == 8 && ((image.channels == 4 && image.hasAlpha) ||
|
||||
(image.channels == 3 && !image.hasAlpha));
|
||||
const PixmapFormat format = image.channels == 3 ? PixmapFormat::RGB : PixmapFormat::RGBA;
|
||||
if (validImageMetadata && appIcon->setSourceRaw(renderer, image.data.data(), image.data.size(), image.width,
|
||||
image.height, image.rowStride, format, true)) {
|
||||
iconSlot->addChild(std::move(appIcon));
|
||||
iconAssigned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!iconAssigned) {
|
||||
auto fallback = std::make_unique<Glyph>();
|
||||
fallback->setGlyph("bell");
|
||||
fallback->setGlyphSize(kHistoryIconGlyphSize * scale);
|
||||
fallback->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
|
||||
fallback->measure(renderer);
|
||||
fallback->setPosition(std::round((iconPx - fallback->width()) * 0.5f),
|
||||
std::round((iconPx - fallback->height()) * 0.5f));
|
||||
iconSlot->addChild(std::move(fallback));
|
||||
}
|
||||
leftCluster->addChild(std::move(iconSlot));
|
||||
|
||||
auto meta = std::make_unique<Label>();
|
||||
std::string metaLine = it->notification.appName + " • " + formatElapsedSince(it->notification.receivedTime);
|
||||
if (!it->active) {
|
||||
std::string metaLine = entry->notification.appName + " • " + relativeMetaLine(entry->notification);
|
||||
if (!entry->active) {
|
||||
metaLine += " • ";
|
||||
metaLine += statusText(*it);
|
||||
metaLine += statusText(*entry);
|
||||
}
|
||||
meta->setText(std::move(metaLine));
|
||||
meta->setCaptionStyle();
|
||||
meta->setFontSize(Style::fontSizeCaption * scale);
|
||||
meta->setColor(colorSpecFromRole(statusColorRole(*it)));
|
||||
meta->setColor(colorSpecFromRole(statusColorRole(*entry)));
|
||||
meta->setMaxWidth(metaTextWidth);
|
||||
meta->setFlexGrow(1.0f);
|
||||
meta->measure(renderer);
|
||||
header->addChild(std::move(meta));
|
||||
leftCluster->addChild(std::move(meta));
|
||||
header->addChild(std::move(leftCluster));
|
||||
|
||||
auto headerActions = std::make_unique<Flex>();
|
||||
headerActions->setDirection(FlexDirection::Horizontal);
|
||||
@@ -320,7 +524,7 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
expand->setMinHeight(actionButtonSize);
|
||||
expand->setPadding(Style::spaceXs * scale);
|
||||
expand->setRadius(Style::radiusMd * scale);
|
||||
expand->setOnClick([this, id = it->notification.id]() { toggleNotificationExpanded(id); });
|
||||
expand->setOnClick([this, id = entry->notification.id]() { toggleNotificationExpanded(id); });
|
||||
headerActions->addChild(std::move(expand));
|
||||
}
|
||||
|
||||
@@ -333,7 +537,7 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
dismiss->setPadding(Style::spaceXs * scale);
|
||||
dismiss->setRadius(Style::radiusMd * scale);
|
||||
dismiss->setOnClick(
|
||||
[this, id = it->notification.id, active = it->active]() { removeNotificationEntry(id, active); });
|
||||
[this, id = entry->notification.id, active = entry->active]() { removeNotificationEntry(id, active); });
|
||||
headerActions->addChild(std::move(dismiss));
|
||||
header->addChild(std::move(headerActions));
|
||||
card->addChild(std::move(header));
|
||||
@@ -364,4 +568,5 @@ void NotificationsTab::rebuild(Renderer& renderer, float width) {
|
||||
m_lastSerial = serial;
|
||||
m_lastWidth = width;
|
||||
m_lastRelativeTimeSlot = relativeSlot;
|
||||
m_lastRebuildFilterIndex = m_filterIndex;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "shell/control_center/tab.h"
|
||||
#include "system/icon_resolver.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_set>
|
||||
@@ -8,6 +9,7 @@
|
||||
class NotificationManager;
|
||||
class Button;
|
||||
class ScrollView;
|
||||
class Segmented;
|
||||
|
||||
class NotificationsTab : public Tab {
|
||||
public:
|
||||
@@ -26,13 +28,17 @@ private:
|
||||
void rebuild(Renderer& renderer, float width);
|
||||
|
||||
NotificationManager* m_notifications = nullptr;
|
||||
IconResolver m_iconResolver;
|
||||
Flex* m_root = nullptr;
|
||||
ScrollView* m_scroll = nullptr;
|
||||
Flex* m_list = nullptr;
|
||||
Button* m_clearAllButton = nullptr;
|
||||
Segmented* m_filter = nullptr;
|
||||
std::size_t m_filterIndex = 0;
|
||||
std::unordered_set<uint32_t> m_expandedIds;
|
||||
std::uint64_t m_lastSerial = 0;
|
||||
float m_lastWidth = -1.0f;
|
||||
/// Wall-clock coarse slot so relative times (e.g. "2 min ago") refresh without churning every frame.
|
||||
std::int64_t m_lastRelativeTimeSlot = -1;
|
||||
std::size_t m_lastRebuildFilterIndex = static_cast<std::size_t>(-1);
|
||||
};
|
||||
|
||||
@@ -78,10 +78,25 @@ Button* Segmented::makeSegmentButton(std::string_view label, std::string_view gl
|
||||
btn->setPadding(Style::spaceXs * m_scale, Style::spaceMd * m_scale);
|
||||
btn->setOnClick([this, index]() { setSelectedIndex(index); });
|
||||
Button* raw = btn.get();
|
||||
raw->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
|
||||
raw->setContentAlign(ButtonContentAlign::Center);
|
||||
addChild(std::move(btn));
|
||||
return raw;
|
||||
}
|
||||
|
||||
void Segmented::setEqualSegmentWidths(bool equalWidths) {
|
||||
if (m_equalSegmentWidths == equalWidths) {
|
||||
return;
|
||||
}
|
||||
m_equalSegmentWidths = equalWidths;
|
||||
for (Button* b : m_buttons) {
|
||||
if (b != nullptr) {
|
||||
b->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
|
||||
}
|
||||
}
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Segmented::refreshVariants() {
|
||||
const std::size_t n = m_buttons.size();
|
||||
const float r = Style::radiusMd * m_scale;
|
||||
|
||||
@@ -24,6 +24,9 @@ public:
|
||||
|
||||
void setOnChange(std::function<void(std::size_t)> callback);
|
||||
|
||||
// When true, each segment gets flexGrow 1 so the group fills the available width (e.g. full bar).
|
||||
void setEqualSegmentWidths(bool equalWidths);
|
||||
|
||||
private:
|
||||
Button* makeSegmentButton(std::string_view label, std::string_view glyph, std::size_t index);
|
||||
void refreshVariants();
|
||||
@@ -35,4 +38,5 @@ private:
|
||||
std::function<void(std::size_t)> m_onChange;
|
||||
float m_fontSize = 0.0f;
|
||||
float m_scale = 1.0f;
|
||||
bool m_equalSegmentWidths = false;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user