From 0031c411e5a71fb6a3b8033aa2611058b8ba91d0 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Tue, 29 Sep 2026 17:16:39 +0800 Subject: [PATCH] [feat] add announcement dismissal and links --- .../src/gui/application/gui_application.cpp | 37 ++- .../assets/localization/localization_data.h | 8 +- .../src/gui/runtime/announcement_inbox.cpp | 247 ++++++++++++++---- .../src/gui/runtime/announcement_inbox.h | 26 +- apps/desktop/src/gui/ui/announcement_text.h | 161 ++++++++++++ apps/desktop/src/gui/ui/common.slint | 3 +- apps/desktop/src/gui/ui/main_window.slint | 118 ++++++--- apps/desktop/src/gui/ui/message_panel.slint | 168 ++++++++++-- apps/desktop/src/gui/ui/ui_localization.h | 9 +- 9 files changed, 636 insertions(+), 141 deletions(-) create mode 100644 apps/desktop/src/gui/ui/announcement_text.h diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index fe58f9a0..40d430a3 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -62,6 +62,7 @@ #endif #include "rd_log.h" #include "server_window_state.h" +#include "ui/announcement_text.h" #include "ui/ui_localization.h" #include "update_checker.h" #include "window_geometry.h" @@ -1272,11 +1273,33 @@ void GuiApplication::ResetSettingsUi() { } void GuiApplication::BindMainCallbacks() { + ui_->main->on_delete_announcement([this](slint::SharedString id, + slint::SharedString revision) { + const auto parse = [](const slint::SharedString& value) + -> std::optional { + const std::string_view text(value.data()); + int64_t number = 0; + const auto result = + std::from_chars(text.data(), text.data() + text.size(), number); + if (result.ec != std::errc{} || + result.ptr != text.data() + text.size() || number <= 0) + return std::nullopt; + return number; + }; + const auto announcement_id = parse(id); + const auto announcement_revision = parse(revision); + if (!announcement_id || !announcement_revision) return; + announcements_.Dismiss(*announcement_id, *announcement_revision); + SyncAnnouncements(); + }); 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_link([this](slint::SharedString link) { + const std::string url(link); + if (announcement_text::IsWebUrl(url)) OpenUrl(url); + }); + ui_->main->on_load_more_announcements([this] { + announcements_.LoadMore(); + SyncAnnouncements(); }); ui_->main->on_open_announcement([this](slint::SharedString id, slint::SharedString revision) { @@ -2326,10 +2349,10 @@ void GuiApplication::SyncAnnouncements() { 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_delete_failed(state.delete_failed); ui_->main->set_announcement_loaded(state.loaded); std::vector rows; bool selected_found = false; @@ -2358,7 +2381,7 @@ void GuiApplication::SyncAnnouncements() { 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())); + announcement_text::Format(item["body"].get())); ui_->main->set_announcement_selected_date(row.date); ui_->main->set_announcement_selected_read(row.read); } @@ -2367,7 +2390,7 @@ void GuiApplication::SyncAnnouncements() { if (!selected_found) { ui_->announcement_selected_id.clear(); ui_->main->set_announcement_selected_id(""); - ui_->main->set_announcement_selected_body(""); + ui_->main->set_announcement_selected_body(slint::StyledText{}); } ui_->main->set_announcements( std::make_shared>( diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 80ded441..2a4777d4 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -25,12 +25,13 @@ struct TranslationRow { 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(message_delete, u8"删除", "Delete", u8"Удалить") \ + X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \ + X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \ + X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", 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"Файл журнала") \ @@ -272,6 +273,7 @@ struct TranslationRow { X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \ X(about, u8"关于", "About", u8"О программе") \ X(notification, u8"通知", "Notification", u8"Уведомление") \ + X(new_version, u8"新版本", "New version", u8"Новая версия") \ X(new_version_available, u8"新版本可用", "New Version Available", \ u8"Доступна новая версия") \ X(check_for_updates, u8"检查更新", "Check for updates", \ diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.cpp b/apps/desktop/src/gui/runtime/announcement_inbox.cpp index 4c9e26a6..c2b1d27f 100644 --- a/apps/desktop/src/gui/runtime/announcement_inbox.cpp +++ b/apps/desktop/src/gui/runtime/announcement_inbox.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #ifdef _WIN32 @@ -62,7 +63,10 @@ void AnnouncementInbox::Configure(std::string host, int port, state_.version = version; catalog_.clear(); catalog_revision_ = -1; - LoadReads(); + page_ids_.clear(); + page_bodies_.clear(); + visible_limit_ = 20; + LoadLocalState(); BeginCatalog(); } void AnnouncementInbox::Reset() { @@ -73,6 +77,10 @@ void AnnouncementInbox::Reset() { scope_.clear(); read_file_.clear(); reads_.clear(); + dismissed_.clear(); + page_ids_.clear(); + page_bodies_.clear(); + visible_limit_ = 20; catalog_.clear(); catalog_revision_ = -1; connected_ = false; @@ -93,20 +101,25 @@ void AnnouncementInbox::SetConnected(bool connected) { connected_ = connected; BeginCatalog(); } -void AnnouncementInbox::Refresh(int offset) { +void AnnouncementInbox::Refresh() { 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_; - } + BeginCatalog(); +} +void AnnouncementInbox::LoadMore() { + std::lock_guard lock(mutex_); + if (!connected_ || state_.loading || refresh_ || state_.failed || + catalog_revision_ < 0 || visible_limit_ >= state_.total) + return; + visible_limit_ += (std::min)(20, state_.total - visible_limit_); + RebuildVisibleItems(); + ++state_.version; } void AnnouncementInbox::UpdateReadingState() { + state_.total = 0; state_.unread = 0; for (const auto& [id, revision] : catalog_) { + if (IsDismissed(id, revision)) continue; + ++state_.total; const auto read = reads_.find(id); if (read == reads_.end() || read->second != revision) ++state_.unread; } @@ -122,19 +135,127 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) { if (item["read"].get()) return true; auto updated = reads_; updated[id] = revision; - if (!SaveReads(updated)) { + if (!SaveLocalState(updated, dismissed_)) { state_.read_save_failed = true; ++state_.version; return false; } reads_ = std::move(updated); state_.read_save_failed = false; + state_.delete_failed = false; UpdateReadingState(); ++state_.version; return true; } return false; } +bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const { + const auto found = dismissed_.find(id); + return found != dismissed_.end() && found->second == revision; +} + +bool AnnouncementInbox::Dismiss(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; + auto updated = dismissed_; + updated[id] = revision; + if (!SaveLocalState(reads_, updated)) { + state_.delete_failed = true; + ++state_.version; + return false; + } + dismissed_ = std::move(updated); + state_.delete_failed = false; + state_.read_save_failed = false; + // An index refresh already in flight must finish before reusing its order. + if (phase_ == Phase::Catalog && (state_.loading || refresh_)) { + auto visible = json::array(); + for (const auto& current : state_.items) + if (!IsDismissed(current["id"], current["revision"])) + visible.push_back(current); + state_.items = std::move(visible); + UpdateReadingState(); + } else { + request_id_.clear(); + state_.loading = false; + RebuildVisibleItems(); + } + ++state_.version; + return true; + } + // The confirmation may refer to an announcement updated while it was open. + state_.delete_failed = true; + ++state_.version; + return false; +} + +void AnnouncementInbox::RebuildVisibleItems() { + UpdateReadingState(); + const int wanted = (std::min)(visible_limit_, state_.total); + auto visible = json::array(); + std::set retained_pages; + int position = 0; + phase_ = Phase::Page; + refresh_ = false; + for (int physical = 0; + wanted > 0 && physical < static_cast(catalog_.size()); + physical += 20) { + const auto ids = page_ids_.find(physical); + if (ids == page_ids_.end()) { + pending_page_offset_ = physical; + refresh_ = connected_; + break; + } + std::set page_selection; + for (auto id : ids->second) { + const auto entry = catalog_.find(id); + if (entry == catalog_.end() || IsDismissed(id, entry->second)) continue; + if (position < wanted) page_selection.insert(id); + ++position; + } + if (page_selection.empty()) { + page_bodies_.erase(physical); + continue; + } + retained_pages.insert(physical); + const auto bodies = page_bodies_.find(physical); + if (bodies == page_bodies_.end()) { + pending_page_offset_ = physical; + refresh_ = connected_; + break; + } + for (const auto& item : bodies->second) + if (page_selection.count(item["id"])) visible.push_back(item); + if (visible.size() == static_cast(wanted)) break; + } + if (visible.size() == static_cast(wanted)) { + for (auto it = page_bodies_.begin(); it != page_bodies_.end();) + if (!retained_pages.count(it->first)) + it = page_bodies_.erase(it); + else + ++it; + } + // A failed body fetch must not erase announcements already received. Keep + // still-published, unchanged versions until the replacement batch arrives. + if (visible.size() < static_cast(wanted)) { + std::set retained_ids; + for (const auto& item : visible) retained_ids.insert(item["id"].get()); + for (const auto& item : state_.items) { + const int64_t id = item["id"]; + const auto current = catalog_.find(id); + if (current == catalog_.end() || current->second != item["revision"] || + IsDismissed(id, current->second) || !retained_ids.insert(id).second) + continue; + visible.push_back(item); + if (visible.size() == static_cast(wanted)) break; + } + } + state_.items = std::move(visible); + state_.loaded = true; + UpdateReadingState(); +} + void AnnouncementInbox::Fail() { request_id_.clear(); refresh_ = false; @@ -159,7 +280,7 @@ nlohmann::json AnnouncementInbox::TakePendingRequest( {"summary_only", phase_ == Phase::Catalog}, {"offset", phase_ == Phase::Catalog ? static_cast(pending_catalog_.size()) - : state_.offset}}; + : pending_page_offset_}}; } void AnnouncementInbox::Failed(const std::string& request_id) { std::lock_guard lock(mutex_); @@ -248,75 +369,93 @@ void AnnouncementInbox::Receive(const nlohmann::json& message) { ++state_.version; return; } + if (catalog_revision_ != revision) { + page_ids_.clear(); + page_bodies_.clear(); + } 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; + // Prune expired records in memory; the next local action persists them. + for (auto* records : {&reads_, &dismissed_}) { + for (auto it = records->begin(); it != records->end();) + if (!catalog_.count(it->first)) + it = records->erase(it); + else + ++it; } } else { if (total != static_cast(catalog_.size())) { Fail(); return; } - state_.items = message["items"]; - state_.offset = offset; - state_.loaded = true; + if (offset != pending_page_offset_) { + Fail(); + return; + } + std::set unique; + std::vector ids; + for (const auto& item : message["items"]) { + const int64_t id = item["id"]; + if (!unique.insert(id).second) { + Fail(); + return; + } + ids.push_back(id); + } + page_ids_[offset] = std::move(ids); + page_bodies_[offset] = message["items"]; } - UpdateReadingState(); + RebuildVisibleItems(); state_.failed = false; ++state_.version; } -void AnnouncementInbox::LoadReads() { +void AnnouncementInbox::LoadLocalState() { reads_.clear(); + dismissed_.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("version") || + (data["version"] != 1 && data["version"] != 2) || !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(); - } + const auto load = [](const json& entries, Revisions& destination) { + if (!entries.is_object()) return; + for (const auto& entry : entries.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) + destination[id] = entry.value(); + } + }; + load(data["reads"], reads_); + if (data.contains("dismissed")) load(data["dismissed"], dismissed_); } -bool AnnouncementInbox::SaveReads(const Revisions& reads) const { +bool AnnouncementInbox::SaveLocalState(const Revisions& reads, + const Revisions& dismissed) 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(); + const auto encode = [](const Revisions& records) { + auto entries = json::object(); + for (const auto& [id, revision] : records) + entries[std::to_string(id)] = revision; + return entries; + }; + const auto payload = json({{"version", 2}, + {"scope", scope_}, + {"reads", encode(reads)}, + {"dismissed", encode(dismissed)}}) + .dump(); auto temporary = read_file_; temporary += ".tmp-" + std::to_string( diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.h b/apps/desktop/src/gui/runtime/announcement_inbox.h index 4705cb89..3fee9093 100644 --- a/apps/desktop/src/gui/runtime/announcement_inbox.h +++ b/apps/desktop/src/gui/runtime/announcement_inbox.h @@ -14,16 +14,18 @@ #include #include #include +#include namespace crossdesk { -// Announcement data is shared; reading state only persists to the local file. +// Announcement data is shared; read and dismissed versions stay on this device. class AnnouncementInbox { public: struct Snapshot { nlohmann::json items = nlohmann::json::array(); - int offset = 0, total = 0, unread = 0; + int total = 0, unread = 0; bool loaded = false, loading = false, failed = false; bool read_save_failed = false; + bool delete_failed = false; uint64_t version = 0; }; Snapshot Get() const; @@ -33,10 +35,12 @@ class AnnouncementInbox { void Configure(std::string host, int port, const std::string& device_id); void Reset(); void SetConnected(bool connected); - void Refresh(int offset = -1); + void Refresh(); + void LoadMore(); bool Read(int64_t id, int64_t revision); + bool Dismiss(int64_t id, int64_t revision); nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now = - std::chrono::steady_clock::now()); + std::chrono::steady_clock::now()); void Failed(const std::string& request_id); void Receive(const nlohmann::json& message); @@ -46,14 +50,22 @@ class AnnouncementInbox { void BeginCatalog(); void Fail(); void UpdateReadingState(); - void LoadReads(); - bool SaveReads(const Revisions& reads) const; + bool IsDismissed(int64_t id, int64_t revision) const; + void RebuildVisibleItems(); + void LoadLocalState(); + bool SaveLocalState(const Revisions& reads, const Revisions& dismissed) const; mutable std::mutex mutex_; Snapshot state_; std::filesystem::path directory_, read_file_; std::string scope_, request_id_; - Revisions reads_, catalog_, pending_catalog_; + Revisions reads_, dismissed_, catalog_, pending_catalog_; + // Keep bodies for the loaded list prefix. Lightweight page IDs let us skip + // locally deleted entries without sending deletion state upstream. + std::map> page_ids_; + std::map page_bodies_; + int pending_page_offset_ = 0; + int visible_limit_ = 20; int64_t catalog_revision_ = -1, pending_revision_ = -1; int pending_total_ = 0; Phase phase_ = Phase::Catalog; diff --git a/apps/desktop/src/gui/ui/announcement_text.h b/apps/desktop/src/gui/ui/announcement_text.h new file mode 100644 index 00000000..8259eb47 --- /dev/null +++ b/apps/desktop/src/gui/ui/announcement_text.h @@ -0,0 +1,161 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-29 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _ANNOUNCEMENT_TEXT_H_ +#define _ANNOUNCEMENT_TEXT_H_ + +#include + +#include +#include +#include +#include + +namespace crossdesk::announcement_text { + +inline size_t WebSchemeLength(std::string_view url) { + for (const std::string_view prefix : {"https://", "http://"}) { + if (url.size() >= prefix.size() && + std::equal(prefix.begin(), prefix.end(), url.begin(), + [](char expected, unsigned char actual) { + return expected == std::tolower(actual); + })) { + return prefix.size(); + } + } + return 0; +} + +inline bool IsWebUrl(std::string_view url) { + const size_t scheme = WebSchemeLength(url); + if (!scheme || url.size() == scheme || + std::any_of(url.begin(), url.end(), [](unsigned char ch) { + return ch <= 0x20 || ch == 0x7f || ch == '\\' || ch == '<' || + ch == '>' || ch == '"'; + })) { + return false; + } + const auto authority = + url.substr(scheme, url.find_first_of("/?#", scheme) - scheme); + return !authority.empty() && authority.back() != '@'; +} + +namespace detail { + +inline size_t UrlEnd(std::string_view text, size_t start) { + // Do not include surrounding prose punctuation in a bare URL. Balanced + // parentheses and IPv6 brackets are part of the address when present. + const std::string_view punctuation = + reinterpret_cast(u8",。;:!?、()【】《》「」『』“”‘’"); + int parentheses = 0, brackets = 0; + size_t end = start; + for (; end < text.size(); ++end) { + const unsigned char ch = text[end]; + if (ch <= 0x20 || ch == 0x7f || + std::string_view("<>\"'`\\{}").find(ch) != std::string_view::npos || + (ch >= 0xe0 && end + 3 <= text.size() && + punctuation.find(text.substr(end, 3)) != std::string_view::npos)) { + break; + } + if (ch == '(') ++parentheses; + if (ch == ')' && parentheses-- == 0) break; + if (ch == '[') ++brackets; + if (ch == ']' && brackets-- == 0) break; + } + return end; +} + +inline void AppendLiteral(std::string& output, std::string_view text) { + for (const unsigned char ch : text) { + // Entity-encode spaces so leading indentation is not parsed as code. + if (ch == ' ') { + output += " "; + } else if (ch == '\t') { + output += " "; + } else { + if (ch < 0x80 && std::ispunct(ch)) output += '\\'; + output += static_cast(ch); + } + } +} + +inline void AppendLink(std::string& output, std::string_view label, + std::string_view url) { + output += '['; + AppendLiteral(output, label); + output += "](<"; + for (const char ch : url) { + // Markdown decodes entities in link destinations; keep query strings exact. + if (ch == '&') + output += "&"; + else + output += ch; + } + output += ">)"; +} + +} // namespace detail + +inline slint::StyledText Format(std::string_view body) { + std::string markdown; + markdown.reserve(body.size()); + bool has_link = false; + for (size_t pos = 0; pos < body.size();) { + if (body[pos] == '[') { + const size_t label_end = body.find("](", pos + 1); + if (label_end != std::string_view::npos && label_end > pos + 1 && + body.substr(pos + 1, label_end - pos - 1).find_first_of("[]\r\n") == + std::string_view::npos && + WebSchemeLength(body.substr(label_end + 2))) { + const size_t end = detail::UrlEnd(body, label_end + 2); + const auto url = body.substr(label_end + 2, end - label_end - 2); + if (end < body.size() && body[end] == ')' && IsWebUrl(url)) { + detail::AppendLink(markdown, + body.substr(pos + 1, label_end - pos - 1), url); + pos = end + 1; + has_link = true; + continue; + } + } + } + if (WebSchemeLength(body.substr(pos))) { + size_t end = detail::UrlEnd(body, pos); + while (end > pos && std::string_view(".,;:!?").find(body[end - 1]) != + std::string_view::npos) { + --end; + } + const auto url = body.substr(pos, end - pos); + if (IsWebUrl(url)) { + detail::AppendLink(markdown, url, url); + pos = end; + has_link = true; + continue; + } + } + if (body[pos] == '\r' && pos + 1 < body.size() && body[pos + 1] == '\n') { + ++pos; + continue; + } + if (body[pos] == '\n') { + // Preserve empty lines instead of letting Markdown collapse them. + if (markdown.empty() || markdown.back() == '\n') markdown += " "; + markdown += '\n'; + } else { + detail::AppendLiteral(markdown, body.substr(pos, 1)); + } + ++pos; + } + if (has_link) { + if (markdown.back() == '\n') markdown += " "; + if (const auto styled = slint::StyledText::from_markdown(markdown)) + return *styled; + } + return slint::StyledText::from_plain_text(body); +} + +} // namespace crossdesk::announcement_text + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 21b376bf..b54c4cd8 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -339,6 +339,7 @@ export component MiniToggleSwitch inherits Rectangle { export component CompactButton inherits Rectangle { in property text; + in property text-size: ImGuiFontStyle.body; in property primary: false; in property enabled: true; callback clicked; @@ -355,7 +356,7 @@ export component CompactButton inherits Rectangle { Text { text: root.text; color: root.enabled ? #24272d : #8e9298; - font-size: ImGuiFontStyle.body; + font-size: root.text-size; overflow: elide; horizontal-alignment: center; vertical-alignment: center; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 780d52fe..d017bf0c 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -66,6 +66,7 @@ export global UiStrings { in-out property signal-disconnected: "Signal server disconnected"; in-out property tls-error: "Signal server TLS certificate error"; in-out property update-available: "A new version is available"; + in-out property new-version: "New version"; in-out property check-for-updates: "Check for updates"; in-out property checking-for-updates: "Checking for updates…"; in-out property up-to-date: "You’re up to date"; @@ -276,21 +277,25 @@ export component MainWindow inherits Window { in property <[RecentConnection]> recent-connections; in property <[AnnouncementItem]> announcements; in property announcement-unread; + private property announcement-unread-label: + root.announcement-unread > 99 ? "99+" : "" + root.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-delete-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-body; in property announcement-selected-date; in property announcement-selected-read; callback open-announcement(string, string); + callback open-announcement-link(string); + callback delete-announcement(string, string); callback refresh-announcements; - callback announcement-page(int); + callback load-more-announcements; in property update-available: false; in property update-check-status: idle; private property update-status-text: @@ -394,8 +399,6 @@ export component MainWindow inherits Window { private property delete-remote-id: ""; private property copy-toast: false; private property compact-language: root.language-index == 0; - private property update-icon-blink-on: - root.update-available && mod(animation-tick(), 2s) < 1s; private property recent-tooltip-visible: false; private property recent-tooltip-name: ""; private property recent-tooltip-host: ""; @@ -677,23 +680,37 @@ export component MainWindow inherits Window { icon-size: 14px; hover-background: #e1e5eb; pressed-background: #cbd1d9; - tooltip: root.announcement-unread > 0 ? MessageStrings.unread + " " + root.announcement-unread : ""; + tooltip: root.announcement-unread > 0 + ? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "") + : root.update-available ? UiStrings.update-available : ""; 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 + (root.announcement-unread > 0 ? -18px : 16px); - y: menu-button.y + 1px; + // Unread messages take precedence so the menu has only one indicator. + if root.update-available && root.announcement-unread <= 0: Rectangle { + x: menu-button.x + menu-button.width - self.width + 4px; + y: menu-button.y - 1px; width: 16px; height: 16px; - text: FontAwesomeIcons.circle-arrow-up; - color: #24272d; - font-family: "Font Awesome 6 Free"; - font-weight: 900; - font-size: 13px; - horizontal-alignment: center; - vertical-alignment: center; + border-radius: 8px; + border-width: 1px; + border-color: white; + background: #2563eb; + opacity: root.menu-open || mod(animation-tick(), 2s) < 1s ? 1 : 0; z: 9; + accessible-role: button; + accessible-label: UiStrings.update-available; + accessible-action-default => { root.menu-open = !root.menu-open; } + Text { + text: "!"; color: white; + font-size: 12px; font-weight: 700; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + TouchArea { + mouse-cursor: pointer; + clicked => { root.menu-open = !root.menu-open; } + } } if root.announcement-unread > 0: Rectangle { x: menu-button.x + menu-button.width - self.width + 4px; @@ -709,7 +726,7 @@ export component MainWindow inherits Window { 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; + text: root.announcement-unread-label; color: white; font-size: 10px; font-weight: 600; @@ -1200,9 +1217,9 @@ 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: max(menu-settings-label.preferred-width, - max(menu-messages-label.preferred-width, - menu-about-label.preferred-width + (root.update-available ? 25px : 0px))) + 28px; + width: max(80px, max(menu-settings-label.preferred-width, + max(menu-messages-label.preferred-width + (root.announcement-unread > 0 ? menu-message-badge.width + 8px : 0px), + menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 24px); height: 93px; background: white; border-width: 1px; @@ -1224,7 +1241,7 @@ export component MainWindow inherits Window { accessible-label: UiStrings.settings; accessible-action-default => { settings-touch.clicked(); } menu-settings-label := Text { - x: 9px; + x: 7px; height: parent.height; text: UiStrings.settings; color: #2d3138; @@ -1242,16 +1259,32 @@ export component MainWindow inherits Window { : messages-touch.has-hover ? #f0f1f2 : white; border-radius: 3px; accessible-role: button; - accessible-label: menu-messages-label.text; + accessible-label: MessageStrings.messages + (root.announcement-unread > 0 ? " · " + MessageStrings.unread + " " + root.announcement-unread : ""); accessible-action-default => { messages-touch.clicked(); } menu-messages-label := Text { - x: 9px; + x: 7px; height: parent.height; - text: MessageStrings.messages + (root.announcement-unread > 0 ? " (" + root.announcement-unread + ")" : ""); + text: MessageStrings.messages; color: #2d3138; vertical-alignment: center; accessible-role: none; } + menu-message-badge := Rectangle { + visible: root.announcement-unread > 0; + x: parent.width - self.width - 7px; + y: (parent.height - self.height) / 2; + width: max(18px, menu-message-count.preferred-width + 10px); + height: 18px; + border-radius: 9px; + background: #fee4e2; + accessible-role: none; + menu-message-count := Text { + text: root.announcement-unread-label; + font-size: 10px; font-weight: 600; color: #b42318; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + } messages-touch := TouchArea { mouse-cursor: pointer; clicked => { @@ -1269,28 +1302,31 @@ export component MainWindow inherits Window { border-width: 0px; border-radius: 3px; accessible-role: button; - accessible-label: UiStrings.about; + accessible-label: UiStrings.about + (root.update-available ? " · " + UiStrings.update-available : ""); accessible-action-default => { about-touch.clicked(); } menu-about-label := Text { - x: 9px; + x: 7px; height: parent.height; text: UiStrings.about; color: #2d3138; vertical-alignment: center; accessible-role: none; } - if root.update-available && root.update-icon-blink-on: Text { - x: menu-about-label.x + menu-about-label.preferred-width + 7px; - width: 18px; - height: parent.height; - text: FontAwesomeIcons.circle-arrow-up; - color: #2d3138; - font-family: "Font Awesome 6 Free"; - font-weight: 900; - font-size: 16px; - horizontal-alignment: center; - vertical-alignment: center; + menu-update-badge := Rectangle { + visible: root.update-available; + x: parent.width - self.width - 7px; + y: (parent.height - self.height) / 2; + width: menu-update-label.preferred-width + 12px; + height: 18px; + border-radius: 9px; + background: #dbeafe; accessible-role: none; + menu-update-label := Text { + text: UiStrings.new-version; + font-size: 11px; font-weight: 400; color: #1d4ed8; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } } about-touch := TouchArea { mouse-cursor: pointer; @@ -1326,16 +1362,18 @@ export component MainWindow inherits Window { width: 520px; height: 330px; items: root.announcements; - unread-count: root.announcement-unread; total: root.announcement-total; offset: root.announcement-offset; + unread-count: root.announcement-unread; total: root.announcement-total; loading: root.announcement-loading; failed: root.announcement-failed; loaded: root.announcement-loaded; read-save-failed: root.announcement-read-save-failed; + delete-failed: root.announcement-delete-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); } + open-link(url) => { root.open-announcement-link(url); } + delete-message(id, revision) => { root.delete-announcement(id, revision); } + load-more => { root.load-more-announcements(); } close => { root.messages-open = false; } } } diff --git a/apps/desktop/src/gui/ui/message_panel.slint b/apps/desktop/src/gui/ui/message_panel.slint index 05affa82..a193b068 100644 --- a/apps/desktop/src/gui/ui/message_panel.slint +++ b/apps/desktop/src/gui/ui/message_panel.slint @@ -1,5 +1,5 @@ import { ScrollView } from "std-widgets.slint"; -import { CompactButton, DialogSurface, ImGuiFontStyle } from "common.slint"; +import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint"; export struct AnnouncementItem { id: string, @@ -15,40 +15,88 @@ export global MessageStrings { 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"; + in-out property delete-message: "Delete"; + in-out property delete-title: "Delete announcement"; + in-out property delete-confirmation: "Delete this announcement?"; + in-out property delete-error: "Unable to delete this announcement. Please try again."; + in-out property cancel: "Cancel"; } 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 delete-failed; in property loaded; in property connected; in property selected-id; in property selected-title; - in property selected-body; + in property selected-body; in property selected-date; in property selected-read; callback open-message(string, string); - callback refresh; - callback change-page(int); + callback open-link(string); + callback delete-message(string, string); + callback load-more; callback close; + private property notice-text: root.delete-failed ? MessageStrings.delete-error + : root.read-save-failed ? MessageStrings.read-save-error : ""; + private property content-top: root.notice-text == "" ? 42px : 78px; + private property pending-delete-id; + private property pending-delete-revision; + function request-delete(id: string, revision: string) { + root.pending-delete-id = id; + root.pending-delete-revision = revision; + confirmation-keys.focus(); + } + confirmation-keys := FocusScope { + width: 0px; height: 0px; + key-pressed(event) => { + if event.text == Key.Escape { + if root.pending-delete-id != "" { root.pending-delete-id = ""; } + else { root.close(); } + return accept; + } + return reject; + } + } 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; } + message-heading := Text { + x: 12px; width: parent.width - 100px; height: parent.height; + text: MessageStrings.messages; + font-size: 14px; font-weight: 400; color: #111827; + vertical-alignment: center; overflow: elide; + } + if root.unread-count > 0: Rectangle { + x: message-heading.x + message-heading.preferred-width + 8px; + y: 5px; height: 20px; + width: max(20px, unread-count-label.preferred-width + 12px); + border-radius: 10px; background: #dbeafe; + accessible-role: text; + accessible-label: MessageStrings.unread + " " + root.unread-count; + unread-count-label := Text { + text: "" + root.unread-count; color: #1d4ed8; + font-size: 12px; font-weight: 600; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + } + if root.loading: Text { + x: parent.width - 150px; width: 110px; height: parent.height; + text: MessageStrings.loading; color: #4b5563; font-size: 12px; + horizontal-alignment: right; vertical-alignment: center; + } Rectangle { x: parent.width - 31px; y: 3px; width: 25px; height: 24px; border-radius: 3px; @@ -60,46 +108,114 @@ export component MessagePanel inherits DialogSurface { close-touch := TouchArea { mouse-cursor: pointer; clicked => { root.close(); } } } } + if root.notice-text != "": Rectangle { + x: 12px; y: 40px; width: parent.width - 24px; height: 28px; + border-radius: 4px; + background: #fef2f2; + Text { + x: 8px; width: parent.width - 16px; height: parent.height; + text: root.notice-text; font-size: 12px; overflow: elide; + color: #b42318; + vertical-alignment: center; + } + } Rectangle { - x: 12px; y: 42px; width: 180px; height: parent.height - 100px; + x: 12px; y: root.content-top; width: 180px; height: parent.height - self.y - 12px; background: white; border-width: 1px; border-color: #e2e5e9; - ScrollView { + announcements-scroll := ScrollView { width: parent.width; height: parent.height; VerticalLayout { width: 160px; spacing: 3px; padding: 5px; for item in root.items: Rectangle { - height: 72px; width: 150px; + height: 56px; 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; } + Text { x: 8px; y: 5px; width: parent.width - 28px; height: 22px; text: item.title; overflow: elide; font-size: 14px; font-weight: item.read ? 400 : 600; color: item.read ? #374151 : #111827; vertical-alignment: center; } + if !item.read: Rectangle { + x: parent.width - 18px; y: 13px; width: 6px; height: 6px; + border-radius: 3px; background: #2563eb; + accessible-role: none; + } + Text { x: 8px; y: 32px; width: parent.width - 42px; text: item.date; color: #4b5563; font-size: 12px; 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); } } + IconButton { + x: parent.width - 25px; y: 29px; width: 20px; height: 20px; + icon: FontAwesomeIcons.xmark; icon-size: 12px; + icon-color: #667085; danger: true; + accessible-role: button; + accessible-label: MessageStrings.delete-message + " " + item.title; + accessible-action-default => { root.request-delete(item.id, item.revision); } + clicked => { root.request-delete(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; } + if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.empty; wrap: word-wrap; color: #4b5563; font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } } Rectangle { - x: 202px; y: 42px; width: parent.width - 214px; height: parent.height - 100px; + x: 202px; y: root.content-top; width: parent.width - 214px; height: parent.height - self.y - 12px; 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 == "" && root.items.length > 0: Text { x: 14px; width: parent.width - 28px; text: MessageStrings.select-message; color: #4b5563; font-size: 14px; 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; } + Text { vertical-stretch: 0; text: root.selected-title; wrap: word-wrap; font-size: 16px; font-weight: 600; color: #202124; } + Text { vertical-stretch: 0; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #4b5563; font-size: 12px; wrap: word-wrap; } + StyledText { + vertical-stretch: 0; + text: root.selected-body; + default-color: #202124; default-font-size: 14px; + link-color: #2563eb; + link-clicked(url) => { root.open-link(url); } + } + } + } + } + Timer { + interval: 100ms; + running: root.connected && !root.loading && !root.failed + && root.pending-delete-id == "" && root.items.length > 0 + && root.items.length < root.total && announcements-scroll.viewport-height > 0px + && announcements-scroll.viewport-height + announcements-scroll.viewport-y <= announcements-scroll.height + 80px; + triggered => { root.load-more(); } + } + if root.pending-delete-id != "": Rectangle { + background: #00000042; border-radius: 8px; z: 30; + TouchArea { clicked => { confirmation-keys.focus(); } } + DialogSurface { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 230px; height: confirmation-content.preferred-height; + TouchArea { clicked => { confirmation-keys.focus(); } } + confirmation-content := VerticalLayout { + padding: 16px; + padding-top: 26px; + padding-bottom: 26px; + spacing: 20px; + Text { text: MessageStrings.delete-confirmation; font-size: 14px; color: #202124; wrap: word-wrap; horizontal-alignment: center; } + HorizontalLayout { + alignment: center; spacing: 8px; + cancel-delete-button := CompactButton { + width: 72px; text-size: 13px; text: MessageStrings.cancel; + accessible-role: button; accessible-label: MessageStrings.cancel; + accessible-action-default => { cancel-delete-button.clicked(); } + clicked => { root.pending-delete-id = ""; } + } + confirm-delete-button := CompactButton { + width: 72px; text-size: 13px; text: MessageStrings.delete-message; primary: true; + accessible-role: button; accessible-label: MessageStrings.delete-title; + accessible-action-default => { confirm-delete-button.clicked(); } + clicked => { + root.delete-message(root.pending-delete-id, root.pending-delete-revision); + root.pending-delete-id = ""; + } + } + } } } } - 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 3f5c6b75..0d46ad33 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -32,12 +32,14 @@ inline int ApplyMainWindowStrings( 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])); + messages.set_delete_message(Text(localization::message_delete[language])); + messages.set_delete_title(Text(localization::message_delete_title[language])); + messages.set_delete_confirmation(Text(localization::message_delete_confirmation[language])); + messages.set_delete_error(Text(localization::message_delete_error[language])); + messages.set_cancel(Text(localization::cancel[language])); strings.set_local_desktop(Text(localization::local_desktop[language])); strings.set_remote_desktop(Text(localization::remote_desktop[language])); @@ -95,6 +97,7 @@ inline int ApplyMainWindowStrings( strings.set_tls_error(Text(localization::signal_tls_cert_error[language])); strings.set_update_available( Text(localization::new_version_available[language])); + strings.set_new_version(Text(localization::new_version[language])); strings.set_check_for_updates(Text(localization::check_for_updates[language])); strings.set_checking_for_updates( Text(localization::checking_for_updates[language]));