From 2d49024bd6bcf8c490904d7df3bc878967541c83 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Tue, 29 Sep 2026 13:04:54 +0800 Subject: [PATCH] [feat] add announcement inbox with local read tracking --- .../src/gui/application/gui_application.cpp | 91 +++++ .../src/gui/application/gui_application.h | 1 + .../assets/localization/localization_data.h | 11 + .../src/gui/runtime/announcement_inbox.cpp | 344 ++++++++++++++++++ .../src/gui/runtime/announcement_inbox.h | 66 ++++ .../src/gui/runtime/connection_runtime.cpp | 6 +- apps/desktop/src/gui/runtime/gui_runtime.h | 2 + .../src/gui/runtime/peer_event_handler.cpp | 7 +- apps/desktop/src/gui/ui/crossdesk_ui.slint | 1 + apps/desktop/src/gui/ui/main_window.slint | 120 +++++- apps/desktop/src/gui/ui/message_panel.slint | 105 ++++++ apps/desktop/src/gui/ui/ui_localization.h | 13 + 12 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/gui/runtime/announcement_inbox.cpp create mode 100644 apps/desktop/src/gui/runtime/announcement_inbox.h create mode 100644 apps/desktop/src/gui/ui/message_panel.slint diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index deb65d32..babd0dab 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -906,6 +907,8 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x, } // namespace struct GuiApplication::SlintUi { + uint64_t announcement_version = UINT64_MAX; + std::string announcement_selected_id, announcement_selected_revision; slint::ComponentHandle main = ui::MainWindow::create(); std::shared_ptr> release_note_blocks_model = @@ -981,6 +984,8 @@ int GuiApplication::Run() { dll_log_path_ = exec_log_path_; InitializeLogger(); cache_path_ = path_manager_->GetCachePath().string(); + announcements_.SetStorageDirectory(path_manager_->GetCachePath() / + "announcement_reads"); config_center_ = std::make_unique(cache_path_ + "/config.ini"); LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION); @@ -1267,6 +1272,27 @@ void GuiApplication::ResetSettingsUi() { } void GuiApplication::BindMainCallbacks() { + ui_->main->on_refresh_announcements([this] { announcements_.Refresh(); }); + ui_->main->on_announcement_page([this](int offset) { + ui_->announcement_selected_id.clear(); + ui_->main->set_announcement_selected_id(""); + announcements_.Refresh(offset); + }); + ui_->main->on_open_announcement([this](slint::SharedString id, + slint::SharedString revision) { + const auto snapshot = announcements_.Get(); + for (const auto& item : snapshot.items) { + if (std::to_string(item["id"].get()) != id.data() || + std::to_string(item["revision"].get()) != revision.data()) + continue; + ui_->announcement_selected_id = id.data(); + ui_->announcement_selected_revision = revision.data(); + ui_->announcement_version = UINT64_MAX; + announcements_.Read(item["id"], item["revision"]); + SyncAnnouncements(); + break; + } + }); auto& main = ui_->main; main->window().on_close_requested([this] { // Keep the application and all active sessions alive. The tray helper @@ -2284,10 +2310,75 @@ void GuiApplication::UpdateLocalization() { ui_->localized_language = language; } +void GuiApplication::SyncAnnouncements() { + if (signal_connected_) { + announcements_.Configure(params_.signal_server_ip, + params_.signal_server_port, client_id_); + } + const auto request = announcements_.NextRequest(); + if (!request.is_null()) { + const auto payload = request.dump(); + if (!peer_ || SendSignalMessage(peer_, payload.data(), payload.size()) != 0) + announcements_.Failed(request["request_id"]); + } + if (ui_->announcement_version == announcements_.Version()) return; + const auto state = announcements_.Get(); + ui_->announcement_version = state.version; + ui_->main->set_announcement_unread(state.unread); + ui_->main->set_announcement_total(state.total); + ui_->main->set_announcement_offset(state.offset); + ui_->main->set_announcement_loading(state.loading); + ui_->main->set_announcement_failed(state.failed); + ui_->main->set_announcement_read_save_failed(state.read_save_failed); + ui_->main->set_announcement_loaded(state.loaded); + std::vector rows; + bool selected_found = false; + for (const auto& item : state.items) { + ui::AnnouncementItem row; + const auto id = std::to_string(item["id"].get()); + const auto revision = std::to_string(item["revision"].get()); + row.id = UiText(id); + row.revision = UiText(revision); + row.title = UiText(item["title"].get()); + const auto timestamp = + static_cast(item["updated_at"].get()); + std::tm local{}; +#ifdef _WIN32 + localtime_s(&local, ×tamp); +#else + localtime_r(×tamp, &local); +#endif + char date[32]{}; + std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local); + row.date = UiText(date); + row.read = item["read"].get(); + if (id == ui_->announcement_selected_id && + revision == ui_->announcement_selected_revision) { + selected_found = true; + ui_->main->set_announcement_selected_id(row.id); + ui_->main->set_announcement_selected_title(row.title); + ui_->main->set_announcement_selected_body( + UiText(item["body"].get())); + ui_->main->set_announcement_selected_date(row.date); + ui_->main->set_announcement_selected_read(row.read); + } + rows.push_back(std::move(row)); + } + if (!selected_found) { + ui_->announcement_selected_id.clear(); + ui_->main->set_announcement_selected_id(""); + ui_->main->set_announcement_selected_body(""); + } + ui_->main->set_announcements( + std::make_shared>( + std::move(rows))); +} + void GuiApplication::SyncMainWindow() { if (!ui_) { return; } + SyncAnnouncements(); #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #endif diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index bef7d322..c368179b 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -56,6 +56,7 @@ private: void HandlePasswordChangeResult(); void HandleCredentialRecovery(); void SyncMainWindow(); + void SyncAnnouncements(); void SyncConnectionDialog(); void SyncPlatformDialogs(); void SyncStreamWindow(); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index a5c86407..80ded441 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -20,6 +20,17 @@ struct TranslationRow { // Single source of truth for all UI strings. #define CROSSDESK_LOCALIZATION_ALL(X) \ + X(messages, u8"消息", "Messages", u8"Сообщения") \ + X(message_unread, u8"未读", "Unread", u8"Не прочитано") \ + X(message_read, u8"已读", "Read", u8"Прочитано") \ + X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \ + X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \ + X(message_refresh, u8"刷新", "Refresh", u8"Обновить") \ + X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \ + X(message_unavailable, u8"同步失败,请重试或检查服务端是否支持公告。", "Unable to sync. Retry or check server support.", u8"Ошибка синхронизации. Повторите или проверьте поддержку сервера.") \ + X(message_offline, u8"当前离线,阅读状态仍保存在本机。", "Offline. Reading status is saved on this device.", u8"Нет подключения. Статус чтения сохраняется на этом устройстве.") \ + X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \ + X(message_close, u8"关闭", "Close", u8"Закрыть") \ X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \ X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \ X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \ diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.cpp b/apps/desktop/src/gui/runtime/announcement_inbox.cpp new file mode 100644 index 00000000..93e2606c --- /dev/null +++ b/apps/desktop/src/gui/runtime/announcement_inbox.cpp @@ -0,0 +1,344 @@ +#include "runtime/announcement_inbox.h" + +#include + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +namespace crossdesk { +namespace { +using nlohmann::json; +bool Integer(const json& object, const char* key, int64_t low, int64_t high) { + return object.contains(key) && object[key].is_number_integer() && + object[key] >= low && object[key] <= high; +} +std::string ScopeFilename(const std::string& scope) { + unsigned char digest[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(scope.data()), scope.size(), + digest); + std::string name; + constexpr char hex[] = "0123456789abcdef"; + for (auto byte : digest) { + name += hex[byte >> 4]; + name += hex[byte & 15]; + } + return name + ".json"; +} +} // namespace + +AnnouncementInbox::Snapshot AnnouncementInbox::Get() const { + std::lock_guard lock(mutex_); + return state_; +} +uint64_t AnnouncementInbox::Version() const { + std::lock_guard lock(mutex_); + return state_.version; +} +void AnnouncementInbox::SetStorageDirectory( + const std::filesystem::path& directory) { + std::lock_guard lock(mutex_); + directory_ = directory; +} +void AnnouncementInbox::Configure(std::string host, int port, + const std::string& device_id) { + std::lock_guard lock(mutex_); + if (directory_.empty() || host.empty() || port <= 0 || device_id.empty()) + return; + for (auto& ch : host) + if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A'; + if (host.back() == '.') host.pop_back(); + const auto scope = json::array({host, port, device_id}).dump(); + if (scope_ == scope) return; + scope_ = scope; + read_file_ = directory_ / ScopeFilename(scope); + const auto version = state_.version + 1; + state_ = {}; + state_.version = version; + catalog_.clear(); + catalog_revision_ = -1; + LoadReads(); + BeginCatalog(); +} +void AnnouncementInbox::Reset() { + std::lock_guard lock(mutex_); + const auto version = state_.version + 1; + state_ = {}; + state_.version = version; + scope_.clear(); + read_file_.clear(); + reads_.clear(); + catalog_.clear(); + catalog_revision_ = -1; + connected_ = false; + BeginCatalog(); +} +void AnnouncementInbox::BeginCatalog() { + pending_catalog_.clear(); + pending_revision_ = -1; + pending_total_ = 0; + phase_ = Phase::Catalog; + request_id_.clear(); + state_.loading = false; + refresh_ = connected_; + ++state_.version; +} +void AnnouncementInbox::SetConnected(bool connected) { + std::lock_guard lock(mutex_); + connected_ = connected; + BeginCatalog(); +} +void AnnouncementInbox::Refresh(int offset) { + std::lock_guard lock(mutex_); + if (offset >= 0) state_.offset = offset; + if (offset < 0 || catalog_revision_ < 0) { + BeginCatalog(); + } else { + phase_ = Phase::Page; + request_id_.clear(); + refresh_ = connected_; + } +} +void AnnouncementInbox::UpdateReadingState() { + state_.unread = 0; + for (const auto& [id, revision] : catalog_) { + const auto read = reads_.find(id); + if (read == reads_.end() || read->second != revision) ++state_.unread; + } + for (auto& item : state_.items) { + const auto read = reads_.find(item["id"]); + item["read"] = read != reads_.end() && read->second == item["revision"]; + } +} +bool AnnouncementInbox::Read(int64_t id, int64_t revision) { + std::lock_guard lock(mutex_); + for (const auto& item : state_.items) { + if (item["id"] != id || item["revision"] != revision) continue; + if (item["read"].get()) return true; + auto updated = reads_; + updated[id] = revision; + if (!SaveReads(updated)) { + state_.read_save_failed = true; + ++state_.version; + return false; + } + reads_ = std::move(updated); + state_.read_save_failed = false; + UpdateReadingState(); + ++state_.version; + return true; + } + return false; +} +void AnnouncementInbox::Fail() { + request_id_.clear(); + refresh_ = false; + state_.loading = false; + state_.failed = true; + ++state_.version; +} +nlohmann::json AnnouncementInbox::NextRequest( + std::chrono::steady_clock::time_point now) { + std::lock_guard lock(mutex_); + if (!connected_ || scope_.empty()) return nullptr; + if (state_.loading && now >= deadline_) Fail(); + if (!refresh_) return nullptr; + refresh_ = false; + request_id_ = std::to_string(++serial_); + state_.loading = true; + state_.failed = false; + ++state_.version; + deadline_ = now + std::chrono::seconds(12); + return {{"type", "announcements_list"}, + {"request_id", request_id_}, + {"summary_only", phase_ == Phase::Catalog}, + {"offset", phase_ == Phase::Catalog + ? static_cast(pending_catalog_.size()) + : state_.offset}}; +} +void AnnouncementInbox::Failed(const std::string& request_id) { + std::lock_guard lock(mutex_); + if (request_id == request_id_) Fail(); +} +void AnnouncementInbox::Receive(const nlohmann::json& message) { + std::lock_guard lock(mutex_); + if (!message.is_object() || !message.contains("request_id") || + message["request_id"] != request_id_ || request_id_.empty()) + return; + if (message.contains("error")) { + Fail(); + return; + } + const bool summary = phase_ == Phase::Catalog; + const int limit = summary ? 200 : 20; + if (!Integer(message, "offset", 0, INT32_MAX) || + !Integer(message, "total", 0, INT32_MAX) || + !Integer(message, "catalog_revision", 0, INT64_MAX) || + !message.contains("summary_only") || message["summary_only"] != summary || + !message.contains("items") || !message["items"].is_array() || + message["items"].size() > static_cast(limit)) { + Fail(); + return; + } + const int offset = message["offset"], total = message["total"]; + const int64_t revision = message["catalog_revision"]; + if (offset > total || + message["items"].size() != + static_cast((std::min)(limit, total - offset))) { + Fail(); + return; + } + const auto expected_revision = + summary ? pending_revision_ : catalog_revision_; + if (expected_revision >= 0 && expected_revision != revision) { + BeginCatalog(); + return; + } + for (const auto& item : message["items"]) { + if (!item.is_object() || !Integer(item, "id", 1, INT64_MAX) || + !Integer(item, "revision", 1, INT64_MAX)) { + Fail(); + return; + } + if (!summary) { + if (!Integer(item, "updated_at", 1, 32503680000LL)) { + Fail(); + return; + } + for (const auto* key : {"title", "body"}) { + if (!item.contains(key) || !item[key].is_string() || + item[key].get_ref().size() > + (std::string(key) == "title" ? 240u : 8000u)) { + Fail(); + return; + } + } + const auto found = catalog_.find(item["id"]); + if (found == catalog_.end() || found->second != item["revision"]) { + Fail(); + return; + } + } + } + request_id_.clear(); + state_.loading = false; + if (summary) { + if (pending_revision_ < 0) { + pending_revision_ = revision; + pending_total_ = total; + } + if (pending_total_ != total || + offset != static_cast(pending_catalog_.size())) { + Fail(); + return; + } + for (const auto& item : message["items"]) { + if (!pending_catalog_.emplace(item["id"], item["revision"]).second) { + Fail(); + return; + } + } + if (static_cast(pending_catalog_.size()) < total) { + refresh_ = true; + ++state_.version; + return; + } + catalog_ = std::move(pending_catalog_); + catalog_revision_ = revision; + state_.total = total; + auto retained = json::array(); + for (const auto& item : state_.items) { + const auto found = catalog_.find(item["id"]); + if (found != catalog_.end() && found->second == item["revision"]) + retained.push_back(item); + } + state_.items = std::move(retained); + // Bound local history to published announcements; persist pruning on read. + for (auto it = reads_.begin(); it != reads_.end();) { + if (!catalog_.count(it->first)) + it = reads_.erase(it); + else + ++it; + } + phase_ = Phase::Page; + refresh_ = total > 0; + if (!total) { + state_.offset = 0; + state_.loaded = true; + } + } else { + if (total != static_cast(catalog_.size())) { + Fail(); + return; + } + state_.items = message["items"]; + state_.offset = offset; + state_.loaded = true; + } + UpdateReadingState(); + state_.failed = false; + ++state_.version; +} + +void AnnouncementInbox::LoadReads() { + reads_.clear(); + std::error_code ec; + const auto size = std::filesystem::file_size(read_file_, ec); + if (ec || size > 16 * 1024 * 1024) return; + std::ifstream input(read_file_, std::ios::binary); + const auto data = json::parse(input, nullptr, false); + if (!data.is_object() || !data.contains("scope") || data["scope"] != scope_ || + !data.contains("version") || data["version"] != 1 || + !data.contains("reads") || !data["reads"].is_object()) + return; + for (const auto& entry : data["reads"].items()) { + int64_t id = 0; + const auto parsed = std::from_chars( + entry.key().data(), entry.key().data() + entry.key().size(), id); + if (parsed.ec == std::errc{} && + parsed.ptr == entry.key().data() + entry.key().size() && id > 0 && + entry.value().is_number_integer() && entry.value() > 0 && + entry.value() <= INT64_MAX) + reads_[id] = entry.value(); + } +} +bool AnnouncementInbox::SaveReads(const Revisions& reads) const { + if (read_file_.empty()) return false; + std::error_code ec; + std::filesystem::create_directories(directory_, ec); + if (ec) return false; + auto entries = json::object(); + for (const auto& [id, revision] : reads) + entries[std::to_string(id)] = revision; + const auto payload = + json({{"version", 1}, {"scope", scope_}, {"reads", entries}}).dump(); + auto temporary = read_file_; + temporary += ".tmp-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + output.write(payload.data(), static_cast(payload.size())); + output.close(); + if (!output) { + std::filesystem::remove(temporary, ec); + return false; + } + } +#ifdef _WIN32 + const bool saved = + MoveFileExW(temporary.c_str(), read_file_.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + std::filesystem::rename(temporary, read_file_, ec); + const bool saved = !ec; +#endif + if (!saved) std::filesystem::remove(temporary, ec); + return saved; +} +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.h b/apps/desktop/src/gui/runtime/announcement_inbox.h new file mode 100644 index 00000000..1db53df9 --- /dev/null +++ b/apps/desktop/src/gui/runtime/announcement_inbox.h @@ -0,0 +1,66 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-29 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _ANNOUNCEMENT_INBOX_H_ +#define _ANNOUNCEMENT_INBOX_H_ + +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk { +// Announcement data is shared; reading state only persists to the local file. +class AnnouncementInbox { + public: + struct Snapshot { + nlohmann::json items = nlohmann::json::array(); + int offset = 0, total = 0, unread = 0; + bool loaded = false, loading = false, failed = false; + bool read_save_failed = false; + uint64_t version = 0; + }; + Snapshot Get() const; + uint64_t Version() const; + void SetStorageDirectory(const std::filesystem::path& directory); + // Called on the UI thread after login supplies the actual device ID. + void Configure(std::string host, int port, const std::string& device_id); + void Reset(); + void SetConnected(bool connected); + void Refresh(int offset = -1); + bool Read(int64_t id, int64_t revision); + nlohmann::json NextRequest(std::chrono::steady_clock::time_point now = + std::chrono::steady_clock::now()); + void Failed(const std::string& request_id); + void Receive(const nlohmann::json& message); + + private: + using Revisions = std::map; + enum class Phase { Catalog, Page }; + void BeginCatalog(); + void Fail(); + void UpdateReadingState(); + void LoadReads(); + bool SaveReads(const Revisions& reads) const; + + mutable std::mutex mutex_; + Snapshot state_; + std::filesystem::path directory_, read_file_; + std::string scope_, request_id_; + Revisions reads_, catalog_, pending_catalog_; + int64_t catalog_revision_ = -1, pending_revision_ = -1; + int pending_total_ = 0; + Phase phase_ = Phase::Catalog; + bool connected_ = false, refresh_ = false; + uint64_t serial_ = 0; + std::chrono::steady_clock::time_point deadline_{}; +}; +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index c74d83bb..37b47f57 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -312,8 +312,12 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) { } void GuiRuntime::CloseConnectionPeer() { - if (!peer_) return; + if (!peer_) { + announcements_.Reset(); + return; + } peer_events_->Deactivate(); + announcements_.Reset(); std::vector controllers; { std::shared_lock lock(connection_status_mutex_); diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 08476c58..fb727cfa 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -20,6 +20,7 @@ #include "features/settings/settings_manager.h" #include "privacy_controller.h" #include "runtime/gui_state.h" +#include "runtime/announcement_inbox.h" #include "runtime/peer_event_handler.h" #include "runtime/privacy_session_policy.h" @@ -99,6 +100,7 @@ class GuiRuntime : protected gui_detail::GuiState { #endif PrivacyController privacy_; + AnnouncementInbox announcements_; PrivacySessionPolicy privacy_sessions_; std::mutex privacy_commands_mutex_; std::deque> privacy_commands_; diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index f16e7567..fd91aba2 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -79,7 +79,11 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size, return; } std::string type = j["type"].get(); - if (type == "presence") { + if (type == "announcements_changed") { + runtime->announcements_.Refresh(); + } else if (type == "announcements") { + runtime->announcements_.Receive(j); + } else if (type == "presence") { if (j.contains("devices") && j["devices"].is_array()) { for (auto& dev : j["devices"]) { if (!dev.is_object()) { @@ -172,6 +176,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id, std::string client_id(user_id, user_id_size); if (client_id == runtime->client_id_) { + runtime->announcements_.SetConnected(status == SignalStatus::SignalConnected); runtime->signal_status_ = status; runtime->device_presence_cache_.SetSignalConnected( status == SignalStatus::SignalConnected); diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index f1169df1..23f0a0b2 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -1,5 +1,6 @@ // Single AOT entry point. Generating one header for the entire UI avoids // duplicate imported-component definitions when C++ uses all three windows. +export { AnnouncementItem, MessageStrings } from "message_panel.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { ServerWindow, ControllerEntry } from "server_window.slint"; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index ec9ba250..780d52fe 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -1,4 +1,5 @@ import { LineEdit, ScrollView } from "std-widgets.slint"; +import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint"; import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; export struct RecentConnection { @@ -273,6 +274,23 @@ export component MainWindow inherits Window { in property signal-connected: false; in property signal-tls-error: false; in property <[RecentConnection]> recent-connections; + in property <[AnnouncementItem]> announcements; + in property announcement-unread; + in property announcement-total; + in property announcement-offset; + in property announcement-loading; + in property announcement-failed; + in property announcement-read-save-failed; + in property announcement-loaded; + in-out property messages-open: false; + in property announcement-selected-id; + in property announcement-selected-title; + in property announcement-selected-body; + in property announcement-selected-date; + in property announcement-selected-read; + callback open-announcement(string, string); + callback refresh-announcements; + callback announcement-page(int); in property update-available: false; in property update-check-status: idle; private property update-status-text: @@ -410,6 +428,10 @@ export component MainWindow inherits Window { if root.wayland-notice-open { focus-sink.focus(); } } + changed messages-open => { + if root.messages-open { focus-sink.focus(); } + } + reset-remote-id => { root.remote-id-input = ""; remote-id-editor.focus(); @@ -623,6 +645,10 @@ export component MainWindow inherits Window { } return accept; } + if root.messages-open { + if event.text == Key.Escape { root.messages-open = false; } + return accept; + } return reject; } } @@ -651,11 +677,12 @@ export component MainWindow inherits Window { icon-size: 14px; hover-background: #e1e5eb; pressed-background: #cbd1d9; + tooltip: root.announcement-unread > 0 ? MessageStrings.unread + " " + root.announcement-unread : ""; z: 8; clicked => { root.menu-open = !root.menu-open; } } if root.update-available && !root.menu-open && root.update-icon-blink-on: Text { - x: menu-button.x + 16px; + x: menu-button.x + (root.announcement-unread > 0 ? -18px : 16px); y: menu-button.y + 1px; width: 16px; height: 16px; @@ -668,6 +695,33 @@ export component MainWindow inherits Window { vertical-alignment: center; z: 9; } + if root.announcement-unread > 0: Rectangle { + x: menu-button.x + menu-button.width - self.width + 4px; + y: menu-button.y - 1px; + width: max(16px, unread-badge-label.preferred-width + 8px); + height: 16px; + border-radius: 8px; + border-width: 1px; + border-color: white; + background: #d92d20; + z: 9; + accessible-role: button; + accessible-label: MessageStrings.messages + " · " + MessageStrings.unread + " " + root.announcement-unread; + accessible-action-default => { root.menu-open = !root.menu-open; } + unread-badge-label := Text { + text: root.announcement-unread > 99 ? "99+" : "" + root.announcement-unread; + color: white; + font-size: 10px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + accessible-role: none; + } + TouchArea { + mouse-cursor: pointer; + clicked => { root.menu-open = !root.menu-open; } + } + } // Local and remote desktop panels. Rectangle { @@ -1146,9 +1200,10 @@ export component MainWindow inherits Window { if root.menu-open: Rectangle { x: menu-button.x + menu-button.width - self.width; y: menu-button.y + menu-button.height + 2px; - width: min(118px, max(menu-settings-label.preferred-width, - menu-about-label.preferred-width + (root.update-available ? 25px : 0px)) + 28px); - height: 64px; + width: max(menu-settings-label.preferred-width, + max(menu-messages-label.preferred-width, + menu-about-label.preferred-width + (root.update-available ? 25px : 0px))) + 28px; + height: 93px; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; @@ -1181,6 +1236,31 @@ export component MainWindow inherits Window { clicked => { root.menu-open = false; root.settings-open = true; } } } + menu-messages := Rectangle { + height: 27px; + background: messages-touch.pressed ? #d8dadd + : messages-touch.has-hover ? #f0f1f2 : white; + border-radius: 3px; + accessible-role: button; + accessible-label: menu-messages-label.text; + accessible-action-default => { messages-touch.clicked(); } + menu-messages-label := Text { + x: 9px; + height: parent.height; + text: MessageStrings.messages + (root.announcement-unread > 0 ? " (" + root.announcement-unread + ")" : ""); + color: #2d3138; + vertical-alignment: center; + accessible-role: none; + } + messages-touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.menu-open = false; + root.messages-open = true; + root.refresh-announcements(); + } + } + } menu-about := Rectangle { height: 27px; background: about-touch.pressed ? #d8dadd @@ -1226,6 +1306,38 @@ export component MainWindow inherits Window { background: ImGuiLineStyle.separator; z: 1; } + Rectangle { + x: 4px; + y: 60.5px; + width: parent.width - 8px; + height: 1px; + background: ImGuiLineStyle.separator; + z: 1; + } + } + + if root.messages-open: Rectangle { + background: #00000042; + z: 40; + TouchArea { clicked => { focus-sink.focus(); } } + MessagePanel { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 520px; + height: 330px; + items: root.announcements; + unread-count: root.announcement-unread; total: root.announcement-total; offset: root.announcement-offset; + loading: root.announcement-loading; failed: root.announcement-failed; loaded: root.announcement-loaded; + read-save-failed: root.announcement-read-save-failed; + connected: root.signal-connected; + selected-id: root.announcement-selected-id; selected-title: root.announcement-selected-title; + selected-body: root.announcement-selected-body; selected-date: root.announcement-selected-date; + selected-read: root.announcement-selected-read; + open-message(id, revision) => { root.open-announcement(id, revision); } + refresh => { root.refresh-announcements(); } + change-page(offset) => { root.announcement-page(offset); } + close => { root.messages-open = false; } + } } copy-timer := Timer { diff --git a/apps/desktop/src/gui/ui/message_panel.slint b/apps/desktop/src/gui/ui/message_panel.slint new file mode 100644 index 00000000..05affa82 --- /dev/null +++ b/apps/desktop/src/gui/ui/message_panel.slint @@ -0,0 +1,105 @@ +import { ScrollView } from "std-widgets.slint"; +import { CompactButton, DialogSurface, ImGuiFontStyle } from "common.slint"; + +export struct AnnouncementItem { + id: string, + revision: string, + title: string, + date: string, + read: bool, +} + +export global MessageStrings { + in-out property messages: "Messages"; + in-out property unread: "Unread"; + in-out property read: "Read"; + in-out property empty: "No announcements"; + in-out property select-message: "Select an announcement to read"; + in-out property refresh: "Refresh"; + in-out property loading: "Loading…"; + in-out property unavailable: "Unable to sync. Retry or check server support."; + in-out property offline: "Offline. Reading status is saved on this device."; + in-out property read-save-error: "Unable to save reading status locally. Open the announcement again to retry."; + in-out property close: "Close"; +} + +export component MessagePanel inherits DialogSurface { + in property <[AnnouncementItem]> items; + in property unread-count; + in property total; + in property offset; + in property loading; + in property failed; + in property read-save-failed; + in property loaded; + in property connected; + in property selected-id; + in property selected-title; + in property selected-body; + in property selected-date; + in property selected-read; + callback open-message(string, string); + callback refresh; + callback change-page(int); + callback close; + background: #f7f8fa; + TouchArea { } + Rectangle { + x: 1px; y: 1px; width: parent.width - 2px; height: 30px; + background: #e7ebf0; border-radius: 7px; + Rectangle { y: parent.height - 7px; height: 7px; background: #e7ebf0; } + Text { x: 12px; width: parent.width - 54px; height: parent.height; text: MessageStrings.messages + " · " + MessageStrings.unread + " " + root.unread-count; font-size: 13px; font-weight: 600; color: #202d3d; vertical-alignment: center; overflow: elide; } + Rectangle { + x: parent.width - 31px; y: 3px; width: 25px; height: 24px; + border-radius: 3px; + background: close-touch.has-hover ? #d8dde5 : transparent; + accessible-role: button; + accessible-label: MessageStrings.close; + accessible-action-default => { root.close(); } + Text { text: "×"; font-size: 20px; color: #4a5568; horizontal-alignment: center; vertical-alignment: center; } + close-touch := TouchArea { mouse-cursor: pointer; clicked => { root.close(); } } + } + } + Rectangle { + x: 12px; y: 42px; width: 180px; height: parent.height - 100px; + background: white; border-width: 1px; border-color: #e2e5e9; + ScrollView { + width: parent.width; height: parent.height; + VerticalLayout { + width: 160px; spacing: 3px; padding: 5px; + for item in root.items: Rectangle { + height: 72px; width: 150px; + background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f1f5fa : white; + border-radius: 4px; + Text { x: 8px; y: 5px; width: parent.width - 16px; height: 22px; text: item.title; overflow: elide; font-weight: item.read ? 400 : 600; color: #202d3d; vertical-alignment: center; } + Text { x: 8px; y: 32px; width: parent.width - 16px; text: item.read ? MessageStrings.read : MessageStrings.unread; color: item.read ? #718096 : #2463c7; font-size: 11px; overflow: elide; } + Text { x: 8px; y: 50px; width: parent.width - 16px; text: item.date; color: #718096; font-size: 10px; overflow: elide; } + accessible-role: button; + accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread); + accessible-action-default => { root.open-message(item.id, item.revision); } + entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } } + } + } + } + if root.items.length == 0 && root.loaded && !root.loading: Text { text: MessageStrings.empty; color: #718096; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + x: 202px; y: 42px; width: parent.width - 214px; height: parent.height - 100px; + background: white; border-width: 1px; border-color: #e2e5e9; + if root.selected-id == "": Text { x: 14px; width: parent.width - 28px; text: MessageStrings.select-message; color: #718096; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; } + if root.selected-id != "": ScrollView { + width: parent.width; height: parent.height; + VerticalLayout { + width: root.width - 234px; padding: 12px; spacing: 10px; alignment: start; + Text { vertical-stretch: 0; text: root.selected-title; wrap: word-wrap; font-size: 14px; font-weight: 600; color: #202d3d; } + Text { vertical-stretch: 0; text: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #718096; font-size: 11px; wrap: word-wrap; } + Text { vertical-stretch: 0; text: root.selected-body; wrap: word-wrap; color: #303b4a; font-size: ImGuiFontStyle.body; } + } + } + } + CompactButton { x: 12px; y: parent.height - 50px; width: 30px; text: "‹"; enabled: root.connected && root.offset > 0 && !root.loading; clicked => { root.change-page(root.offset - 20); } } + Text { x: 46px; y: parent.height - 50px; width: 112px; height: 24px; text: (root.total > 0 ? root.offset + 1 : 0) + "–" + min(root.offset + 20, root.total) + " / " + root.total; color: #718096; horizontal-alignment: center; vertical-alignment: center; font-size: 11px; } + CompactButton { x: 162px; y: parent.height - 50px; width: 30px; text: "›"; enabled: root.connected && root.offset + 20 < root.total && !root.loading; clicked => { root.change-page(root.offset + 20); } } + CompactButton { x: parent.width - 82px; y: parent.height - 50px; width: 70px; text: MessageStrings.refresh; enabled: root.connected && !root.loading; clicked => { root.refresh(); } } + Text { x: 12px; y: parent.height - 25px; width: parent.width - 24px; height: 22px; text: root.read-save-failed ? MessageStrings.read-save-error : !root.connected ? MessageStrings.offline : root.loading ? MessageStrings.loading : root.failed ? MessageStrings.unavailable : ""; color: root.failed || root.read-save-failed ? #ba342b : #718096; font-size: 11px; overflow: elide; } +} diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index fdf660b4..3f5c6b75 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -26,6 +26,19 @@ inline int ApplyMainWindowStrings( const int language = localization::detail::ClampLanguageIndex(language_index); auto& strings = window->global(); + auto& messages = window->global(); + messages.set_messages(Text(localization::messages[language])); + messages.set_unread(Text(localization::message_unread[language])); + messages.set_read(Text(localization::message_read[language])); + messages.set_empty(Text(localization::message_empty[language])); + messages.set_select_message(Text(localization::message_select[language])); + messages.set_refresh(Text(localization::message_refresh[language])); + messages.set_loading(Text(localization::message_loading[language])); + messages.set_unavailable(Text(localization::message_unavailable[language])); + messages.set_offline(Text(localization::message_offline[language])); + messages.set_read_save_error(Text(localization::message_read_save_error[language])); + messages.set_close(Text(localization::message_close[language])); + strings.set_local_desktop(Text(localization::local_desktop[language])); strings.set_remote_desktop(Text(localization::remote_desktop[language])); strings.set_recent_connections(