mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
Compare commits
3
Commits
597c1dd2c9
...
0031c411e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0031c411e5 | ||
|
|
bc8dbcbf68 | ||
|
|
2d49024bd6 |
@@ -16,6 +16,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -61,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"
|
||||
@@ -906,6 +908,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 +985,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 +1273,49 @@ 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<int64_t> {
|
||||
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_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) {
|
||||
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 +2333,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_.TakePendingRequest();
|
||||
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_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<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, ×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<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(
|
||||
announcement_text::Format(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(slint::StyledText{});
|
||||
}
|
||||
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,18 @@ 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_loading, u8"正在加载…", "Loading…", 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"Файл журнала") \
|
||||
@@ -261,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", \
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
#include "runtime/announcement_inbox.h"
|
||||
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#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;
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
LoadLocalState();
|
||||
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();
|
||||
dismissed_.clear();
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
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() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::LoadMore() {
|
||||
std::lock_guard<std::mutex> 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;
|
||||
}
|
||||
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 (!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<std::mutex> 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<int> retained_pages;
|
||||
int position = 0;
|
||||
phase_ = Phase::Page;
|
||||
refresh_ = false;
|
||||
for (int physical = 0;
|
||||
wanted > 0 && physical < static_cast<int>(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<int64_t> 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<size_t>(wanted)) break;
|
||||
}
|
||||
if (visible.size() == static_cast<size_t>(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<size_t>(wanted)) {
|
||||
std::set<int64_t> retained_ids;
|
||||
for (const auto& item : visible) retained_ids.insert(item["id"].get<int64_t>());
|
||||
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<size_t>(wanted)) break;
|
||||
}
|
||||
}
|
||||
state_.items = std::move(visible);
|
||||
state_.loaded = true;
|
||||
UpdateReadingState();
|
||||
}
|
||||
|
||||
void AnnouncementInbox::Fail() {
|
||||
request_id_.clear();
|
||||
refresh_ = false;
|
||||
state_.loading = false;
|
||||
state_.failed = true;
|
||||
++state_.version;
|
||||
}
|
||||
nlohmann::json AnnouncementInbox::TakePendingRequest(
|
||||
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())
|
||||
: pending_page_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;
|
||||
}
|
||||
if (catalog_revision_ != revision) {
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
}
|
||||
catalog_ = std::move(pending_catalog_);
|
||||
catalog_revision_ = revision;
|
||||
// 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<int>(catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (offset != pending_page_offset_) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
std::set<int64_t> unique;
|
||||
std::vector<int64_t> 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"];
|
||||
}
|
||||
RebuildVisibleItems();
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
}
|
||||
|
||||
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["version"] != 2) ||
|
||||
!data.contains("reads") || !data["reads"].is_object())
|
||||
return;
|
||||
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::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;
|
||||
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(
|
||||
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,78 @@
|
||||
/*
|
||||
* @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>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
// Announcement data is shared; read and dismissed versions stay on this device.
|
||||
class AnnouncementInbox {
|
||||
public:
|
||||
struct Snapshot {
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
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;
|
||||
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();
|
||||
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());
|
||||
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();
|
||||
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_, 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<int, std::vector<int64_t>> page_ids_;
|
||||
std::map<int, nlohmann::json> 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;
|
||||
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);
|
||||
|
||||
@@ -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 <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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<const char*>(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<char>(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
|
||||
@@ -339,6 +339,7 @@ export component MiniToggleSwitch inherits Rectangle {
|
||||
|
||||
export component CompactButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <length> text-size: ImGuiFontStyle.body;
|
||||
in property <bool> primary: false;
|
||||
in property <bool> 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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
@@ -65,6 +66,7 @@ export global UiStrings {
|
||||
in-out property <string> signal-disconnected: "Signal server disconnected";
|
||||
in-out property <string> tls-error: "Signal server TLS certificate error";
|
||||
in-out property <string> update-available: "A new version is available";
|
||||
in-out property <string> new-version: "New version";
|
||||
in-out property <string> check-for-updates: "Check for updates";
|
||||
in-out property <string> checking-for-updates: "Checking for updates…";
|
||||
in-out property <string> up-to-date: "You’re up to date";
|
||||
@@ -273,6 +275,27 @@ 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;
|
||||
private property <string> announcement-unread-label:
|
||||
root.announcement-unread > 99 ? "99+" : "" + root.announcement-unread;
|
||||
in property <int> announcement-total;
|
||||
in property <bool> announcement-loading;
|
||||
in property <bool> announcement-failed;
|
||||
in property <bool> announcement-read-save-failed;
|
||||
in property <bool> announcement-delete-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 <styled-text> announcement-selected-body;
|
||||
in property <string> announcement-selected-date;
|
||||
in property <bool> announcement-selected-read;
|
||||
callback open-announcement(string, string);
|
||||
callback open-announcement-link(string);
|
||||
callback delete-announcement(string, string);
|
||||
callback refresh-announcements;
|
||||
callback load-more-announcements;
|
||||
in property <bool> update-available: false;
|
||||
in property <UpdateCheckStatus> update-check-status: idle;
|
||||
private property <string> update-status-text:
|
||||
@@ -376,8 +399,6 @@ export component MainWindow inherits Window {
|
||||
private property <string> delete-remote-id: "";
|
||||
private property <bool> copy-toast: false;
|
||||
private property <bool> compact-language: root.language-index == 0;
|
||||
private property <bool> update-icon-blink-on:
|
||||
root.update-available && mod(animation-tick(), 2s) < 1s;
|
||||
private property <bool> recent-tooltip-visible: false;
|
||||
private property <string> recent-tooltip-name: "";
|
||||
private property <string> recent-tooltip-host: "";
|
||||
@@ -410,6 +431,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 +648,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,22 +680,64 @@ export component MainWindow inherits Window {
|
||||
icon-size: 14px;
|
||||
hover-background: #e1e5eb;
|
||||
pressed-background: #cbd1d9;
|
||||
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 + 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;
|
||||
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-label;
|
||||
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.
|
||||
@@ -1146,9 +1217,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(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;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
@@ -1169,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;
|
||||
@@ -1181,6 +1253,47 @@ 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: MessageStrings.messages + (root.announcement-unread > 0 ? " · " + MessageStrings.unread + " " + root.announcement-unread : "");
|
||||
accessible-action-default => { messages-touch.clicked(); }
|
||||
menu-messages-label := Text {
|
||||
x: 7px;
|
||||
height: parent.height;
|
||||
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 => {
|
||||
root.menu-open = false;
|
||||
root.messages-open = true;
|
||||
root.refresh-announcements();
|
||||
}
|
||||
}
|
||||
}
|
||||
menu-about := Rectangle {
|
||||
height: 27px;
|
||||
background: about-touch.pressed ? #d8dadd
|
||||
@@ -1189,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;
|
||||
@@ -1226,6 +1342,40 @@ 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;
|
||||
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); }
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
copy-timer := Timer {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons } 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> loading: "Loading…";
|
||||
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";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
in-out property <string> delete-confirmation: "Delete this announcement?";
|
||||
in-out property <string> delete-error: "Unable to delete this announcement. Please try again.";
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
in property <bool> loading;
|
||||
in property <bool> failed;
|
||||
in property <bool> read-save-failed;
|
||||
in property <bool> delete-failed;
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
in property <bool> selected-read;
|
||||
callback open-message(string, string);
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
callback close;
|
||||
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
|
||||
: root.read-save-failed ? MessageStrings.read-save-error : "";
|
||||
private property <length> content-top: root.notice-text == "" ? 42px : 78px;
|
||||
private property <string> pending-delete-id;
|
||||
private property <string> 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; }
|
||||
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;
|
||||
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(); } }
|
||||
}
|
||||
}
|
||||
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: root.content-top; width: 180px; height: parent.height - self.y - 12px;
|
||||
background: white; border-width: 1px; border-color: #e2e5e9;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: 160px; spacing: 3px; padding: 5px;
|
||||
for item in root.items: Rectangle {
|
||||
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 - 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.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: 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 == "" && 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: 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,21 @@ 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_loading(Text(localization::message_loading[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]));
|
||||
strings.set_recent_connections(
|
||||
@@ -82,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]));
|
||||
|
||||
Reference in New Issue
Block a user