[feat] add announcement inbox with local read tracking

This commit is contained in:
dijunkun
2026-09-29 13:04:54 +08:00
parent 597c1dd2c9
commit 2d49024bd6
12 changed files with 761 additions and 6 deletions
@@ -16,6 +16,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <memory>
#include <mutex>
@@ -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<ui::MainWindow> main = ui::MainWindow::create();
std::shared_ptr<slint::VectorModel<ui::ReleaseNoteBlock>>
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<ConfigCenter>(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<int64_t>()) != id.data() ||
std::to_string(item["revision"].get<int64_t>()) != 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<ui::AnnouncementItem> rows;
bool selected_found = false;
for (const auto& item : state.items) {
ui::AnnouncementItem row;
const auto id = std::to_string(item["id"].get<int64_t>());
const auto revision = std::to_string(item["revision"].get<int64_t>());
row.id = UiText(id);
row.revision = UiText(revision);
row.title = UiText(item["title"].get<std::string>());
const auto timestamp =
static_cast<time_t>(item["updated_at"].get<int64_t>());
std::tm local{};
#ifdef _WIN32
localtime_s(&local, &timestamp);
#else
localtime_r(&timestamp, &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<bool>();
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<std::string>()));
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<slint::VectorModel<ui::AnnouncementItem>>(
std::move(rows)));
}
void GuiApplication::SyncMainWindow() {
if (!ui_) {
return;
}
SyncAnnouncements();
#if _WIN32
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
#endif
@@ -56,6 +56,7 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
void SyncAnnouncements();
void SyncConnectionDialog();
void SyncPlatformDialogs();
void SyncStreamWindow();
@@ -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"Файл журнала") \
@@ -0,0 +1,344 @@
#include "runtime/announcement_inbox.h"
#include <openssl/sha.h>
#include <algorithm>
#include <charconv>
#include <fstream>
#include <system_error>
#ifdef _WIN32
#include <windows.h>
#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<const unsigned char*>(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<std::mutex> lock(mutex_);
return state_;
}
uint64_t AnnouncementInbox::Version() const {
std::lock_guard<std::mutex> lock(mutex_);
return state_.version;
}
void AnnouncementInbox::SetStorageDirectory(
const std::filesystem::path& directory) {
std::lock_guard<std::mutex> lock(mutex_);
directory_ = directory;
}
void AnnouncementInbox::Configure(std::string host, int port,
const std::string& device_id) {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(mutex_);
connected_ = connected;
BeginCatalog();
}
void AnnouncementInbox::Refresh(int offset) {
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
for (const auto& item : state_.items) {
if (item["id"] != id || item["revision"] != revision) continue;
if (item["read"].get<bool>()) 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<std::mutex> 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<int>(pending_catalog_.size())
: state_.offset}};
}
void AnnouncementInbox::Failed(const std::string& request_id) {
std::lock_guard<std::mutex> lock(mutex_);
if (request_id == request_id_) Fail();
}
void AnnouncementInbox::Receive(const nlohmann::json& message) {
std::lock_guard<std::mutex> 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<size_t>(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<size_t>((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<const std::string&>().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<int>(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<int>(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<int>(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<std::streamsize>(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
@@ -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 <chrono>
#include <cstdint>
#include <filesystem>
#include <map>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
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<int64_t, int64_t>;
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
@@ -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<std::string> controllers;
{
std::shared_lock lock(connection_status_mutex_);
@@ -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<std::pair<std::string, PrivacyCommand>> privacy_commands_;
@@ -79,7 +79,11 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size,
return;
}
std::string type = j["type"].get<std::string>();
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);
@@ -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";
+116 -4
View File
@@ -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 <bool> signal-connected: false;
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <[AnnouncementItem]> announcements;
in property <int> announcement-unread;
in property <int> announcement-total;
in property <int> announcement-offset;
in property <bool> announcement-loading;
in property <bool> announcement-failed;
in property <bool> announcement-read-save-failed;
in property <bool> announcement-loaded;
in-out property <bool> messages-open: false;
in property <string> announcement-selected-id;
in property <string> announcement-selected-title;
in property <string> announcement-selected-body;
in property <string> announcement-selected-date;
in property <bool> announcement-selected-read;
callback open-announcement(string, string);
callback refresh-announcements;
callback announcement-page(int);
in property <bool> update-available: false;
in property <UpdateCheckStatus> update-check-status: idle;
private property <string> 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 {
+105
View File
@@ -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 <string> messages: "Messages";
in-out property <string> unread: "Unread";
in-out property <string> read: "Read";
in-out property <string> empty: "No announcements";
in-out property <string> select-message: "Select an announcement to read";
in-out property <string> refresh: "Refresh";
in-out property <string> loading: "Loading…";
in-out property <string> unavailable: "Unable to sync. Retry or check server support.";
in-out property <string> offline: "Offline. Reading status is saved on this device.";
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
in-out property <string> close: "Close";
}
export component MessagePanel inherits DialogSurface {
in property <[AnnouncementItem]> items;
in property <int> unread-count;
in property <int> total;
in property <int> offset;
in property <bool> loading;
in property <bool> failed;
in property <bool> read-save-failed;
in property <bool> loaded;
in property <bool> connected;
in property <string> selected-id;
in property <string> selected-title;
in property <string> selected-body;
in property <string> selected-date;
in property <bool> 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; }
}
+13
View File
@@ -26,6 +26,19 @@ inline int ApplyMainWindowStrings(
const int language =
localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->global<ui::UiStrings>();
auto& messages = window->global<ui::MessageStrings>();
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(